From 47c2d0aba9999200b6f43da0475ffdb12902977e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:19:54 +0800 Subject: [PATCH 01/76] fix(auth): define reset and delete safety flows --- README.md | 9 +- docs/troubleshooting.md | 44 +- index.ts | 56 +- lib/cli.ts | 119 +++- lib/codex-manager.ts | 88 ++- lib/destructive-actions.ts | 196 ++++++ lib/quota-cache.ts | 66 +- lib/storage.ts | 109 ++-- lib/ui/auth-menu.ts | 372 ++++++++--- lib/ui/copy.ts | 34 +- test/auth-menu-hotkeys.test.ts | 103 +++ test/cli-auth-menu.test.ts | 32 +- test/cli.test.ts | 787 +++++++++++++++-------- test/codex-manager-cli.test.ts | 324 +++++++++- test/destructive-actions.test.ts | 301 +++++++++ test/quota-cache.test.ts | 106 ++- test/release-main-prs-regression.test.ts | 2 +- test/storage-flagged.test.ts | 167 ++--- test/storage.test.ts | 319 ++++++++- 19 files changed, 2638 insertions(+), 596 deletions(-) create mode 100644 lib/destructive-actions.ts create mode 100644 test/destructive-actions.test.ts diff --git a/README.md b/README.md index 8bcfe401..0ede89bd 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Codex CLI-first multi-account OAuth manager for the official `@openai/codex` CLI ### Option A: Standard install ```bash +npm i -g @openai/codex npm i -g codex-multi-auth ``` @@ -74,16 +75,18 @@ codex auth status ### Step-by-step -1. Install global package: +1. Install global packages: + - `npm i -g @openai/codex` - `npm i -g codex-multi-auth` 2. Run first login flow with `codex auth login` -3. Validate state with `codex auth status` and `codex auth check` +3. Validate state with `codex auth list` and `codex auth check` 4. Confirm routing with `codex auth forecast --live` ### Verification ```bash codex auth status +codex auth list codex auth check ``` @@ -95,7 +98,7 @@ codex auth check ```bash codex auth login -codex auth status +codex auth list codex auth check codex auth forecast --live ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad0a67e5..0c0bbe86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -89,24 +89,52 @@ codex auth doctor --json --- -## Soft Reset +## Reset Options -PowerShell: +- Delete a single saved account: `codex auth login` → pick account → **Delete Account** +- Delete saved accounts: `codex auth login` → Danger Zone → **Delete Saved Accounts** +- Reset local state: `codex auth login` → Danger Zone → **Reset Local State** + +Exact effects: + +| Action | Saved accounts | Flagged/problem accounts | Settings | Codex CLI sync state | Quota cache | +| --- | --- | --- | --- | --- | --- | +| Delete Account | Delete the selected saved account | Delete the matching flagged/problem entry for that refresh token | Keep | Keep | Keep | +| Delete Saved Accounts | Delete all saved accounts | Keep | Keep | Keep | Keep | +| Reset Local State | Delete all saved accounts | Delete all flagged/problem accounts | Keep | Keep | Clear | + +To perform the same actions manually: + +Delete saved accounts only: ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\settings.json" -Force -ErrorAction SilentlyContinue -codex auth login +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +``` + +```bash +rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* ``` -Bash: +Reset local state (also clears flagged/problem accounts and quota cache; preserves settings and Codex CLI sync state): + +```powershell +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\quota-cache.json" -Force -ErrorAction SilentlyContinue +``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* rm -f ~/.codex/multi-auth/openai-codex-flagged-accounts.json -rm -f ~/.codex/multi-auth/settings.json -codex auth login +rm -f ~/.codex/multi-auth/quota-cache.json ``` --- diff --git a/index.ts b/index.ts index 147959c4..1db25ec2 100644 --- a/index.ts +++ b/index.ts @@ -101,6 +101,12 @@ import { } from "./lib/logger.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./lib/destructive-actions.js"; import { AccountManager, getAccountIdCandidates, @@ -122,13 +128,11 @@ import { loadAccounts, saveAccounts, withAccountStorageTransaction, - clearAccounts, setStoragePath, exportAccounts, importAccounts, loadFlaggedAccounts, saveFlaggedAccounts, - clearFlaggedAccounts, findMatchingAccountIndex, StorageError, formatStorageErrorHint, @@ -3101,19 +3105,18 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "manage") { if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); + const deleted = await deleteAccountAtIndex({ + storage: workingStorage, + index: menuResult.deleteAccountIndex, + }); + if (deleted) { invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); + const label = `Account ${menuResult.deleteAccountIndex + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`\nDeleted ${label}.${flaggedNote}\n`); } continue; } @@ -3143,16 +3146,35 @@ while (attempted.size < Math.max(1, accountCount)) { if (menuResult.mode === "fresh") { startFresh = true; if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + const result = await deleteSavedAccounts(); invalidateAccountManagerCache(); console.log( - "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + `\n${ + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs." + }\n`, ); } break; } + if (menuResult.mode === "reset") { + startFresh = true; + const result = await resetLocalState(); + invalidateAccountManagerCache(); + console.log( + `\n${ + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs." + }\n`, + ); + break; + } + startFresh = false; break; } diff --git a/lib/cli.ts b/lib/cli.ts index d223c14c..363b1b2b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,11 +1,12 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js"; import type { AccountIdSource } from "./types.js"; import { - showAuthMenu, - showAccountDetails, - isTTY, type AccountStatus, + isTTY, + showAccountDetails, + showAuthMenu, } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; @@ -19,12 +20,15 @@ export function isNonInteractiveMode(): boolean { if (!input.isTTY || !output.isTTY) return true; if (process.env.CODEX_TUI === "1") return true; if (process.env.CODEX_DESKTOP === "1") return true; - if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") + return true; if (process.env.ELECTRON_RUN_AS_NODE === "1") return true; return false; } -export async function promptAddAnotherAccount(currentCount: number): Promise { +export async function promptAddAnotherAccount( + currentCount: number, +): Promise { if (isNonInteractiveMode()) { return false; } @@ -32,7 +36,9 @@ export async function promptAddAnotherAccount(currentCount: number): Promise 6 ? account.accountId.slice(-6) : account.accountId; + const suffix = + account.accountId.length > 6 + ? account.accountId.slice(-6) + : account.accountId; return `${num}. ${suffix}`; } return `${num}. Account`; @@ -112,7 +127,8 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string function resolveAccountSourceIndex(account: ExistingAccountInfo): number { const sourceIndex = - typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + typeof account.sourceIndex === "number" && + Number.isFinite(account.sourceIndex) ? Math.max(0, Math.floor(account.sourceIndex)) : undefined; if (typeof sourceIndex === "number") return sourceIndex; @@ -123,21 +139,40 @@ function resolveAccountSourceIndex(account: ExistingAccountInfo): number { } function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void { - const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + const label = + account.email?.trim() || + account.accountId?.trim() || + `index ${account.index + 1}`; console.log(`Unable to resolve saved account for action: ${label}`); } async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { - const answer = await rl.question("Type DELETE to remove all saved accounts: "); + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.typedConfirm, + ); return answer.trim() === "DELETE"; } finally { rl.close(); } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptResetTypedConfirm(): Promise { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.resetLocalState.typedConfirm, + ); + return answer.trim() === "RESET"; + } finally { + rl.close(); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -152,17 +187,41 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): const answer = await rl.question(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; - if (normalized === "b" || normalized === "p" || normalized === "forecast") { + if ( + normalized === "b" || + normalized === "p" || + normalized === "forecast" + ) { return { mode: "forecast" }; } if (normalized === "x" || normalized === "fix") return { mode: "fix" }; - if (normalized === "s" || normalized === "settings" || normalized === "configure") { + if ( + normalized === "s" || + normalized === "settings" || + normalized === "configure" + ) { return { mode: "settings" }; } - if (normalized === "f" || normalized === "fresh" || normalized === "clear") { + if ( + normalized === "f" || + normalized === "fresh" || + normalized === "clear" + ) { + if (!(await promptDeleteAllTypedConfirm())) { + console.log("\nDelete saved accounts cancelled.\n"); + continue; + } return { mode: "fresh", deleteAll: true }; } - if (normalized === "c" || normalized === "check") return { mode: "check" }; + if (normalized === "r" || normalized === "reset") { + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; + } + if (normalized === "c" || normalized === "check") + return { mode: "check" }; if (normalized === "d" || normalized === "deep") { return { mode: "deep-check" }; } @@ -174,7 +233,8 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): ) { return { mode: "verify-flagged" }; } - if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; + if (normalized === "q" || normalized === "quit") + return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); } } finally { @@ -211,10 +271,16 @@ export async function promptLoginMode( return { mode: "settings" }; case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; + case "reset-all": + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } + return { mode: "reset" }; case "check": return { mode: "check" }; case "deep-check": @@ -278,7 +344,7 @@ export async function promptLoginMode( continue; case "delete-all": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -306,7 +372,8 @@ export async function promptAccountSelection( ): Promise { if (candidates.length === 0) return null; const defaultIndex = - typeof options.defaultIndex === "number" && Number.isFinite(options.defaultIndex) + typeof options.defaultIndex === "number" && + Number.isFinite(options.defaultIndex) ? Math.max(0, Math.min(options.defaultIndex, candidates.length - 1)) : 0; @@ -316,7 +383,9 @@ export async function promptAccountSelection( const rl = createInterface({ input, output }); try { - console.log(`\n${options.title ?? "Multiple workspaces detected for this account:"}`); + console.log( + `\n${options.title ?? "Multiple workspaces detected for this account:"}`, + ); candidates.forEach((candidate, index) => { const isDefault = candidate.isDefault ? " (default)" : ""; console.log(` ${index + 1}. ${candidate.label}${isDefault}`); @@ -324,7 +393,9 @@ export async function promptAccountSelection( console.log(""); while (true) { - const answer = await rl.question(`Select workspace [${defaultIndex + 1}]: `); + const answer = await rl.question( + `Select workspace [${defaultIndex + 1}]: `, + ); const normalized = answer.trim().toLowerCase(); if (!normalized) { return candidates[defaultIndex] ?? candidates[0] ?? null; diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 486afea7..09e0a8f9 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -30,6 +30,12 @@ import { type DashboardDisplaySettings, type DashboardAccountSortMode, } from "./dashboard-settings.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./destructive-actions.js"; import { evaluateForecastAccounts, isHardRefreshFailure, @@ -51,7 +57,6 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { - clearAccounts, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -87,6 +92,8 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -3868,10 +3875,6 @@ async function runDoctor(args: string[]): Promise { return summary.error > 0 ? 1 : 0; } -async function clearAccountsAndReset(): Promise { - await clearAccounts(); -} - async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -3885,14 +3888,18 @@ async function handleManageAction( if (typeof menuResult.deleteAccountIndex === "number") { const idx = menuResult.deleteAccountIndex; if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; + const deleted = await deleteAccountAtIndex({ + storage, + index: idx, + }); + if (deleted) { + const label = `Account ${idx + 1}`; + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`Deleted ${label}.${flaggedNote}`); } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); } return; } @@ -4020,10 +4027,59 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } continue; } if (menuResult.mode === "manage") { diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts new file mode 100644 index 00000000..66a8571e --- /dev/null +++ b/lib/destructive-actions.ts @@ -0,0 +1,196 @@ +import { clearCodexCliStateCache } from "./codex-cli/state.js"; +import { MODEL_FAMILIES } from "./prompts/codex.js"; +import { clearQuotaCache } from "./quota-cache.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + type FlaggedAccountStorageV1, + loadFlaggedAccounts, + saveAccounts, + saveFlaggedAccounts, +} from "./storage.js"; + +export const DESTRUCTIVE_ACTION_COPY = { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, +} as const; + +export function clampActiveIndices(storage: AccountStorageV3): void { + const count = storage.accounts.length; + const baseIndex = + typeof storage.activeIndex === "number" && + Number.isFinite(storage.activeIndex) + ? storage.activeIndex + : 0; + + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + + storage.activeIndex = Math.max(0, Math.min(baseIndex, count - 1)); + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const clamped = Math.max( + 0, + Math.min( + typeof rawIndex === "number" && Number.isFinite(rawIndex) + ? rawIndex + : fallback, + count - 1, + ), + ); + activeIndexByFamily[family] = clamped; + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +function rebaseActiveIndicesAfterDelete( + storage: AccountStorageV3, + removedIndex: number, +): void { + if (storage.activeIndex > removedIndex) { + storage.activeIndex -= 1; + } + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + if (typeof rawIndex === "number" && Number.isFinite(rawIndex) && rawIndex > removedIndex) { + activeIndexByFamily[family] = rawIndex - 1; + } + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +export interface DeleteAccountResult { + storage: AccountStorageV3; + flagged: FlaggedAccountStorageV1; + removedAccount: AccountMetadataV3; + removedFlaggedCount: number; +} + +export interface DestructiveActionResult { + accountsCleared: boolean; + flaggedCleared: boolean; + quotaCacheCleared: boolean; +} + +function asError(error: unknown, fallbackMessage: string): Error { + return error instanceof Error + ? error + : new Error(`${fallbackMessage}: ${String(error)}`); +} + +export async function deleteAccountAtIndex(options: { + storage: AccountStorageV3; + index: number; +}): Promise { + const target = options.storage.accounts.at(options.index); + if (!target) return null; + const flagged = await loadFlaggedAccounts(); + const nextStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + const previousStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + + nextStorage.accounts.splice(options.index, 1); + rebaseActiveIndicesAfterDelete(nextStorage, options.index); + clampActiveIndices(nextStorage); + await saveAccounts(nextStorage); + + const remainingFlagged = flagged.accounts.filter( + (account) => account.refreshToken !== target.refreshToken, + ); + const removedFlaggedCount = flagged.accounts.length - remainingFlagged.length; + let updatedFlagged = flagged; + if (removedFlaggedCount > 0) { + updatedFlagged = { ...flagged, accounts: remainingFlagged }; + try { + await saveFlaggedAccounts(updatedFlagged); + } catch (error) { + const originalError = asError( + error, + "Failed to save flagged account storage after deleting an account", + ); + try { + await saveAccounts(previousStorage); + } catch (rollbackError) { + throw new AggregateError( + [ + originalError, + asError( + rollbackError, + "Failed to roll back account storage after flagged save failure", + ), + ], + "Deleting the account partially failed and rollback also failed.", + ); + } + throw originalError; + } + } + + return { + storage: nextStorage, + flagged: updatedFlagged, + removedAccount: target, + removedFlaggedCount, + }; +} + +/** + * Delete saved accounts without touching flagged/problem accounts, settings, or Codex CLI sync state. + * Removes the accounts WAL and backups via the underlying storage helper. + */ +export async function deleteSavedAccounts(): Promise { + return { + accountsCleared: await clearAccounts(), + flaggedCleared: false, + quotaCacheCleared: false, + }; +} + +/** + * Reset local multi-auth state: clears saved accounts, flagged/problem accounts, and quota cache. + * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. + */ +export async function resetLocalState(): Promise { + const accountsCleared = await clearAccounts(); + const flaggedCleared = await clearFlaggedAccounts(); + const quotaCacheCleared = await clearQuotaCache(); + clearCodexCliStateCache(); + return { + accountsCleared, + flaggedCleared, + quotaCacheCleared, + }; +} diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index 9870a2b6..0fa647c6 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -30,8 +30,7 @@ interface QuotaCacheFile { byEmail: Record; } -const QUOTA_CACHE_PATH = join(getCodexMultiAuthDir(), "quota-cache.json"); -const QUOTA_CACHE_LABEL = basename(QUOTA_CACHE_PATH); +const QUOTA_CACHE_FILE_NAME = "quota-cache.json"; const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); function isRetryableFsError(error: unknown): boolean { @@ -46,7 +45,9 @@ function isRetryableFsError(error: unknown): boolean { * @returns The input as a finite number, or `undefined` if the value is not a finite number */ function normalizeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } /** @@ -104,7 +105,7 @@ function normalizeEntry(value: unknown): QuotaCacheEntry | null { * * @param value - Parsed JSON value (typically an object) containing raw entries keyed by identifier; non-objects, empty keys, or invalid entries are ignored. * @returns A record mapping valid string keys to normalized `QuotaCacheEntry` objects; malformed entries are omitted. - * + * * Note: This function is pure and performs no filesystem I/O. Callers are responsible for any filesystem concurrency or Windows-specific behavior when loading/saving the on-disk cache, and for redacting any sensitive tokens before logging or persisting. */ function normalizeEntryMap(value: unknown): Record { @@ -132,7 +133,9 @@ async function readCacheFileWithRetry(path: string): Promise { await sleep(10 * 2 ** attempt); } } - throw lastError instanceof Error ? lastError : new Error("quota cache read retry exhausted"); + throw lastError instanceof Error + ? lastError + : new Error("quota cache read retry exhausted"); } /** @@ -146,7 +149,11 @@ async function readCacheFileWithRetry(path: string): Promise { * @returns The absolute path to the quota-cache.json file */ export function getQuotaCachePath(): string { - return QUOTA_CACHE_PATH; + return join(getCodexMultiAuthDir(), QUOTA_CACHE_FILE_NAME); +} + +function getQuotaCacheLabel(path: string): string { + return basename(path); } /** @@ -168,18 +175,21 @@ export function getQuotaCachePath(): string { * will be empty if the on-disk file is absent, malformed, or could not be read. */ export async function loadQuotaCache(): Promise { - if (!existsSync(QUOTA_CACHE_PATH)) { + const quotaCachePath = getQuotaCachePath(); + if (!existsSync(quotaCachePath)) { return { byAccountId: {}, byEmail: {} }; } try { - const content = await readCacheFileWithRetry(QUOTA_CACHE_PATH); + const content = await readCacheFileWithRetry(quotaCachePath); const parsed = JSON.parse(content) as unknown; if (!isRecord(parsed)) { return { byAccountId: {}, byEmail: {} }; } if (parsed.version !== 1) { - logWarn(`Quota cache rejected due to version mismatch: ${String(parsed.version)}`); + logWarn( + `Quota cache rejected due to version mismatch: ${String(parsed.version)}`, + ); return { byAccountId: {}, byEmail: {} }; } @@ -189,7 +199,7 @@ export async function loadQuotaCache(): Promise { }; } catch (error) { logWarn( - `Failed to load quota cache from ${QUOTA_CACHE_LABEL}: ${ + `Failed to load quota cache from ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -222,10 +232,11 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { byAccountId: data.byAccountId, byEmail: data.byEmail, }; + const quotaCachePath = getQuotaCachePath(); try { await fs.mkdir(getCodexMultiAuthDir(), { recursive: true }); - const tempPath = `${QUOTA_CACHE_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const tempPath = `${quotaCachePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600, @@ -234,7 +245,7 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { try { for (let attempt = 0; attempt < 5; attempt += 1) { try { - await fs.rename(tempPath, QUOTA_CACHE_PATH); + await fs.rename(tempPath, quotaCachePath); renamed = true; break; } catch (error) { @@ -253,9 +264,38 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { } } catch (error) { logWarn( - `Failed to save quota cache to ${QUOTA_CACHE_LABEL}: ${ + `Failed to save quota cache to ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); } } + +/** + * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. + */ +export async function clearQuotaCache(): Promise { + const quotaCachePath = getQuotaCachePath(); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(quotaCachePath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return true; + } + if (!isRetryableFsError(error) || attempt >= 4) { + logWarn( + `Failed to clear quota cache ${getQuotaCacheLabel(quotaCachePath)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + await sleep(10 * 2 ** attempt); + } + } + + return false; +} diff --git a/lib/storage.ts b/lib/storage.ts index 1ea0fc0b..e29a4084 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -179,6 +179,26 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +async function unlinkWithRetry(path: string): Promise { + 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; + } + } +} + type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -1834,34 +1854,19 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { throw emptyError; } - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.rename(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } - lastAccountsSaveTimestamp = Date.now(); - try { - await fs.unlink(walPath); - } catch { - // Best effort cleanup. - } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); - continue; - } - throw renameError; - } + await renameFileWithRetry(tempPath, path); + try { + await fs.unlink(resetMarkerPath); + } catch { + // Best effort cleanup. + } + lastAccountsSaveTimestamp = Date.now(); + try { + await fs.unlink(walPath); + } catch { + // Best effort cleanup. } - if (lastError) throw lastError; + return; } catch (error) { try { await fs.unlink(tempPath); @@ -1996,24 +2001,34 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ -export async function clearAccounts(): Promise { +export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const walPath = getAccountsWalPath(path); const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + const legacyPaths = Array.from( + new Set( + [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ), + ), + ); await fs.writeFile( resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), { encoding: "utf-8", mode: 0o600 }, ); + let hadError = false; const clearPath = async (targetPath: string): Promise => { try { - await fs.unlink(targetPath); + await unlinkWithRetry(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear account storage artifact", { path: targetPath, error: String(error), @@ -2023,14 +2038,13 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([ - clearPath(path), - clearPath(walPath), - ...backupPaths.map(clearPath), - ]); + const artifacts = Array.from(new Set([path, walPath, ...backupPaths, ...legacyPaths])); + await Promise.all(artifacts.map(clearPath)); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } + + return !hadError; }); } @@ -2258,7 +2272,7 @@ export async function saveFlaggedAccounts( }); } -export async function clearFlaggedAccounts(): Promise { +export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); const markerPath = getIntentionalResetMarkerPath(path); @@ -2277,22 +2291,37 @@ export async function clearFlaggedAccounts(): Promise { } const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const candidate of [path, ...backupPaths, markerPath]) { + let hadError = false; + for (const candidate of [path, ...backupPaths]) { try { - await fs.unlink(candidate); + await unlinkWithRetry(candidate); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + hadError = true; log.error("Failed to clear flagged account storage", { path: candidate, error: String(error), }); - if (candidate === path) { - throw error; - } } } } + if (!hadError) { + try { + await unlinkWithRetry(markerPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged reset marker", { + path, + markerPath, + error: String(error), + }); + hadError = true; + } + } + } + return !hadError; }); } diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b7c7708f..fbe9293a 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,11 +1,15 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; +import { formatCheckFlaggedLabel, UI_COPY } from "./copy.js"; +import { + formatUiBadge, + paintUiText, + quotaToneFromLeftPercent, +} from "./format.js"; import { getUiRuntimeOptions } from "./runtime.js"; -import { select, type MenuItem } from "./select.js"; -import { paintUiText, formatUiBadge, quotaToneFromLeftPercent } from "./format.js"; -import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; +import { type MenuItem, select } from "./select.js"; export type AccountStatus = | "active" @@ -56,6 +60,7 @@ export type AuthMenuAction = | { type: "fix" } | { type: "settings" } | { type: "fresh" } + | { type: "reset-all" } | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } @@ -68,7 +73,16 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; -export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; +export type AccountAction = + | "back" + | "delete" + | "refresh" + | "toggle" + | "set-current" + | "cancel"; + +const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); function resolveCliVersionLabel(): string | null { const raw = (process.env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); @@ -85,8 +99,8 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHAR_PATTERN, "") .trim(); } @@ -112,10 +126,14 @@ function statusBadge(status: AccountStatus | undefined): string { tone: "accent" | "success" | "warning" | "danger" | "muted", ): string => { if (ui.v2Enabled) return formatUiBadge(ui, label, tone); - if (tone === "accent") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "success") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "warning") return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "danger") return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; + if (tone === "accent") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "success") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "warning") + return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "danger") + return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; return `${ANSI.inverse}[${label}]${ANSI.reset}`; }; @@ -161,7 +179,7 @@ function statusBadge(status: AccountStatus | undefined): string { } function accountTitle(account: AccountInfo): string { - const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const accountNumber = account.quickSwitchNumber ?? account.index + 1; const base = sanitizeTerminalText(account.email) || sanitizeTerminalText(account.accountLabel) || @@ -175,15 +193,21 @@ function accountSearchText(account: AccountInfo): string { sanitizeTerminalText(account.email), sanitizeTerminalText(account.accountLabel), sanitizeTerminalText(account.accountId), - String(account.quickSwitchNumber ?? (account.index + 1)), + String(account.quickSwitchNumber ?? account.index + 1), ] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) .join(" ") .toLowerCase(); } -function accountRowColor(account: AccountInfo): MenuItem["color"] { - if (account.isCurrentAccount && account.highlightCurrentRow !== false) return "green"; +function accountRowColor( + account: AccountInfo, +): MenuItem["color"] { + if (account.isCurrentAccount && account.highlightCurrentRow !== false) + return "green"; switch (account.status) { case "active": case "ok": @@ -200,7 +224,9 @@ function accountRowColor(account: AccountInfo): MenuItem["color" } } -function statusTone(status: AccountStatus | undefined): "success" | "warning" | "danger" | "muted" { +function statusTone( + status: AccountStatus | undefined, +): "success" | "warning" | "danger" | "muted" { switch (status) { case "active": case "ok": @@ -226,12 +252,16 @@ function normalizeQuotaPercent(value: number | undefined): number | null { return Math.max(0, Math.min(100, Math.round(value))); } -function parseLeftPercentFromSummary(summary: string, windowLabel: "5h" | "7d"): number | null { +function parseLeftPercentFromSummary( + summary: string, + windowLabel: "5h" | "7d", +): number | null { const segments = summary.split("|"); for (const segment of segments) { const trimmed = segment.trim().toLowerCase(); if (!trimmed.startsWith(`${windowLabel} `)) continue; - const percentToken = trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; + const percentToken = + trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; const parsed = Number.parseInt(percentToken.replace("%", ""), 10); if (!Number.isFinite(parsed)) continue; return Math.max(0, Math.min(100, parsed)); @@ -274,15 +304,21 @@ function formatQuotaBar( const filledText = "█".repeat(filled); const emptyText = "▒".repeat(width - filled); if (ui.v2Enabled) { - const tone = leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); - const filledSegment = filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; - const emptySegment = emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; + const tone = + leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); + const filledSegment = + filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; + const emptySegment = + emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; return `${filledSegment}${emptySegment}`; } if (leftPercent === null) return `${ANSI.dim}${emptyText}${ANSI.reset}`; - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; - const filledSegment = filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; - const emptySegment = emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; + const color = + leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const filledSegment = + filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; + const emptySegment = + emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; return `${filledSegment}${emptySegment}`; } @@ -293,7 +329,12 @@ function formatQuotaPercent( if (leftPercent === null) return null; const percentText = `${leftPercent}%`; if (!ui.v2Enabled) { - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const color = + leftPercent <= 15 + ? ANSI.red + : leftPercent <= 35 + ? ANSI.yellow + : ANSI.green; return `${color}${percentText}${ANSI.reset}`; } const tone = quotaToneFromLeftPercent(leftPercent); @@ -317,28 +358,60 @@ function formatQuotaWindow( if (!cooldown) { return percent ? `${labelText} ${bar} ${percent}` : `${labelText} ${bar}`; } - const cooldownText = ui.v2Enabled ? paintUiText(ui, cooldown, "muted") : cooldown; + const cooldownText = ui.v2Enabled + ? paintUiText(ui, cooldown, "muted") + : cooldown; if (!percent) { return `${labelText} ${bar} ${cooldownText}`; } return `${labelText} ${bar} ${percent} ${cooldownText}`; } -function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatQuotaSummary( + account: AccountInfo, + ui: ReturnType, +): string { const summary = account.quotaSummary ?? ""; const showCooldown = account.showQuotaCooldown !== false; - const left5h = normalizeQuotaPercent(account.quota5hLeftPercent) ?? parseLeftPercentFromSummary(summary, "5h"); - const left7d = normalizeQuotaPercent(account.quota7dLeftPercent) ?? parseLeftPercentFromSummary(summary, "7d"); + const left5h = + normalizeQuotaPercent(account.quota5hLeftPercent) ?? + parseLeftPercentFromSummary(summary, "5h"); + const left7d = + normalizeQuotaPercent(account.quota7dLeftPercent) ?? + parseLeftPercentFromSummary(summary, "7d"); const segments: string[] = []; if (left5h !== null || typeof account.quota5hResetAtMs === "number") { - segments.push(formatQuotaWindow("5h", left5h, account.quota5hResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "5h", + left5h, + account.quota5hResetAtMs, + showCooldown, + ui, + ), + ); } if (left7d !== null || typeof account.quota7dResetAtMs === "number") { - segments.push(formatQuotaWindow("7d", left7d, account.quota7dResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "7d", + left7d, + account.quota7dResetAtMs, + showCooldown, + ui, + ), + ); } - if (account.quotaRateLimited || summary.toLowerCase().includes("rate-limited")) { - segments.push(ui.v2Enabled ? paintUiText(ui, "rate-limited", "danger") : `${ANSI.red}rate-limited${ANSI.reset}`); + if ( + account.quotaRateLimited || + summary.toLowerCase().includes("rate-limited") + ) { + segments.push( + ui.v2Enabled + ? paintUiText(ui, "rate-limited", "danger") + : `${ANSI.red}rate-limited${ANSI.reset}`, + ); } if (segments.length === 0) { @@ -350,7 +423,10 @@ function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatAccountHint( + account: AccountInfo, + ui: ReturnType, +): string { const withKey = ( key: string, value: string, @@ -365,19 +441,30 @@ function formatAccountHint(account: AccountInfo, ui: ReturnType(); if (account.showStatusBadge === false) { - partsByKey.set("status", withKey("Status:", statusText(account.status), statusTone(account.status))); + partsByKey.set( + "status", + withKey( + "Status:", + statusText(account.status), + statusTone(account.status), + ), + ); } if (account.showLastUsed !== false) { - partsByKey.set("last-used", withKey("Last used:", formatRelativeTime(account.lastUsed), "heading")); + partsByKey.set( + "last-used", + withKey("Last used:", formatRelativeTime(account.lastUsed), "heading"), + ); } const quotaSummaryText = formatQuotaSummary(account, ui); if (quotaSummaryText) { partsByKey.set("limits", withKey("Limits:", quotaSummaryText, "accent")); } - const fields = account.statuslineFields && account.statuslineFields.length > 0 - ? account.statuslineFields - : ["last-used", "limits", "status"]; + const fields = + account.statuslineFields && account.statuslineFields.length > 0 + ? account.statuslineFields + : ["last-used", "limits", "status"]; const orderedParts: string[] = []; for (const field of fields) { const part = partsByKey.get(field); @@ -407,7 +494,7 @@ async function promptSearchQuery(current: string): Promise { try { const suffix = current ? ` (${current})` : ""; const answer = await rl.question(`Search${suffix} (blank clears): `); - return answer.trim().toLowerCase(); + return (sanitizeTerminalText(answer) ?? "").toLowerCase(); } finally { rl.close(); } @@ -426,6 +513,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "fix": case "settings": case "fresh": + case "reset-all": case "check": case "deep-check": case "verify-flagged": @@ -448,13 +536,16 @@ export async function showAuthMenu( let focusKey = "action:add"; while (true) { const normalizedSearch = searchQuery.trim().toLowerCase(); - const visibleAccounts = normalizedSearch.length > 0 - ? accounts.filter((account) => accountSearchText(account).includes(normalizedSearch)) - : accounts; + const visibleAccounts = + normalizedSearch.length > 0 + ? accounts.filter((account) => + accountSearchText(account).includes(normalizedSearch), + ) + : accounts; const visibleByNumber = new Map(); const duplicateQuickSwitchNumbers = new Set(); for (const account of visibleAccounts) { - const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + const quickSwitchNumber = account.quickSwitchNumber ?? account.index + 1; if (visibleByNumber.has(quickSwitchNumber)) { duplicateQuickSwitchNumbers.add(quickSwitchNumber); continue; @@ -463,18 +554,58 @@ export async function showAuthMenu( } const items: MenuItem[] = [ - { label: UI_COPY.mainMenu.quickStart, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.addAccount, value: { type: "add" }, color: "green" }, - { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, - { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, - { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, - { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { + label: UI_COPY.mainMenu.quickStart, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.addAccount, + value: { type: "add" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.checkAccounts, + value: { type: "check" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.bestAccount, + value: { type: "forecast" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.fixIssues, + value: { type: "fix" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.settings, + value: { type: "settings" }, + color: "green", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, - { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, + { + label: UI_COPY.mainMenu.moreChecks, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.refreshChecks, + value: { type: "deep-check" }, + color: "green", + }, + { + label: verifyLabel, + value: { type: "verify-flagged" }, + color: flaggedCount > 0 ? "red" : "yellow", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.mainMenu.accounts, + value: { type: "cancel" }, + kind: "heading", + }, ]; if (visibleAccounts.length === 0) { @@ -486,20 +617,34 @@ export async function showAuthMenu( } else { items.push( ...visibleAccounts.map((account) => { - const currentBadge = account.isCurrentAccount && account.showCurrentBadge !== false - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) - : ""; - const badge = account.showStatusBadge === false ? "" : statusBadge(account.status); + const currentBadge = + account.isCurrentAccount && account.showCurrentBadge !== false + ? ui.v2Enabled + ? ` ${formatUiBadge(ui, "current", "accent")}` + : ` ${ANSI.cyan}[current]${ANSI.reset}` + : ""; + const badge = + account.showStatusBadge === false + ? "" + : statusBadge(account.status); const statusSuffix = badge ? ` ${badge}` : ""; const title = ui.v2Enabled - ? paintUiText(ui, accountTitle(account), account.isCurrentAccount ? "accent" : "heading") + ? paintUiText( + ui, + accountTitle(account), + account.isCurrentAccount ? "accent" : "heading", + ) : accountTitle(account); const label = `${title}${currentBadge}${statusSuffix}`; const hint = formatAccountHint(account, ui); const hasHint = hint.length > 0; const hintText = ui.v2Enabled - ? (hasHint ? hint : undefined) - : (hasHint ? hint : undefined); + ? hasHint + ? hint + : undefined + : hasHint + ? hint + : undefined; return { label, hint: hintText, @@ -511,27 +656,45 @@ export async function showAuthMenu( } items.push({ label: "", value: { type: "cancel" }, separator: true }); - items.push({ label: UI_COPY.mainMenu.dangerZone, value: { type: "cancel" }, kind: "heading" }); - items.push({ label: UI_COPY.mainMenu.removeAllAccounts, value: { type: "delete-all" }, color: "red" }); + items.push({ + label: UI_COPY.mainMenu.dangerZone, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.removeAllAccounts, + value: { type: "delete-all" }, + color: "red", + }); + items.push({ + label: UI_COPY.mainMenu.resetLocalState, + value: { type: "reset-all" }, + color: "red", + }); const compactHelp = UI_COPY.mainMenu.helpCompact; const detailedHelp = UI_COPY.mainMenu.helpDetailed; - const showHintsForUnselectedRows = visibleAccounts[0]?.showHintsForUnselectedRows ?? + const showHintsForUnselectedRows = + visibleAccounts[0]?.showHintsForUnselectedRows ?? accounts[0]?.showHintsForUnselectedRows ?? false; - const focusStyle = visibleAccounts[0]?.focusStyle ?? - accounts[0]?.focusStyle ?? - "row-invert"; + const focusStyle = + visibleAccounts[0]?.focusStyle ?? accounts[0]?.focusStyle ?? "row-invert"; const resolveStatusMessage = (): string | undefined => { - const raw = typeof options.statusMessage === "function" - ? options.statusMessage() - : options.statusMessage; - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + const raw = + typeof options.statusMessage === "function" + ? options.statusMessage() + : options.statusMessage; + const sanitized = typeof raw === "string" ? sanitizeTerminalText(raw) : undefined; + return sanitized && sanitized.length > 0 ? sanitized : undefined; }; const buildSubtitle = (): string | undefined => { const parts: string[] = []; - if (normalizedSearch.length > 0) { - parts.push(`${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`); + const safeSearch = sanitizeTerminalText(normalizedSearch); + if (safeSearch && safeSearch.length > 0) { + parts.push( + `${UI_COPY.mainMenu.searchSubtitlePrefix} ${safeSearch}`, + ); } const statusText = resolveStatusMessage(); if (statusText) { @@ -541,7 +704,8 @@ export async function showAuthMenu( return parts.join(" | "); }; const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; + if (item.separator || item.disabled || item.kind === "heading") + return false; return authMenuFocusKey(item.value) === focusKey; }); @@ -582,7 +746,12 @@ export async function showAuthMenu( } const selected = context.items[context.cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") { + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) { return undefined; } if (selected.value.type !== "select-account") return undefined; @@ -590,7 +759,13 @@ export async function showAuthMenu( }, onCursorChange: ({ cursor }) => { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusKey = authMenuFocusKey(selected.value); }, }); @@ -601,16 +776,16 @@ export async function showAuthMenu( focusKey = "action:search"; continue; } - if (result.type === "delete-all") { - const confirmed = await confirm("Delete all accounts?"); - if (!confirmed) continue; - } if (result.type === "delete-account") { - const confirmed = await confirm(`Delete ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Delete ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } if (result.type === "refresh-account") { - const confirmed = await confirm(`Re-authenticate ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Re-authenticate ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } focusKey = authMenuFocusKey(result); @@ -618,14 +793,16 @@ export async function showAuthMenu( } } -export async function showAccountDetails(account: AccountInfo): Promise { +export async function showAccountDetails( + account: AccountInfo, +): Promise { const ui = getUiRuntimeOptions(); const header = `${accountTitle(account)} ${statusBadge(account.status)}` + (account.enabled === false - ? (ui.v2Enabled + ? ui.v2Enabled ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) + : ` ${ANSI.red}[disabled]${ANSI.reset}` : ""); const statusLabel = account.status ?? "unknown"; const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`; @@ -635,7 +812,10 @@ export async function showAccountDetails(account: AccountInfo): Promise[] = [ { label: UI_COPY.accountDetails.back, value: "back" }, { - label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + label: + account.enabled === false + ? UI_COPY.accountDetails.enable + : UI_COPY.accountDetails.disable, value: "toggle", color: account.enabled === false ? "green" : "yellow", }, @@ -644,7 +824,11 @@ export async function showAccountDetails(account: AccountInfo): Promise item.value === focusAction); @@ -668,7 +852,13 @@ export async function showAccountDetails(account: AccountInfo): Promise { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusAction = selected.value; }, }); @@ -680,7 +870,9 @@ export async function showAccountDetails(account: AccountInfo): Promise `Returning in ${seconds}s... Press any key to pause.`, + autoReturn: (seconds: number) => + `Returning in ${seconds}s... Press any key to pause.`, paused: "Paused. Press any key to continue.", working: "Running...", done: "Done.", @@ -89,22 +92,27 @@ export const UI_COPY = { backNoSave: "Back Without Saving", accountListTitle: "Account List View", accountListSubtitle: "Choose row details and optional smart sorting", - accountListHelp: "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", + accountListHelp: + "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", summaryTitle: "Account Details Row", summarySubtitle: "Choose and order detail fields", - summaryHelp: "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", + summaryHelp: + "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", behaviorTitle: "Return Behavior", behaviorSubtitle: "Control how result screens return", - behaviorHelp: "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", + behaviorHelp: + "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", themeTitle: "Color Theme", themeSubtitle: "Pick base color and accent", themeHelp: "Enter Select | 1-2 Base | S Save | Q Back (No Save)", backendTitle: "Backend Controls", backendSubtitle: "Tune sync, retry, and limit behavior", - backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + backendHelp: + "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", - backendCategoryHelp: "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", + backendCategoryHelp: + "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", backendToggleHeading: "Switches", backendNumberHeading: "Numbers", backendDecrease: "Decrease Focused Value", @@ -118,11 +126,13 @@ export const UI_COPY = { moveDown: "Move Focused Field Down", }, fallback: { - addAnotherTip: "Tip: Use private mode or sign out before adding another account.", - addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, + addAnotherTip: + "Tip: Use private mode or sign out before adding another account.", + addAnotherQuestion: (count: number) => + `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (q) back [a/c/b/x/s/d/g/f/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", }, } as const; diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index 75ec2269..91413196 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -3,6 +3,8 @@ import type { AccountInfo } from "../lib/ui/auth-menu.js"; const selectMock = vi.fn(); const confirmMock = vi.fn(async () => true); +const searchQuestionMock = vi.fn(); +const searchCloseMock = vi.fn(); vi.mock("../lib/ui/select.js", () => ({ select: selectMock, @@ -12,6 +14,13 @@ vi.mock("../lib/ui/confirm.js", () => ({ confirm: confirmMock, })); +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: searchQuestionMock, + close: searchCloseMock, + })), +})); + function createAccounts(): AccountInfo[] { const baseTime = 1_700_000_000_000; return [ @@ -27,6 +36,8 @@ describe("auth-menu hotkeys", () => { vi.resetModules(); selectMock.mockReset(); confirmMock.mockReset(); + searchQuestionMock.mockReset(); + searchCloseMock.mockReset(); confirmMock.mockResolvedValue(true); previousCliVersion = process.env.CODEX_MULTI_AUTH_CLI_VERSION; delete process.env.CODEX_MULTI_AUTH_CLI_VERSION; @@ -138,6 +149,34 @@ describe("auth-menu hotkeys", () => { expect(selectMock).toHaveBeenCalledTimes(2); }); + it("sanitizes search subtitles and status messages", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + searchQuestionMock.mockResolvedValueOnce(" \u001b[31mNeedle\u0007 "); + selectMock + .mockImplementationOnce( + async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { + if (!options.onInput) return null; + return options.onInput("/", { + cursor: 0, + items, + requestRerender: () => undefined, + }); + }, + ) + .mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts(), { + statusMessage: () => "\u001b[32mNeeds\u0000 attention\u0007 ", + }); + + expect(result).toEqual({ type: "cancel" }); + expect(searchCloseMock).toHaveBeenCalledTimes(1); + const options = selectMock.mock.calls[1]?.[1] as { subtitle?: string }; + expect(options.subtitle).toBe("Search: needle | Needs attention"); + }); + it("supports help toggle hotkey (?) and requests rerender", async () => { let rerenderCalls = 0; selectMock.mockImplementationOnce(async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { @@ -197,4 +236,68 @@ describe("auth-menu hotkeys", () => { const options = selectMock.mock.calls[0]?.[1] as { message?: string }; expect(options?.message).toBe("Accounts Dashboard (v0.1.6)"); }); + + it("returns delete-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "delete-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "delete-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("returns reset-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "reset-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "reset-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ANSI and control characters in rendered account labels", async () => { + const baseTime = 1_700_000_000_000; + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + await showAuthMenu([ + { + index: 0, + email: "\u001b[31mfirst@example.com\u0000", + status: "ok", + lastUsed: baseTime, + }, + { + index: 1, + accountLabel: "\u001b[32mFriendly \r\nLabel\u007f", + status: "ok", + lastUsed: baseTime, + }, + { + index: 2, + email: "", + accountLabel: " \r\n ", + accountId: "\u001b[33macc-id-42\u0007", + status: "ok", + lastUsed: baseTime, + }, + ]); + + const items = (selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + value: { type: string }; + }>).filter((item) => item.value.type === "select-account"); + const labels = items.map((item) => item.label); + const strippedLabels = labels.map((label) => + label.replace(new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"), ""), + ); + + expect(strippedLabels[0]).toContain("1. first@example.com"); + expect(strippedLabels[1]).toContain("2. Friendly Label"); + expect(strippedLabels[2]).toContain("3. acc-id-42"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional test assertion + expect(strippedLabels.join("")).not.toMatch(/[\u0000\u0007\u007f]/); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 0f06f2c3..cf76445c 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -290,7 +290,35 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); + consoleSpy.mockRestore(); + }); + + it("returns reset mode when reset-all is confirmed", async () => { + mockRl.question.mockResolvedValueOnce("RESET"); + showAuthMenu.mockResolvedValueOnce({ type: "reset-all" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("cancels reset-all when typed confirmation is not RESET", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("nope"); + showAuthMenu + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); consoleSpy.mockRestore(); }); @@ -305,7 +333,7 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); consoleSpy.mockRestore(); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 39f1b753..a2750841 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,274 +1,443 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createInterface } from "node:readline/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:readline/promises", () => ({ - createInterface: vi.fn(), + createInterface: vi.fn(), })); const mockRl = { - question: vi.fn(), - close: vi.fn(), + question: vi.fn(), + close: vi.fn(), }; describe("CLI Module", () => { - beforeEach(() => { - vi.resetModules(); - process.env.FORCE_INTERACTIVE_MODE = "1"; - mockRl.question.mockReset(); - mockRl.close.mockReset(); - vi.mocked(createInterface).mockReturnValue(mockRl as any); - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - delete process.env.FORCE_INTERACTIVE_MODE; - vi.restoreAllMocks(); - }); - - describe("promptAddAnotherAccount", () => { - it("returns true for 'y' input", async () => { - mockRl.question.mockResolvedValueOnce("y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns true for 'yes' input", async () => { - mockRl.question.mockResolvedValueOnce("yes"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(2); - - expect(result).toBe(true); - }); - - it("returns true for 'Y' input (case insensitive)", async () => { - mockRl.question.mockResolvedValueOnce("Y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - }); - - it("returns false for 'n' input", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for empty input", async () => { - mockRl.question.mockResolvedValueOnce(""); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for random input", async () => { - mockRl.question.mockResolvedValueOnce("maybe"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("includes current count in prompt", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - await promptAddAnotherAccount(5); - - expect(mockRl.question).toHaveBeenCalledWith( - expect.stringContaining("5 added") - ); - }); - - it("always closes readline interface", async () => { - mockRl.question.mockRejectedValueOnce(new Error("test error")); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - - await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); - expect(mockRl.close).toHaveBeenCalled(); - }); - }); - - describe("promptLoginMode", () => { - it("returns 'add' for 'a' input", async () => { - mockRl.question.mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([ - { index: 0, email: "test@example.com" }, - ]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns 'add' for 'add' input", async () => { - mockRl.question.mockResolvedValueOnce("add"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("returns 'forecast' for 'p' input", async () => { - mockRl.question.mockResolvedValueOnce("p"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "forecast" }); - }); - - it("returns 'fix' for 'x' input", async () => { - mockRl.question.mockResolvedValueOnce("x"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fix" }); - }); - - it("returns 'settings' for 's' input", async () => { - mockRl.question.mockResolvedValueOnce("s"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "settings" }); - }); - - it("returns 'fresh' for 'f' input", async () => { - mockRl.question.mockResolvedValueOnce("f"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'fresh' for 'fresh' input", async () => { - mockRl.question.mockResolvedValueOnce("fresh"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'verify-flagged' for 'g' input", async () => { - mockRl.question.mockResolvedValueOnce("g"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "verify-flagged" }); - }); - - it("accepts uppercase quick shortcuts for advanced actions", async () => { - const { promptLoginMode } = await import("../lib/cli.js"); - - mockRl.question.mockResolvedValueOnce("P"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "forecast" }); - - mockRl.question.mockResolvedValueOnce("X"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "fix" }); - - mockRl.question.mockResolvedValueOnce("S"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "settings" }); - - mockRl.question.mockResolvedValueOnce("G"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "verify-flagged" }); - }); - - it("is case insensitive", async () => { - mockRl.question.mockResolvedValueOnce("A"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("re-prompts on invalid input then accepts valid", async () => { - mockRl.question - .mockResolvedValueOnce("invalid") - .mockResolvedValueOnce("zzz") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.question).toHaveBeenCalledTimes(3); - }); - - it("displays account list with email", async () => { - mockRl.question.mockResolvedValueOnce("a"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, email: "user1@example.com" }, - { index: 1, email: "user2@example.com" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 account(s)")); - }); - - it("displays account with accountId suffix when no email", async () => { - mockRl.question.mockResolvedValueOnce("f"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, accountId: "acc_1234567890" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/1\.\s*567890/)); - }); + beforeEach(() => { + vi.resetModules(); + process.env.FORCE_INTERACTIVE_MODE = "1"; + mockRl.question.mockReset(); + mockRl.close.mockReset(); + vi.mocked(createInterface).mockReturnValue(mockRl as any); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + delete process.env.FORCE_INTERACTIVE_MODE; + vi.restoreAllMocks(); + }); + + describe("promptAddAnotherAccount", () => { + it("returns true for 'y' input", async () => { + mockRl.question.mockResolvedValueOnce("y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns true for 'yes' input", async () => { + mockRl.question.mockResolvedValueOnce("yes"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(2); + + expect(result).toBe(true); + }); + + it("returns true for 'Y' input (case insensitive)", async () => { + mockRl.question.mockResolvedValueOnce("Y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + }); + + it("returns false for 'n' input", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for empty input", async () => { + mockRl.question.mockResolvedValueOnce(""); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for random input", async () => { + mockRl.question.mockResolvedValueOnce("maybe"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("includes current count in prompt", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + await promptAddAnotherAccount(5); + + expect(mockRl.question).toHaveBeenCalledWith( + expect.stringContaining("5 added"), + ); + }); + + it("always closes readline interface", async () => { + mockRl.question.mockRejectedValueOnce(new Error("test error")); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + + await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); + expect(mockRl.close).toHaveBeenCalled(); + }); + }); + + describe("promptLoginMode", () => { + it("returns 'add' for 'a' input", async () => { + mockRl.question.mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([ + { index: 0, email: "test@example.com" }, + ]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns 'add' for 'add' input", async () => { + mockRl.question.mockResolvedValueOnce("add"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("returns 'forecast' for 'p' input", async () => { + mockRl.question.mockResolvedValueOnce("p"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "forecast" }); + }); + + it("returns 'fix' for 'x' input", async () => { + mockRl.question.mockResolvedValueOnce("x"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fix" }); + }); + + it("returns 'settings' for 's' input", async () => { + mockRl.question.mockResolvedValueOnce("s"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "settings" }); + }); + + it("returns 'fresh' for 'f' input", async () => { + mockRl.question + .mockResolvedValueOnce("f") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'fresh' for 'fresh' input", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'reset' for fallback reset confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + }); + + it("cancels fallback delete-all when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nDelete saved accounts cancelled.\n", + ); + }); + + it("cancels fallback reset when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + }); + + it("returns reset for TTY reset-all confirmation", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi.fn().mockResolvedValue({ type: "reset-all" }); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(showAuthMenuMock).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("uses reset local state cancellation copy in TTY reset-all flow", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi + .fn() + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "add" }); + const consoleSpy = vi.spyOn(console, "log"); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("nope"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("returns 'verify-flagged' for 'g' input", async () => { + mockRl.question.mockResolvedValueOnce("g"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "verify-flagged" }); + }); + + it("accepts uppercase quick shortcuts for advanced actions", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("P"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "forecast", + }); + + mockRl.question.mockResolvedValueOnce("X"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "fix", + }); + + mockRl.question.mockResolvedValueOnce("S"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "settings", + }); + + mockRl.question.mockResolvedValueOnce("G"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "verify-flagged", + }); + }); + + it("is case insensitive", async () => { + mockRl.question.mockResolvedValueOnce("A"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("re-prompts on invalid input then accepts valid", async () => { + mockRl.question + .mockResolvedValueOnce("invalid") + .mockResolvedValueOnce("zzz") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.question).toHaveBeenCalledTimes(3); + }); + + it("displays account list with email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([ + { index: 0, email: "user1@example.com" }, + { index: 1, email: "user2@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("2 account(s)"), + ); + }); + + it("displays account with accountId suffix when no email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([{ index: 0, accountId: "acc_1234567890" }]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/1\.\s*567890/), + ); + }); it("displays plain Account N when no email or accountId", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0 }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Account")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Account"), + ); }); it("displays label with email when both present", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([{ index: 0, accountLabel: "Work", email: "work@example.com" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/Work.*work@example\.com/)); + await promptLoginMode([ + { index: 0, accountLabel: "Work", email: "work@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Work.*work@example\.com/), + ); }); it("displays only label when no email", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0, accountLabel: "Personal" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Personal")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Personal"), + ); }); }); @@ -321,16 +490,32 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); - + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + try { const { isNonInteractiveMode } = await import("../lib/cli.js"); expect(isNonInteractiveMode()).toBe(false); } finally { - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); @@ -344,63 +529,63 @@ describe("CLI Module", () => { it("returns first candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("1"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.close).toHaveBeenCalled(); }); it("returns second candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("2"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[1]); }); it("returns default on empty input", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); + expect(result).toEqual(candidates[1]); }); it("returns default on quit input", async () => { mockRl.question.mockResolvedValueOnce("q"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); }); it("re-prompts on invalid selection", async () => { - mockRl.question - .mockResolvedValueOnce("99") - .mockResolvedValueOnce("1"); - + mockRl.question.mockResolvedValueOnce("99").mockResolvedValueOnce("1"); + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.question).toHaveBeenCalledTimes(2); }); @@ -408,51 +593,59 @@ describe("CLI Module", () => { it("displays custom title", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection( [{ accountId: "acc1", label: "Account 1" }], - { title: "Custom Title" } + { title: "Custom Title" }, + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Custom Title"), ); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Custom Title")); }); it("shows default marker for default candidates", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection([ { accountId: "acc1", label: "Account 1", isDefault: true }, ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("(default)")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("(default)"), + ); }); it("clamps defaultIndex to valid range", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 999 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 999, + }); + expect(result).toEqual(candidates[1]); }); it("handles negative defaultIndex", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: -5 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: -5, + }); + expect(result).toEqual(candidates[0]); }); }); @@ -485,7 +678,9 @@ describe("CLI Module", () => { { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); expect(result).toEqual(candidates[1]); }); }); @@ -494,13 +689,19 @@ describe("CLI Module", () => { const { promptLoginMode } = await import("../lib/cli.js"); mockRl.question.mockResolvedValueOnce("check"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "check", + }); mockRl.question.mockResolvedValueOnce("deep"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "deep-check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "deep-check", + }); mockRl.question.mockResolvedValueOnce("quit"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "cancel" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "cancel", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { @@ -508,8 +709,16 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); try { process.env.CODEX_TUI = "1"; @@ -536,8 +745,16 @@ describe("CLI Module", () => { delete process.env.CODEX_DESKTOP; delete process.env.TERM_PROGRAM; delete process.env.ELECTRON_RUN_AS_NODE; - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bf9fbef6..1e42be7e 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -18,6 +18,9 @@ const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const selectMock = vi.fn(); +const deleteSavedAccountsMock = vi.fn(); +const resetLocalStateMock = vi.fn(); +const deleteAccountAtIndexMock = vi.fn(); const planOcChatgptSyncMock = vi.fn(); const applyOcChatgptSyncMock = vi.fn(); const runNamedBackupExportMock = vi.fn(); @@ -163,6 +166,35 @@ vi.mock("../lib/quota-cache.js", () => ({ saveQuotaCache: saveQuotaCacheMock, })); +vi.mock("../lib/destructive-actions.js", () => ({ + DESTRUCTIVE_ACTION_COPY: { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: + "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, + }, + deleteSavedAccounts: deleteSavedAccountsMock, + resetLocalState: resetLocalStateMock, + deleteAccountAtIndex: deleteAccountAtIndexMock, +})); + vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); @@ -442,6 +474,20 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); selectMock.mockReset(); + deleteSavedAccountsMock.mockReset(); + resetLocalStateMock.mockReset(); + deleteAccountAtIndexMock.mockReset(); + deleteAccountAtIndexMock.mockResolvedValue(null); + deleteSavedAccountsMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + resetLocalStateMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -4174,16 +4220,38 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); + deleteAccountAtIndexMock.mockResolvedValueOnce({ + storage: { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + flagged: { version: 1, accounts: [] }, + removedAccount: { + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + accountIdSource: undefined, + enabled: true, + }, + removedFlaggedCount: 0, + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( - "first@example.com", - ); + expect(deleteAccountAtIndexMock).toHaveBeenCalledTimes(1); + expect(deleteAccountAtIndexMock.mock.calls[0]?.[0]?.index).toBe(1); }); it("toggles account enabled state from manage mode", async () => { @@ -4216,6 +4284,252 @@ describe("codex manager cli commands", () => { ); }); + it("skips destructive work when user cancels from menu", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "keep@example.com", + refreshToken: "keep-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("deletes saved accounts only when requested", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("resets local state when reset mode is chosen", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("waits for an in-flight menu quota refresh before resetting local state", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", + expiresAt: now + 3_600_000, + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + return fetchDeferred.promise; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(resetLocalStateMock).not.toHaveBeenCalled(); + + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + resetLocalStateMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("skips a second destructive action while reset is already running", async () => { + const now = Date.now(); + const skipMessage = + "Another destructive action is already running. Wait for it to finish."; + const secondMenuAttempted = createDeferred(); + const skipLogged = createDeferred(); + const logSpy = vi.spyOn(console, "log").mockImplementation((message?: unknown) => { + if (message === skipMessage) { + skipLogged.resolve(); + } + }); + const firstResetStarted = createDeferred(); + const allowFirstResetToFinish = createDeferred(); + let menuPromptCall = 0; + + loadAccountsMock.mockImplementation(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + })); + promptLoginModeMock.mockImplementation(async () => { + menuPromptCall += 1; + if (menuPromptCall === 2) { + secondMenuAttempted.resolve(); + } + if (menuPromptCall <= 2) { + return { mode: "reset" }; + } + return { mode: "cancel" }; + }); + resetLocalStateMock.mockImplementationOnce(async () => { + firstResetStarted.resolve(); + await allowFirstResetToFinish.promise; + return { + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const firstRunPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstResetStarted.promise; + + const secondRunPromise = runCodexMultiAuthCli(["auth", "login"]); + await secondMenuAttempted.promise; + await skipLogged.promise; + + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + + allowFirstResetToFinish.resolve(); + + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRunPromise, + secondRunPromise, + ]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + logSpy.mockRestore(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue( diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts new file mode 100644 index 00000000..50083d9d --- /dev/null +++ b/test/destructive-actions.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clearAccountsMock = vi.fn(); +const clearFlaggedAccountsMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); +const clearCodexCliStateCacheMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex", "gpt-5.x"] as const, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, +})); + +vi.mock("../lib/storage.js", () => ({ + clearAccounts: clearAccountsMock, + clearFlaggedAccounts: clearFlaggedAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, +})); + +describe("destructive actions", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + clearAccountsMock.mockResolvedValue(true); + clearFlaggedAccountsMock.mockResolvedValue(true); + clearQuotaCacheMock.mockResolvedValue(true); + loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); + saveAccountsMock.mockResolvedValue(undefined); + saveFlaggedAccountsMock.mockResolvedValue(undefined); + }); + + it("returns delete-only results without pretending kept data was cleared", async () => { + const { deleteSavedAccounts } = await import( + "../lib/destructive-actions.js" + ); + + await expect(deleteSavedAccounts()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("returns reset results and clears Codex CLI state", async () => { + clearAccountsMock.mockResolvedValueOnce(true); + clearFlaggedAccountsMock.mockResolvedValueOnce(false); + clearQuotaCacheMock.mockResolvedValueOnce(true); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: true, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1); + }); + + it("does not clear Codex CLI state when resetLocalState aborts on an exception", async () => { + const resetError = Object.assign(new Error("flagged clear failed"), { + code: "EPERM", + }); + clearFlaggedAccountsMock.mockRejectedValueOnce(resetError); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).rejects.toBe(resetError); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("re-bases active indices before clamping when deleting an earlier account", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 2, "gpt-5.x": 1 }, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-active", + addedAt: 2, + lastUsed: 2, + }, + { + refreshToken: "refresh-other", + addedAt: 3, + lastUsed: 3, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 0 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([ + "refresh-active", + "refresh-other", + ]); + expect(deleted?.storage.activeIndex).toBe(0); + expect(deleted?.storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5.x": 0, + }); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeIndex: 0, + activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, + }), + ); + }); + + it("reloads flagged storage at delete time so newer flagged entries are preserved", async () => { + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + flaggedAt: 2, + }, + { + refreshToken: "refresh-newer", + addedAt: 3, + lastUsed: 3, + flaggedAt: 3, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 1 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.flagged.accounts).toEqual([ + expect.objectContaining({ refreshToken: "refresh-newer" }), + ]); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "refresh-newer" })], + }); + }); + + it("rethrows the original flagged-save failure after a successful rollback", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + await expect(deleteAccountAtIndex({ storage, index: 1 })).rejects.toBe( + flaggedSaveError, + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); + + it("preserves both the flagged-save failure and rollback failure", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + const rollbackError = Object.assign(new Error("rollback failed"), { + code: "EPERM", + }); + saveAccountsMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(rollbackError); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + try { + await deleteAccountAtIndex({ storage, index: 1 }); + throw new Error("expected deleteAccountAtIndex to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + const aggregateError = error as AggregateError; + expect(aggregateError.message).toBe( + "Deleting the account partially failed and rollback also failed.", + ); + expect(aggregateError.errors).toEqual([ + flaggedSaveError, + rollbackError, + ]); + } + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); +}); diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..fd712784 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("quota cache", () => { let tempDir: string; @@ -20,7 +21,7 @@ describe("quota cache", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("returns empty cache by default", async () => { @@ -79,6 +80,109 @@ describe("quota cache", () => { expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); }); + it("resolves the quota cache path from the current CODEX_MULTI_AUTH_DIR on each call", async () => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const firstPath = getQuotaCachePath(); + const nextTempDir = await fs.mkdtemp( + join(tmpdir(), "codex-multi-auth-quota-next-"), + ); + + try { + process.env.CODEX_MULTI_AUTH_DIR = nextTempDir; + const nextPath = getQuotaCachePath(); + + expect(nextPath).not.toBe(firstPath); + expect(nextPath).toBe(join(nextTempDir, "quota-cache.json")); + + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + await expect(fs.access(nextPath)).resolves.toBeUndefined(); + + await clearQuotaCache(); + await expect(fs.access(nextPath)).rejects.toThrow(); + } finally { + await removeWithRetry(nextTempDir, { recursive: true, force: true }); + } + }); + + it.each(["EBUSY", "EPERM"] as const)( + "retries transient %s while clearing cache", + async (code) => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + const realUnlink = fs.unlink.bind(fs); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + if (attempts < 3) { + const error = new Error( + `unlink failed: ${code}`, + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + } + return realUnlink(...args); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + await expect(fs.access(quotaCachePath)).rejects.toThrow(); + expect(attempts).toBe(3); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "returns false when clearQuotaCache exhausts %s retries", + async (code) => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + + try { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + const error = new Error(`locked: ${code}`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return Promise.resolve(); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(attempts).toBe(5); + await expect(fs.access(quotaCachePath)).resolves.toBeUndefined(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to clear quota cache quota-cache.json: locked: ${code}`, + ), + ); + } finally { + unlinkSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + } + }, + ); + it("retries transient EBUSY while loading cache", async () => { const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); diff --git a/test/release-main-prs-regression.test.ts b/test/release-main-prs-regression.test.ts index 6eb7929b..bd4d680b 100644 --- a/test/release-main-prs-regression.test.ts +++ b/test/release-main-prs-regression.test.ts @@ -199,7 +199,7 @@ describe("release-main-prs regressions", () => { return originalUnlink(targetPath); }); - await expect(clearFlaggedAccounts()).rejects.toThrow("EPERM primary delete"); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); const syncResult = await syncAccountStorageFromCodexCli(null); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index c1d50877..4bbe6bc8 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -207,8 +207,8 @@ describe("flagged account storage", () => { expect(existsSync(getFlaggedAccountsPath())).toBe(true); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); - await clearFlaggedAccounts(); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); + await expect(clearFlaggedAccounts()).resolves.toBe(true); expect(existsSync(getFlaggedAccountsPath())).toBe(false); expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(false); @@ -245,50 +245,100 @@ describe("flagged account storage", () => { ], }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(flagged.accounts).toHaveLength(0); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged storage", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stale-primary", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], }); - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); + const flaggedPath = getFlaggedAccountsPath(); + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged storage exhausts retryable %s failures", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stuck-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); - unlinkSpy.mockRestore(); - }); + const flaggedPath = getFlaggedAccountsPath(); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error( + "still locked", + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); it("does not recover flagged backups when the primary file exists but read fails", async () => { await saveFlaggedAccounts({ @@ -354,7 +404,7 @@ describe("flagged account storage", () => { const manualBackupPath = `${getFlaggedAccountsPath()}.manual-checkpoint`; await fs.copyFile(getFlaggedAccountsPath(), manualBackupPath); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(true); const flagged = await loadFlaggedAccounts(); expect(existsSync(manualBackupPath)).toBe(false); @@ -402,7 +452,7 @@ describe("flagged account storage", () => { return originalUnlink(targetPath); }); - await clearFlaggedAccounts(); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); expect(existsSync(backupPath)).toBe(true); @@ -411,45 +461,6 @@ describe("flagged account storage", () => { unlinkSpy.mockRestore(); }); - it("suppresses flagged accounts when clear cannot delete the primary file after writing the reset marker", async () => { - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - refreshToken: "stale-primary", - flaggedAt: 1, - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const flaggedPath = getFlaggedAccountsPath(); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === flaggedPath) { - const error = new Error( - "EPERM primary delete", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalUnlink(targetPath); - }); - - await expect(clearFlaggedAccounts()).rejects.toThrow( - "EPERM primary delete", - ); - - const flagged = await loadFlaggedAccounts(); - expect(existsSync(flaggedPath)).toBe(true); - expect(flagged.accounts).toHaveLength(0); - - unlinkSpy.mockRestore(); - }); - it("emits snapshot metadata for flagged account backups", async () => { await saveFlaggedAccounts({ version: 1, diff --git a/test/storage.test.ts b/test/storage.test.ts index 14c13ecd..790ee247 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,10 +2,12 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { buildNamedBackupPath, clearAccounts, + clearFlaggedAccounts, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -1782,6 +1784,130 @@ describe("storage", () => { it("does not throw when file does not exist", async () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( + "retries transient %s when clearing saved account artifacts", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(walPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearAccounts()).resolves.toBe(true); + expect(existsSync(testStoragePath)).toBe(false); + expect(existsSync(walPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); + + describe("clearFlaggedAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-flagged-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged account storage", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged account storage exhausts retryable %s failures", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("setStoragePath", () => { @@ -1943,6 +2069,37 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing saved accounts exhausts retryable %s failures", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearAccounts()).resolves.toBe(false); + expect(existsSync(testStoragePath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("StorageError with cause", () => { @@ -2356,6 +2513,36 @@ describe("storage", () => { expect(existsSync(legacyWorktreePath)).toBe(false); }); + it("clearAccounts removes legacy project and worktree account files for linked worktrees", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyProjectPath = join(worktreeRepo, ".codex", "openai-codex-accounts.json"); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + const storage = buildStorage([accountFromLegacy]); + + await fs.mkdir(dirname(canonicalPath), { recursive: true }); + await fs.mkdir(dirname(legacyProjectPath), { recursive: true }); + await fs.mkdir(dirname(legacyWorktreePath), { recursive: true }); + await Promise.all([ + fs.writeFile(canonicalPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyProjectPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyWorktreePath, JSON.stringify(storage), "utf-8"), + ]); + + await expect(clearAccounts()).resolves.toBe(true); + + expect(existsSync(canonicalPath)).toBe(false); + expect(existsSync(legacyProjectPath)).toBe(false); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + it("keeps legacy worktree file when migration persist fails", async () => { const { worktreeRepo } = await prepareWorktreeFixture(); @@ -2642,6 +2829,37 @@ describe("storage", () => { renameSpy.mockRestore(); }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { @@ -3095,12 +3313,111 @@ describe("storage", () => { Object.assign(new Error("EACCES error"), { code: "EACCES" }), ); - await clearAccounts(); + await expect(clearAccounts()).resolves.toBe(false); expect(unlinkSpy).toHaveBeenCalled(); unlinkSpy.mockRestore(); }); }); + + describe("clearQuotaCache", () => { + const tmpRoot = join( + tmpdir(), + `quota-cache-test-${Math.random().toString(36).slice(2)}`, + ); + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + process.env.CODEX_MULTI_AUTH_DIR = tmpRoot; + await fs.mkdir(tmpRoot, { recursive: true }); + }); + + afterEach(async () => { + if (originalDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalDir; + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it("removes only the quota cache file", async () => { + const quotaPath = getQuotaCachePath(); + const accountsPath = join(tmpRoot, "openai-codex-accounts.json"); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + await fs.writeFile(accountsPath, "{}", "utf-8"); + + expect(existsSync(quotaPath)).toBe(true); + expect(existsSync(accountsPath)).toBe(true); + + await expect(clearQuotaCache()).resolves.toBe(true); + + expect(existsSync(quotaPath)).toBe(false); + expect(existsSync(accountsPath)).toBe(true); + }); + + it("ignores missing quota cache file", async () => { + await expect(clearQuotaCache()).resolves.toBe(true); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing the quota cache", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { + const err = new Error("locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + return realUnlink(target); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + expect(existsSync(quotaPath)).toBe(false); + expect(unlinkSpy).toHaveBeenCalledTimes(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when quota-cache clear exhausts retryable %s failures", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath) { + const err = new Error("still locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + const err = new Error("missing") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(existsSync(quotaPath)).toBe(true); + expect(unlinkSpy).toHaveBeenCalledTimes(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); }); it("clearAccounts removes discovered backup artifacts as well as fixed slots", async () => { From 541ef0a4c68cdf9e8efc039368c795c8aff3381b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:23:17 +0800 Subject: [PATCH 02/76] feat(auth): add backup restore manager --- docs/reference/commands.md | 2 + docs/reference/public-api.md | 6 + docs/reference/storage-paths.md | 14 + lib/cli.ts | 11 + lib/codex-manager.ts | 550 ++++++-- lib/storage.ts | 789 ++++++++++- lib/ui/auth-menu.ts | 13 + lib/ui/copy.ts | 25 +- test/cli.test.ts | 24 + test/codex-manager-cli.test.ts | 2020 ++++++++++++++++++++++++++-- test/storage.test.ts | 2218 ++++++++++++++++++++++++++++++- 11 files changed, 5383 insertions(+), 289 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..f7a9cf0a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -111,6 +112,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 186ab1f5..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -22,6 +22,7 @@ Override root: | --- | --- | | Unified settings | `~/.codex/multi-auth/settings.json` | | Accounts | `~/.codex/multi-auth/openai-codex-accounts.json` | +| Named backups | `~/.codex/multi-auth/backups/.json` | | Accounts backup | `~/.codex/multi-auth/openai-codex-accounts.json.bak` | | Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | @@ -56,6 +57,7 @@ Backup metadata: When project-scoped behavior is enabled: - `~/.codex/multi-auth/projects//openai-codex-accounts.json` +- `~/.codex/multi-auth/projects//backups/.json` `` is derived as: @@ -100,6 +102,17 @@ Rules: - `.rotate.`, `.tmp`, and `.wal` names are rejected - existing files are not overwritten unless a lower-level force path is used explicitly +Restore workflow: + +1. Run `codex auth login`. +2. Open the `Recovery` section. +3. Choose `Restore From Backup`. +4. Pick a backup and confirm the merge summary before import. + +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths @@ -115,6 +128,7 @@ Experimental sync targets the companion `oc-chatgpt-multi-auth` storage layout: ## Verification Commands ```bash +codex auth login codex auth status codex auth list ``` diff --git a/lib/cli.ts b/lib/cli.ts index 363b1b2b..67c304db 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -57,6 +57,7 @@ export type LoginMode = | "check" | "deep-check" | "verify-flagged" + | "restore-backup" | "cancel"; export interface ExistingAccountInfo { @@ -233,6 +234,14 @@ async function promptLoginModeFallback( ) { return { mode: "verify-flagged" }; } + if ( + normalized === "u" || + normalized === "backup" || + normalized === "restore" || + normalized === "restore-backup" + ) { + return { mode: "restore-backup" }; + } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); @@ -287,6 +296,8 @@ export async function promptLoginMode( return { mode: "deep-check" }; case "verify-flagged": return { mode: "verify-flagged" }; + case "restore-backup": + return { mode: "restore-backup" }; case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 09e0a8f9..798aee92 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -57,6 +57,13 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + assessNamedBackupRestore, + getNamedBackupsDirectoryPath, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, + NAMED_BACKUP_ASSESS_CONCURRENCY, + restoreAssessedNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -78,6 +85,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; +import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; @@ -126,6 +134,19 @@ function formatReasonLabel(reason: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function formatRelativeDateShort( + timestamp: number | null | undefined, +): string | null { + if (timestamp === null || timestamp === undefined || timestamp === 0) + return null; + if (!Number.isFinite(timestamp)) return null; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -310,6 +331,7 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", + " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3941,161 +3963,179 @@ async function runAuthLogin(): Promise { let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - while (true) { - existingStorage = await loadAccounts(); - if (!existingStorage || existingStorage.accounts.length === 0) { - break; - } - const currentStorage = existingStorage; - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; - } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); + while (true) { + const existingStorage = await loadAccounts(); + const currentStorage = existingStorage ?? createEmptyAccountStorage(); + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } - const flaggedStorage = await loadFlaggedAccounts(); + } + const flaggedStorage = await loadFlaggedAccounts(); - const menuResult = await promptLoginMode( - toExistingAccountInfo(currentStorage, quotaCache, displaySettings), - { - flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, - }, - ); + const menuResult = await promptLoginMode( + toExistingAccountInfo(currentStorage, quotaCache, displaySettings), + { + flaggedCount: flaggedStorage.accounts.length, + statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + }, + ); - if (menuResult.mode === "cancel") { - console.log("Cancelled."); - return 0; - } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); - continue; + if (menuResult.mode === "cancel") { + console.log("Cancelled."); + return 0; + } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix" || + menuResult.mode === "restore-backup"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); - continue; + } + if (menuResult.mode === "check") { + await runActionPanel("Quick Check", "Checking local session + live status", async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "deep-check") { + await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "forecast") { + await runActionPanel("Best Account", "Comparing accounts", async () => { + await runForecast(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fix") { + await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { + await runFix(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "settings") { + await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "verify-flagged") { + await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { + await runVerifyFlagged([]); + }, displaySettings); + continue; + } + if (menuResult.mode === "restore-backup") { + try { + await runBackupRestoreManager(displaySettings); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "fresh" && menuResult.deleteAll) { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, - async () => { - const result = await deleteSavedAccounts(); - console.log( - result.accountsCleared - ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed - : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } + continue; + } + if (menuResult.mode === "reset") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); continue; } - if (menuResult.mode === "reset") { - if (destructiveActionInFlight) { - console.log("Another destructive action is already running. Wait for it to finish."); - continue; - } - destructiveActionInFlight = true; - try { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.resetLocalState.label, - DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, - async () => { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - const result = await resetLocalState(); - console.log( - result.accountsCleared && - result.flaggedCleared && - result.quotaCacheCleared - ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed - : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); - } finally { - destructiveActionInFlight = false; - } - continue; + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); - continue; - } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await handleManageAction(currentStorage, menuResult); continue; } - if (menuResult.mode === "add") { - break; - } + await runActionPanel("Applying Change", "Updating selected account", async () => { + await handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; } } @@ -4565,6 +4605,220 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +type BackupMenuAction = + | { + type: "restore"; + assessment: Awaited>; + } + | { type: "back" }; + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + // Reuse only within this list -> assess flow so storage.ts can safely treat + // the cache contents as LoadedBackupCandidate entries. + const candidateCache = new Map(); + let backups: Awaited>; + try { + backups = await listNamedBackups({ candidateCache }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNamedBackupContainmentError(error)) { + console.error( + `Backup validation failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + } else if (isNamedBackupPathValidationTransientError(error)) { + console.error(collapseWhitespace(message) || "unknown error"); + } else { + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return false; + } + if (backups.length === 0) { + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return true; + } + + const currentStorage = await loadAccounts(); + const assessments: Awaited>[] = []; + const assessmentFailures: string[] = []; + 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, + candidateCache, + }), + ), + ); + 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}`, + ); + } + } + if (assessments.length === 0) { + console.error( + `Could not assess any named backups in ${backupDir}: ${ + assessmentFailures.join("; ") || "all assessments failed" + }`, + ); + return false; + } + + const items: MenuItem[] = assessments.map((assessment) => { + const status = + assessment.eligibleForRestore + ? "ready" + : assessment.wouldExceedLimit + ? "limit" + : "invalid"; + const lastUpdated = formatRelativeDateShort(assessment.backup.updatedAt); + const parts = [ + assessment.backup.accountCount !== null + ? `${assessment.backup.accountCount} account${assessment.backup.accountCount === 1 ? "" : "s"}` + : undefined, + lastUpdated ? `updated ${lastUpdated}` : undefined, + assessment.wouldExceedLimit + ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` + : undefined, + assessment.error ?? assessment.backup.loadError, + ].filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); + + return { + label: assessment.backup.name, + hint: parts.length > 0 ? parts.join(" | ") : undefined, + value: { type: "restore", assessment }, + color: + status === "ready" ? "green" : status === "limit" ? "red" : "yellow", + disabled: !assessment.eligibleForRestore, + }; + }); + + items.push({ label: "Back", value: { type: "back" } }); + + const ui = getUiRuntimeOptions(); + const selection = await select(items, { + message: "Restore From Backup", + subtitle: backupDir, + help: UI_COPY.mainMenu.helpCompact, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: ui.theme, + }); + + if (!selection || selection.type === "back") { + return true; + } + + let latestAssessment: Awaited>; + try { + latestAssessment = await assessNamedBackupRestore( + selection.assessment.backup.name, + { currentStorage: await loadAccounts() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return false; + } + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); + return false; + } + + const netNewAccounts = latestAssessment.imported ?? 0; + const confirmMessage = UI_COPY.mainMenu.restoreBackupConfirm( + latestAssessment.backup.name, + netNewAccounts, + latestAssessment.backup.accountCount ?? 0, + latestAssessment.currentAccountCount, + latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount, + ); + const confirmed = await confirm(confirmMessage); + if (!confirmed) return true; + + try { + const result = await restoreAssessedNamedBackup(latestAssessment); + if (!result.changed) { + console.log("All accounts in this backup already exist"); + return true; + } + if (result.imported === 0) { + console.log( + UI_COPY.mainMenu.restoreBackupRefreshSuccess( + latestAssessment.backup.name, + ), + ); + } else { + console.log( + UI_COPY.mainMenu.restoreBackupSuccess( + latestAssessment.backup.name, + result.imported, + result.skipped, + result.total, + ), + ); + } + try { + const synced = await autoSyncActiveAccountToCodex(); + if (!synced) { + console.warn( + "Backup restored, but Codex CLI auth state could not be synced.", + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Backup restored, but Codex CLI auth sync failed: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + } + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const collapsedMessage = collapseWhitespace(message) || "unknown error"; + console.error( + /exceed maximum/i.test(collapsedMessage) + ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` + : `Restore failed: ${collapsedMessage}`, + ); + return false; + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); @@ -4625,6 +4879,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } + if (command === "restore-backup") { + setStoragePath(null); + try { + const completedWithoutFailure = + await runBackupRestoreManager(startupDisplaySettings); + return completedWithoutFailure ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return 1; + } + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index e29a4084..60720967 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,11 +1,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { + existsSync, + lstatSync, + promises as fs, + realpathSync, + type Dirent, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, + getNamedBackupRoot, resolveNamedBackupPath, } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -46,6 +53,15 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +// Max total wait across 6 sleeps is about 1.26 s with proportional jitter. +// That's acceptable for transient AV/file-lock recovery, but it also bounds how +// long the interactive restore menu can pause while listing or assessing backups. +const TRANSIENT_FILESYSTEM_MAX_ATTEMPTS = 7; +const TRANSIENT_FILESYSTEM_BASE_DELAY_MS = 10; +export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +// Each assessment does more I/O than a listing pass, so keep a lower ceiling to +// reduce transient AV/file-lock pressure on Windows restore menus. +export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +130,95 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; +export interface NamedBackupMetadata { + name: string; + path: string; + createdAt: number | null; + updatedAt: number | null; + sizeBytes: number | null; + version: number | null; + accountCount: number | null; + schemaErrors: string[]; + valid: boolean; + loadError?: string; +} + +export interface BackupRestoreAssessment { + backup: NamedBackupMetadata; + currentAccountCount: number; + mergedAccountCount: number | null; + imported: number | null; + // Accounts already present in current storage. Metadata-only refreshes can + // still report them here because they are merged rather than newly imported. + skipped: number | null; + wouldExceedLimit: boolean; + eligibleForRestore: boolean; + error?: string; +} + +type LoadedBackupCandidate = { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}; + +type NamedBackupCandidateCache = Map; + +class BackupContainmentError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupContainmentError"; + } +} + +class BackupPathValidationTransientError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackupPathValidationTransientError"; + } +} + +function isLoadedBackupCandidate( + candidate: unknown, +): candidate is LoadedBackupCandidate { + if (!candidate || typeof candidate !== "object") { + return false; + } + const typedCandidate = candidate as { + normalized?: unknown; + storedVersion?: unknown; + schemaErrors?: unknown; + error?: unknown; + }; + const normalized = typedCandidate.normalized; + return ( + "storedVersion" in typedCandidate && + Array.isArray(typedCandidate.schemaErrors) && + (normalized === null || + (typeof normalized === "object" && + normalized !== null && + Array.isArray((normalized as { accounts?: unknown }).accounts))) && + (typedCandidate.error === undefined || + typeof typedCandidate.error === "string") + ); +} + +function getCachedNamedBackupCandidate( + candidateCache: NamedBackupCandidateCache | undefined, + backupPath: string, +): LoadedBackupCandidate | undefined { + const candidate = candidateCache?.get(backupPath); + if (candidate === undefined) { + return undefined; + } + if (isLoadedBackupCandidate(candidate)) { + return candidate; + } + candidateCache?.delete(backupPath); + return undefined; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -168,6 +273,7 @@ let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; active: boolean; + storagePath: string; }>(); function withStorageLock(fn: () => Promise): Promise { @@ -1551,6 +1657,249 @@ export async function getRestoreAssessment(): Promise { }; } +export async function listNamedBackups( + options: { candidateCache?: Map } = {}, +): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + const candidateCache = options.candidateCache; + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backupEntries = entries + .filter((entry) => entry.isFile()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupMetadata[] = []; + let transientValidationError: BackupPathValidationTransientError | undefined; + for ( + let index = 0; + index < backupEntries.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backupEntries.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + const chunkResults = await Promise.allSettled( + chunk.map(async (entry) => { + const path = assertNamedBackupRestorePath( + resolvePath(join(backupRoot, entry.name)), + backupRoot, + ); + const candidate = await loadBackupCandidate(path); + candidateCache?.set(path, candidate); + return buildNamedBackupMetadata( + entry.name.slice(0, -".json".length), + path, + { candidate }, + ); + }), + ); + for (const [chunkIndex, result] of chunkResults.entries()) { + if (result.status === "fulfilled") { + backups.push(result.value); + continue; + } + if (isNamedBackupContainmentError(result.reason)) { + throw result.reason; + } + if ( + !transientValidationError && + isNamedBackupPathValidationTransientError(result.reason) + ) { + transientValidationError = result.reason; + } + log.warn("Skipped named backup during listing", { + path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), + error: String(result.reason), + }); + } + } + if (backups.length === 0 && transientValidationError) { + throw transientValidationError; + } + return backups.sort((left, right) => { + // Treat epoch (0), null, and non-finite mtimes as "unknown" so the + // sort order matches the restore hints, which also suppress them. + const leftUpdatedAt = left.updatedAt; + const leftTime = + typeof leftUpdatedAt === "number" && + Number.isFinite(leftUpdatedAt) && + leftUpdatedAt !== 0 + ? leftUpdatedAt + : 0; + const rightUpdatedAt = right.updatedAt; + const rightTime = + typeof rightUpdatedAt === "number" && + Number.isFinite(rightUpdatedAt) && + rightUpdatedAt !== 0 + ? rightUpdatedAt + : 0; + return rightTime - leftTime; + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return []; + } + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + throw error; + } +} + +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"; +} + +async function retryTransientFilesystemOperation( + operation: () => Promise, +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !isRetryableFilesystemErrorCode(code) || + attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 + ) { + 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), + ); + } + attempt += 1; + } +} + +export function getNamedBackupsDirectoryPath(): string { + return getNamedBackupRoot(getStoragePath()); +} + +export async function createNamedBackup( + name: string, + options: { force?: boolean } = {}, +): Promise { + const backupPath = await exportNamedBackup(name, options); + const candidate = await loadBackupCandidate(backupPath); + return buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); +} + +export async function assessNamedBackupRestore( + name: string, + options: { + currentStorage?: AccountStorageV3 | null; + candidateCache?: Map; + } = {}, +): Promise { + const backupPath = await resolveNamedBackupRestorePath(name); + const candidateCache = options.candidateCache; + const candidate = + getCachedNamedBackupCandidate(candidateCache, backupPath) ?? + (await loadBackupCandidate(backupPath)); + candidateCache?.delete(backupPath); + const backup = await buildNamedBackupMetadata( + basename(backupPath).slice(0, -".json".length), + backupPath, + { candidate }, + ); + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); + const currentAccounts = currentStorage?.accounts ?? []; + // Baseline merge math on a deduplicated current snapshot so pre-existing + // duplicate rows in storage cannot produce negative import counts. + const currentDeduplicatedAccounts = deduplicateAccounts([...currentAccounts]); + + if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: backup.loadError ?? "Backup is empty or invalid", + }; + } + + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...candidate.normalized.accounts, + ]); + const mergedAccounts = deduplicateAccounts([ + ...currentDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentDeduplicatedAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, incomingDeduplicatedAccounts.length - (imported ?? 0)); + const changed = !haveEquivalentAccountRows( + mergedAccounts, + currentDeduplicatedAccounts, + ); + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && changed, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : !changed + ? "All accounts in this backup already exist" + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise { + const assessment = await assessNamedBackupRestore(name); + return restoreAssessedNamedBackup(assessment); +} + +export async function restoreAssessedNamedBackup( + assessment: Pick, +): Promise { + if (!assessment.eligibleForRestore) { + throw new Error( + assessment.error ?? "Backup is not eligible for restore.", + ); + } + const resolvedPath = await resolveNamedBackupRestorePath( + assessment.backup.name, + ); + return importAccounts(resolvedPath); +} + function parseAndNormalizeStorage(data: unknown): { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1564,6 +1913,70 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + // Runtime always includes this field; it stays optional in the public type so + // older compatibility callers that only model the legacy shape do not break. + changed?: boolean; +}; + +function normalizeStoragePathForComparison(path: string): string { + const resolved = resolvePath(path); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function canonicalizeComparisonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeComparisonValue(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), + ); +} + +function stableStringifyForComparison(value: unknown): string { + return JSON.stringify(canonicalizeComparisonValue(value)); +} + +function haveEquivalentAccountRows( + left: readonly unknown[], + right: readonly unknown[], +): boolean { + // deduplicateAccounts() keeps the last occurrence of duplicates, so incoming + // rows win when we compare merged restore data against the current snapshot. + // That keeps index-aligned comparison correct for restore no-op detection. + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + stableStringifyForComparison(left[index]) !== + stableStringifyForComparison(right[index]) + ) { + return false; + } + } + return true; +} + +const namedBackupContainmentFs = { + lstat(path: string) { + return lstatSync(path); + }, + realpath(path: string) { + return realpathSync.native(path); + }, +}; + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1574,6 +1987,234 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + const errorMessage = + error instanceof SyntaxError + ? `Invalid JSON in import file: ${path}` + : (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Import file not found: ${path}` + : error instanceof Error + ? error.message + : String(error); + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: errorMessage, + }; + } +} + +function equalsNamedBackupEntry(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + +async function findExistingNamedBackupPath( + name: string, +): Promise { + const requested = (name ?? "").trim(); + if (!requested) { + return undefined; + } + + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; + + try { + entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + + return undefined; +} + +function resolvePathForNamedBackupContainment(path: string): string { + const resolvedPath = resolvePath(path); + let existingPrefix = resolvedPath; + const unresolvedSegments: string[] = []; + while (true) { + try { + namedBackupContainmentFs.lstat(existingPrefix); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + const parentPath = dirname(existingPrefix); + if (parentPath === existingPrefix) { + return resolvedPath; + } + unresolvedSegments.unshift(basename(existingPrefix)); + existingPrefix = parentPath; + continue; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } + } + try { + const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); + return unresolvedSegments.reduce( + (currentPath, segment) => join(currentPath, segment), + canonicalPrefix, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return resolvedPath; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const resolvedBackupRoot = resolvePath(backupRoot); + let backupRootIsSymlink = false; + try { + backupRootIsSymlink = + namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + backupRootIsSymlink = false; + } else if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } else { + throw error; + } + } + if (backupRootIsSymlink) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + const canonicalBackupRoot = + resolvePathForNamedBackupContainment(resolvedBackupRoot); + const containedPath = resolvePathForNamedBackupContainment(resolvedPath); + const relativePath = relative(canonicalBackupRoot, containedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + return containedPath; +} + +export function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof BackupContainmentError || + (error instanceof Error && /escapes backup directory/i.test(error.message)) + ); +} + +export function isNamedBackupPathValidationTransientError( + error: unknown, +): error is BackupPathValidationTransientError { + return ( + error instanceof BackupPathValidationTransientError || + (error instanceof Error && + /^Backup path validation failed(\.|:|\b)/i.test(error.message)) + ); +} + +export async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return assertNamedBackupRestorePath(existingPath, backupRoot); + } + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; + try { + builtPath = buildNamedBackupPath(requested); + } catch (error) { + // buildNamedBackupPath rejects names with special characters even when the + // requested backup name is a plain filename inside the backups directory. + // In that case, reporting ENOENT is clearer than surfacing the filename + // validator, but only when no separator/traversal token is present. + if ( + requested.length > 0 && + basename(requestedWithExtension) === requestedWithExtension && + !requestedWithExtension.includes("..") && + !/^[A-Za-z0-9_-]+$/.test(baseName) + ) { + throw new Error( + `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, + ); + } + throw error; + } + return assertNamedBackupRestorePath(builtPath, backupRoot); +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -1782,6 +2423,50 @@ async function loadAccountsInternal( } } +async function buildNamedBackupMetadata( + name: string, + path: string, + opts: { candidate?: Awaited> } = {}, +): Promise { + const candidate = opts.candidate ?? (await loadBackupCandidate(path)); + let stats: { + size?: number; + mtimeMs?: number; + birthtimeMs?: number; + ctimeMs?: number; + } | null = null; + try { + stats = await retryTransientFilesystemOperation(() => fs.stat(path)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to stat named backup", { path, error: String(error) }); + } + } + + const version = + candidate.normalized?.version ?? + (typeof candidate.storedVersion === "number" + ? candidate.storedVersion + : null); + const accountCount = candidate.normalized?.accounts.length ?? null; + const createdAt = stats?.birthtimeMs ?? stats?.ctimeMs ?? null; + const updatedAt = stats?.mtimeMs ?? null; + + return { + name, + path, + createdAt, + updatedAt, + sizeBytes: typeof stats?.size === "number" ? stats.size : null, + version, + accountCount, + schemaErrors: candidate.schemaErrors, + valid: !!candidate.normalized, + loadError: candidate.error, + }; +} + async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); @@ -1917,9 +2602,11 @@ export async function withAccountStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async (storage: AccountStorageV3): Promise => { @@ -1942,9 +2629,11 @@ export async function withAccountAndFlaggedStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, + storagePath, }; const current = state.snapshot; const persist = async ( @@ -2343,11 +3032,17 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); + const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); const storage = transactionState?.active - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + ? normalizeStoragePathForComparison(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)); if (!storage || storage.accounts.length === 0) { throw new Error("No accounts to export"); } @@ -2383,7 +3078,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); // Check file exists with friendly error @@ -2391,7 +3086,17 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - const content = await fs.readFile(resolvedPath, "utf-8"); + let content: string; + try { + content = await retryTransientFilesystemOperation(() => + fs.readFile(resolvedPath, "utf-8"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Import file not found: ${resolvedPath}`); + } + throw error; + } let imported: unknown; try { @@ -2409,22 +3114,48 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; + // Keep import counts anchored to a deduplicated current snapshot for the + // same reason as assessNamedBackupRestore. + const existingDeduplicatedAccounts = deduplicateAccounts([ + ...existingAccounts, + ]); + const incomingDeduplicatedAccounts = deduplicateAccounts([ + ...normalized.accounts, + ]); const existingActiveIndex = existing?.activeIndex ?? 0; - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } + const merged = [ + ...existingDeduplicatedAccounts, + ...incomingDeduplicatedAccounts, + ]; + const deduplicatedAccounts = deduplicateAccounts(merged); + if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, + ); } + const imported = + deduplicatedAccounts.length - existingDeduplicatedAccounts.length; + const skipped = Math.max( + 0, + incomingDeduplicatedAccounts.length - imported, + ); + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingDeduplicatedAccounts, + ); - const deduplicatedAccounts = deduplicateAccounts(merged); + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -2434,10 +3165,12 @@ export async function importAccounts( }; await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; }); log.info("Imported accounts", { @@ -2445,7 +3178,17 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, + changed, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } + +export const __testOnly = { + namedBackupContainmentFs, +}; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index fbe9293a..5f77ad62 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -64,6 +64,7 @@ export type AuthMenuAction = | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } + | { type: "restore-backup" } | { type: "select-account"; account: AccountInfo } | { type: "set-current-account"; account: AccountInfo } | { type: "refresh-account"; account: AccountInfo } @@ -517,6 +518,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "check": case "deep-check": case "verify-flagged": + case "restore-backup": case "search": case "delete-all": case "cancel": @@ -655,6 +657,17 @@ export async function showAuthMenu( ); } + items.push({ label: "", value: { type: "cancel" }, separator: true }); + items.push({ + label: UI_COPY.mainMenu.recovery, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.restoreBackup, + value: { type: "restore-backup" }, + color: "yellow", + }); items.push({ label: "", value: { type: "cancel" }, separator: true }); items.push({ label: UI_COPY.mainMenu.dangerZone, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index b4505e8e..1b14d107 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -14,6 +14,27 @@ export const UI_COPY = { accounts: "Saved Accounts", loadingLimits: "Fetching account limits...", noSearchMatches: "No accounts match your search", + recovery: "Recovery", + restoreBackup: "Restore From Backup", + restoreBackupConfirm: ( + name: string, + netNewAccounts: number, + backupAccountCount: number, + currentAccountCount: number, + mergedAccountCount: number, + ) => + netNewAccounts === 0 + ? `Restore backup "${name}"? This will refresh stored metadata for matching existing account(s) in this backup.` + : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, + restoreBackupSuccess: ( + name: string, + imported: number, + skipped: number, + total: number, + ) => + `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, + restoreBackupRefreshSuccess: (name: string) => + `Restored backup "${name}". Refreshed stored metadata for matching existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", @@ -131,8 +152,8 @@ export const UI_COPY = { addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (u) restore backup, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/u/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, u, f, r, q.", }, } as const; diff --git a/test/cli.test.ts b/test/cli.test.ts index a2750841..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -704,6 +704,30 @@ describe("CLI Module", () => { }); }); + it("returns restore-backup for fallback restore aliases", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("u"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + }); + it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { delete process.env.FORCE_INTERACTIVE_MODE; const { stdin, stdout } = await import("node:process"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1e42be7e..4f3c8cda 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,11 +1,25 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const MOCK_BACKUP_DIR = fileURLToPath( + new URL("./.vitest-mock-backups", import.meta.url), +); +const mockBackupPath = (name: string): string => + resolve(MOCK_BACKUP_DIR, `${name}.json`); + const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const listNamedBackupsMock = vi.fn(); +const assessNamedBackupRestoreMock = vi.fn(); +const getNamedBackupsDirectoryPathMock = vi.fn(); +const resolveNamedBackupRestorePathMock = vi.fn(); +const importAccountsMock = vi.fn(); +const restoreAssessedNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -116,6 +130,12 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock, + importAccounts: importAccountsMock, + restoreAssessedNamedBackup: restoreAssessedNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -199,6 +219,12 @@ vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); +const confirmMock = vi.fn(); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + vi.mock("../lib/oc-chatgpt-orchestrator.js", () => ({ planOcChatgptSync: planOcChatgptSyncMock, applyOcChatgptSync: applyOcChatgptSyncMock, @@ -464,6 +490,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -502,6 +529,51 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + resolveNamedBackupRestorePathMock.mockReset(); + importAccountsMock.mockReset(); + restoreAssessedNamedBackupMock.mockReset(); + confirmMock.mockReset(); + listNamedBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue({ + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }); + getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); + resolveNamedBackupRestorePathMock.mockImplementation(async (name: string) => + mockBackupPath(name), + ); + importAccountsMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + restoreAssessedNamedBackupMock.mockImplementation(async (assessment) => + importAccountsMock( + await resolveNamedBackupRestorePathMock(assessment.backup.name), + ), + ); + confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); @@ -3320,157 +3392,1663 @@ describe("codex manager cli commands", () => { ); }); - it("shows experimental settings in the settings hub", async () => { + it("restores a named backup from the login recovery menu", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - queueSettingsSelectSequence([{ type: "back" }]); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("runs experimental oc sync with mandatory preview before apply", async () => { + it("restores a named backup from the direct restore-backup command", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "ready", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", - }, - preview: { - payload: { version: 3, accounts: [], activeIndex: 0 }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - toAdd: [{ refreshTokenLast4: "1234" }], - toUpdate: [], - toSkip: [], - unchangedDestinationOnly: [], - activeSelectionBehavior: "preserve-destination", - }, - payload: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - applyOcChatgptSyncMock.mockResolvedValue({ - kind: "applied", - target: { - scope: "global", - root: "C:/target", - accountPath: "C:/target/openai-codex-accounts.json", - backupRoot: "C:/target/backups", - source: "default-global", - resolution: "accounts", + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, }, - preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, - merged: { version: 3, accounts: [], activeIndex: 0 }, - destination: null, - persistedPath: "C:/target/openai-codex-accounts.json", - }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "apply" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(selectMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), - ]), - expect.any(Object), + expect(setStoragePathMock).toHaveBeenCalledWith(null); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); - it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - planOcChatgptSyncMock.mockResolvedValue({ - kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "sync" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + importAccountsMock.mockRejectedValueOnce(new Error("backup locked")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); - expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith("Restore failed: backup locked"); + } finally { + errorSpy.mockRestore(); + } }); - - it("exports named pool backup from experimental settings", async () => { + it("returns a non-zero exit code when every direct restore assessment fails", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + listNamedBackupsMock.mockResolvedValue([ + { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("backup busy", "EBUSY"), + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not assess any named backups in"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("named-backup: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); - expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + it("rejects a restore when the backup root changes before the final import path check", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + resolveNamedBackupRestorePathMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Backup path escapes backup directory", + ); + } finally { + errorSpy.mockRestore(); + } }); - it("rejects invalid or colliding experimental backup filenames", async () => { + it("offers backup restore from the login menu when no accounts are saved", async () => { + setInteractiveTTY(true); const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); - const selectSequence = queueSettingsSelectSequence([ - { type: "experimental" }, - { type: "backup" }, - { type: "back" }, - { type: "back" }, - { type: "back" }, - ]); + const restoredStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + accountId: "acc_restored", + refreshToken: "refresh-restored", + accessToken: "access-restored", + expiresAt: now + 3_600_000, + addedAt: now - 500, + lastUsed: now - 500, + enabled: true, + }, + ], + }; + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(restoredStorage); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(selectSequence.remaining()).toBe(0); - expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); - }); - - it("backs out of experimental sync preview without applying", async () => { - const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc_restored", + email: "restored@example.com", + refreshToken: "refresh-restored", + accessToken: "access-restored", + }), + ); + }); + + it("does not restore a named backup when confirmation is declined", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(false); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).not.toHaveBeenCalled(); + }); + + it("catches restore failures and returns to the login menu", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error(`Import file not found: ${mockBackupPath("named-backup")}`), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + const restoreFailureCalls = [ + ...errorSpy.mock.calls, + ...logSpy.mock.calls, + ].flat(); + expect(restoreFailureCalls).toContainEqual( + expect.stringContaining("Restore failed: Import file not found"), + ); + } finally { + errorSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it("adds actionable guidance when a confirmed restore exceeds the account limit", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockRejectedValueOnce( + new Error("Import would exceed maximum of 10 accounts (would have 11)"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + "Restore failed: Import would exceed maximum of 10 accounts (would have 11). Close other Codex instances and try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("treats post-confirm duplicate-only restores as a no-op", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restored backup "named-backup"'), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("catches backup listing failures and returns to the login menu", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + makeErrnoError( + "EPERM: operation not permitted, scandir '/mock/backups'", + "EPERM", + ), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Could not read backup directory: EPERM: operation not permitted", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("reports backup validation failures separately from directory read failures", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Backup validation failed: Backup path escapes backup directory", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("surfaces transient backup path validation failures with retry guidance", async () => { + setInteractiveTTY(true); + listNamedBackupsMock.mockRejectedValueOnce( + new Error("Backup path validation failed. Try again."), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup path validation failed. Try again.", + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("propagates containment errors from batch backup assessment and returns to the login menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + listNamedBackupsMock.mockResolvedValue([ + { + name: "escaped-backup", + path: mockBackupPath("escaped-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + new Error("Backup path escapes backup directory"), + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(selectMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Restore failed: Backup path escapes backup directory", + ), + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Skipped backup assessment for "escaped-backup"'), + ); + } finally { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + + it("keeps healthy backups selectable when one assessment fails", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.now(); + const healthyAssessment = { + backup: { + name: "healthy-backup", + path: mockBackupPath("healthy-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([ + { + ...healthyAssessment.backup, + name: "broken-backup", + path: mockBackupPath("broken-backup"), + }, + healthyAssessment.backup, + ]); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup directory busy"); + } + return healthyAssessment; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockImplementationOnce(async (items) => { + const labels = items.map((item) => item.label); + expect(labels).toContain("healthy-backup"); + expect(labels).not.toContain("broken-backup"); + return { type: "restore", assessment: healthyAssessment }; + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("healthy-backup"), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipped backup assessment for "broken-backup": backup directory busy', + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it("limits concurrent backup assessments in the restore menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const { NAMED_BACKUP_ASSESS_CONCURRENCY } = + await vi.importActual( + "../lib/storage.js", + ); + const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; + const backups = Array.from({ length: totalBackups }, (_value, index) => ({ + name: `named-backup-${index + 1}`, + path: mockBackupPath(`named-backup-${index + 1}`), + createdAt: null, + updatedAt: Date.now() + index, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + })); + const backupsByName = new Map(backups.map((backup) => [backup.name, backup])); + let inFlight = 0; + let maxInFlight = 0; + let pending: Array>> = []; + let releaseScheduled = false; + const releasePending = () => { + if (releaseScheduled) { + return; + } + releaseScheduled = true; + queueMicrotask(() => { + releaseScheduled = false; + if (pending.length === 0) { + return; + } + const release = pending; + pending = []; + for (const deferred of release) { + deferred.resolve(); + } + }); + }; + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const gate = createDeferred(); + pending.push(gate); + releasePending(); + await gate.promise; + inFlight -= 1; + return { + backup: backupsByName.get(name) ?? backups[0], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); + expect(maxInFlight).toBeLessThanOrEqual( + NAMED_BACKUP_ASSESS_CONCURRENCY, + ); + }); + + it("reassesses a backup before confirmation so the merge summary stays current", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 3, + mergedAccountCount: 4, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining("add 1 new account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + }); + + it("uses metadata refresh wording when a restore only updates existing accounts", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "same@example.com", + accountId: "acc_same", + refreshToken: "refresh-same", + accessToken: "access-same", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 2, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 2, + mergedAccountCount: 2, + imported: 0, + skipped: 2, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 2, + total: 2, + changed: true, + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining( + "refresh stored metadata for matching existing account(s)", + ), + ); + expect(confirmMock).not.toHaveBeenCalledWith( + expect.stringContaining("for 2 existing account(s)"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + mockBackupPath("named-backup"), + ); + expect(logSpy).toHaveBeenCalledWith( + 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Imported 0, skipped 2"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment becomes ineligible", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + const refreshedAssessment = { + ...initialAssessment, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 1, + eligibleForRestore: false, + error: "All accounts in this backup already exist", + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockResolvedValueOnce(refreshedAssessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "All accounts in this backup already exist", + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("returns to the login menu when backup reassessment fails before confirmation", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { + backup: { + name: "named-backup", + path: mockBackupPath("named-backup"), + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Restore failed: backup busy"), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("shows epoch backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "epoch-backup", + path: mockBackupPath("epoch-backup"), + createdAt: null, + updatedAt: 0, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("formats recent backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const now = Date.UTC(2026, 0, 10, 12, 0, 0); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const backups = [ + { + name: "today-backup", + path: mockBackupPath("today-backup"), + createdAt: null, + updatedAt: now - 1_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "yesterday-backup", + path: mockBackupPath("yesterday-backup"), + createdAt: null, + updatedAt: now - 1.5 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "three-days-backup", + path: mockBackupPath("three-days-backup"), + createdAt: null, + updatedAt: now - 3 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "older-backup", + path: mockBackupPath("older-backup"), + createdAt: null, + updatedAt: now - 8 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assessmentsByName = new Map( + backups.map((backup) => [ + backup.name, + { + backup, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }, + ]), + ); + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + return assessmentsByName.get(name) ?? assessmentsByName.get(backups[0].name)!; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("updated today"); + expect(backupItems?.[1]?.hint).toContain("updated yesterday"); + expect(backupItems?.[2]?.hint).toContain("updated 3d ago"); + expect(backupItems?.[3]?.hint).toContain("updated "); + } finally { + nowSpy.mockRestore(); + } + }); + + it("suppresses invalid backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "nan-backup", + path: mockBackupPath("nan-backup"), + createdAt: null, + updatedAt: Number.NaN, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "back" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const backupItems = selectMock.mock.calls[0]?.[0]; + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); + }); + + it("shows experimental settings in the settings hub", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + queueSettingsSelectSequence([{ type: "back" }]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + }); + + it("runs experimental oc sync with mandatory preview before apply", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "ready", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [{ refreshTokenLast4: "1234" }], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, + payload: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + }); + applyOcChatgptSyncMock.mockResolvedValue({ + kind: "applied", + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { merged: { version: 3, accounts: [], activeIndex: 0 } }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + destination: null, + persistedPath: "C:/target/openai-codex-accounts.json", + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "apply" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(selectMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + ]), + expect.any(Object), + ); + }); + + it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-ambiguous", + detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "sync" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(planOcChatgptSyncMock).toHaveBeenCalledOnce(); + expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); + }); + + + it("exports named pool backup from experimental settings", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + }); + + it("rejects invalid or colliding experimental backup filenames", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + promptQuestionMock.mockResolvedValueOnce("../bad-name"); + runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + const selectSequence = queueSettingsSelectSequence([ + { type: "experimental" }, + { type: "backup" }, + { type: "back" }, + { type: "back" }, + { type: "back" }, + ]); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectSequence.remaining()).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledOnce(); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + }); + + it("backs out of experimental sync preview without applying", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", @@ -4457,6 +6035,218 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before starting quick check", async () => { + const now = Date.now(); + const menuStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + refreshToken: "refresh-alpha", + addedAt: now, + lastUsed: now, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + refreshToken: "refresh-beta", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + const quickCheckStorage = { + ...menuStorage, + accounts: [menuStorage.accounts[0]!], + }; + let loadAccountsCalls = 0; + loadAccountsMock.mockImplementation(async () => { + loadAccountsCalls += 1; + return structuredClone( + loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, + ); + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-beta"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual( + expect.arrayContaining(["alpha@example.com", "beta@example.com"]), + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("waits for an in-flight menu quota refresh before starting backup restore", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restore@example.com", + accountId: "acc-restore", + accessToken: "access-restore", + expiresAt: now + 3_600_000, + refreshToken: "refresh-restore", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const fetchStarted = createDeferred(); + const releaseFetch = createDeferred(); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + await releaseFetch.promise; + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }); + listNamedBackupsMock.mockResolvedValue([]); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + + releaseFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + listNamedBackupsMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); + } finally { + logSpy.mockRestore(); + } + }); + it("skips a second destructive action while reset is already running", async () => { const now = Date.now(); const skipMessage = diff --git a/test/storage.test.ts b/test/storage.test.ts index 790ee247..7e37dcfa 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,13 +1,19 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + __testOnly, + assessNamedBackupRestore, + assertNamedBackupRestorePath, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, @@ -15,11 +21,18 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, + isNamedBackupContainmentError, + isNamedBackupPathValidationTransientError, + listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, + resolveNamedBackupRestorePath, + restoreAssessedNamedBackup, + restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, StorageError, @@ -327,7 +340,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("should export accounts to a file", async () => { @@ -363,6 +376,69 @@ describe("storage", () => { ); }); + it("throws when exporting inside an active transaction for a different storage path", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export", + refreshToken: "ref-transactional-export", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const alternateStoragePath = join(testWorkDir, "alternate-accounts.json"); + + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(alternateStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).rejects.toThrow(/different storage path/); + }); + + it("allows exporting inside an active transaction when the storage path only differs by case on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "transactional-export-same-path", + refreshToken: "ref-transactional-export-same-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const casedStoragePath = testStoragePath.toUpperCase(); + + try { + await expect( + withAccountStorageTransaction(async () => { + setStoragePathDirect(casedStoragePath); + try { + await exportAccounts(exportPath); + } finally { + setStoragePathDirect(testStoragePath); + } + }), + ).resolves.toBeUndefined(); + expect(existsSync(exportPath)).toBe(true); + } finally { + platformSpy.mockRestore(); + } + }); + it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -399,6 +475,243 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); + it("should skip persisting duplicate-only imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + await saveAccounts(existing); + await fs.writeFile(exportPath, JSON.stringify(existing)); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should treat deduplicated current snapshots as a no-op import", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should deduplicate incoming backup rows before reporting skipped imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await clearAccounts(); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + lastUsed: 2, + }); + }); + + it("should persist duplicate-only imports when they refresh stored metadata", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "existing", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("should skip semantically identical duplicate-only imports even when key order differs", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -444,7 +757,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -495,7 +813,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -541,7 +864,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -920,10 +1248,78 @@ describe("storage", () => { ); }); + it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { + const nearLimitAccounts = Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: nearLimitAccounts, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-one", + refreshToken: "ref-extra-one", + addedAt: 10_000, + lastUsed: 10_000, + }, + ], + }), + ); + + const first = await importAccounts(exportPath); + expect(first).toMatchObject({ + imported: 1, + skipped: 0, + total: ACCOUNT_LIMITS.MAX_ACCOUNTS, + changed: true, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-two", + refreshToken: "ref-extra-two", + addedAt: 20_000, + lastUsed: 20_000, + }, + ], + }), + ); + + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + loaded?.accounts.some((account) => account.accountId === "extra-two"), + ).toBe(false); + }); + it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); - setStoragePathDirect(testStoragePath); - await expect(exportAccounts(exportPath)).rejects.toThrow( + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(testStoragePath); + await storageModule.clearAccounts(); + await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); }); @@ -936,6 +1332,51 @@ describe("storage", () => { ); }); + it("retries transient import read errors before parsing the backup", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-import-read", + refreshToken: "ref-retry-import-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === exportPath && busyFailures === 0) { + busyFailures += 1; + const error = new Error("import file busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); @@ -1073,6 +1514,1767 @@ describe("storage", () => { ); }); }); + + it("creates and lists named backups with metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acct-backup", + refreshToken: "ref-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + + const backup = await createNamedBackup("backup-2026-03-12"); + const backups = await listNamedBackups(); + + expect(backup.name).toBe("backup-2026-03-12"); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "backup-2026-03-12", + accountCount: 1, + valid: true, + }), + ]), + ); + }); + + it("lists named backups across the chunk boundary", async () => { + const expectedNames: string[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `basic-chunk-${index}`, + refreshToken: `ref-basic-chunk-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const name = `basic-chunk-${String(index).padStart(2, "0")}`; + expectedNames.push(name); + await createNamedBackup(name); + } + + const backups = await listNamedBackups(); + + expect(backups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(backups).toEqual( + expect.arrayContaining( + expectedNames.map((name) => + expect.objectContaining({ + name, + accountCount: 1, + valid: true, + }), + ), + ), + ); + }); + + it("returns a contained fallback path for missing named backups", async () => { + const requestedName = " missing-backup "; + const resolvedPath = + await resolveNamedBackupRestorePath(requestedName); + + expect(resolvedPath).toBe(buildNamedBackupPath("missing-backup")); + await expect(importAccounts(resolvedPath)).rejects.toThrow( + /Import file not found/, + ); + }); + + it("maps read-time ENOENT back to the import file-not-found contract", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "read-race", + refreshToken: "ref-read-race", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("read-race"); + const originalReadFile = fs.readFile.bind(fs); + let injectedEnoent = false; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && !injectedEnoent) { + injectedEnoent = true; + const error = new Error("backup disappeared") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + await expect(importAccounts(backup.path)).rejects.toThrow( + `Import file not found: ${backup.path}`, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("assesses eligibility and restores a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("restore-me"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("restore-me"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + const restoreResult = await restoreNamedBackup("restore-me"); + expect(restoreResult.total).toBe(1); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]?.accountId).toBe("primary"); + }); + + it("honors explicit null currentStorage when assessing a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-account", + refreshToken: "ref-backup-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("explicit-null-current-storage"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore( + "explicit-null-current-storage", + { currentStorage: null }, + ); + + expect(assessment.currentAccountCount).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("deduplicates incoming backup rows when assessing restore counts", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "internal-duplicates.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-account", + email: "duplicate-account@example.com", + refreshToken: "ref-duplicate-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("internal-duplicates"); + + expect(assessment.imported).toBe(1); + expect(assessment.skipped).toBe(0); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + }); + + it("rejects duplicate-only backups with nothing new to restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("already-present"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("already-present"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("already-present")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("treats deduplicated current snapshots as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("repair-current-duplicates"); + + const assessment = await assessNamedBackupRestore( + "repair-current-duplicates", + { + currentStorage: { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + }, + ); + expect(assessment.currentAccountCount).toBe(2); + expect(assessment.mergedAccountCount).toBe(1); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect( + restoreNamedBackup("repair-current-duplicates"), + ).rejects.toThrow("All accounts in this backup already exist"); + }); + + it("treats identical accounts in a different backup order as a no-op restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await createNamedBackup("reversed-order"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "second-account", + email: "second@example.com", + refreshToken: "ref-second-account", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "first-account", + email: "first@example.com", + refreshToken: "ref-first-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("reversed-order"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(2); + expect(assessment.eligibleForRestore).toBe(false); + expect(assessment.error).toBe("All accounts in this backup already exist"); + + await expect(restoreNamedBackup("reversed-order")).rejects.toThrow( + "All accounts in this backup already exist", + ); + }); + + it("keeps metadata-only backups eligible for restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }); + await createNamedBackup("metadata-refresh"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "ref-existing-account", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("metadata-refresh"); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.error).toBeUndefined(); + + const restoreResult = await restoreNamedBackup("metadata-refresh"); + expect(restoreResult).toMatchObject({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]).toMatchObject({ + accountId: "existing-account", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("restores manually named backups that already exist inside the backups directory", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("restores manually named backups with uppercase JSON extensions", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.JSON", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-uppercase", + refreshToken: "ref-manual-uppercase", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Manual Backup", valid: true }), + ]), + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + + it("throws when a named backup is deleted after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-backup", + refreshToken: "ref-deleted-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("deleted-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect( + restoreNamedBackup("deleted-after-assessment"), + ).rejects.toThrow(/Import file not found/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("re-resolves an assessed named backup before the final import", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "deleted-helper", + refreshToken: "ref-deleted-helper", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("deleted-helper-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore( + "deleted-helper-assessment", + ); + expect(assessment.eligibleForRestore).toBe(true); + + await removeWithRetry(backup.path, { force: true }); + + await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow( + /Import file not found/, + ); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it("throws when a named backup becomes invalid JSON after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "invalid-backup", + refreshToken: "ref-invalid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("invalid-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("invalid-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await fs.writeFile(backup.path, "not valid json {[", "utf-8"); + + await expect( + restoreNamedBackup("invalid-after-assessment"), + ).rejects.toThrow(/Invalid JSON in import file/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( + "rejects backup names that escape the backups directory: %s", + async (input) => { + await expect(assessNamedBackupRestore(input)).rejects.toThrow( + /must not contain path separators/i, + ); + await expect(restoreNamedBackup(input)).rejects.toThrow( + /must not contain path separators/i, + ); + }, + ); + + it("allows backup filenames that begin with dots when they stay inside the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "..notes.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "leading-dot-backup", + refreshToken: "ref-leading-dot-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + const assessment = await assessNamedBackupRestore("..notes"); + expect(assessment.eligibleForRestore).toBe(true); + + const result = await restoreNamedBackup("..notes"); + expect(result.imported).toBe(1); + expect((await loadAccounts())?.accounts).toHaveLength(1); + }); + + it("rejects matched backup entries whose resolved path escapes the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(assessNamedBackupRestore("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + await expect(restoreNamedBackup("../escaped-entry")).rejects.toThrow( + /escapes backup directory/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside"); + const linkedRoot = join(backupRoot, "linked"); + const outsideBackupPath = join(outsideRoot, "escape.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile( + outsideBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-escape", + refreshToken: "ref-linked-escape", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "escape.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects missing files beneath symlinked backup subdirectories", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const outsideRoot = join(testWorkDir, "outside-missing"); + const linkedRoot = join(backupRoot, "linked-missing"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.symlink( + resolve(outsideRoot), + linkedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedRoot, "missing.json"), + backupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rejects symlinked backup roots during restore path validation", async () => { + const canonicalBackupRoot = join(testWorkDir, "canonical-backups"); + const linkedBackupRoot = join(testWorkDir, "linked-backups"); + const backupPath = join(canonicalBackupRoot, "linked-root.json"); + await fs.mkdir(canonicalBackupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "linked-root", + refreshToken: "ref-linked-root", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await fs.symlink( + resolve(canonicalBackupRoot), + linkedBackupRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + expect(() => + assertNamedBackupRestorePath( + join(linkedBackupRoot, "linked-root.json"), + linkedBackupRoot, + ), + ).toThrow(/escapes backup directory/i); + }); + + it("rethrows realpath containment errors for existing backup paths", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "locked-path", + refreshToken: "ref-locked-path", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupPath)) { + const error = new Error( + "backup path locked", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient realpath errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root busy", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("classifies transient lstat errors for the backup root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const originalLstat = __testOnly.namedBackupContainmentFs.lstat; + const lstatSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "lstat") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error("backup root locked") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalLstat(path); + }); + + try { + expect(() => + assertNamedBackupRestorePath(backupPath, backupRoot), + ).toThrow("Backup path validation failed. Try again."); + } finally { + lstatSpy.mockRestore(); + } + }); + + it("classifies transient backup path validation errors separately from containment escapes", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const backupPath = join(backupRoot, "pending", "locked.json"); + await fs.mkdir(backupRoot, { recursive: true }); + const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; + const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; + const realpathSpy = vi + .spyOn(__testOnly.namedBackupContainmentFs, "realpath") + .mockImplementation((path) => { + if (String(path) === resolve(backupRoot)) { + const error = new Error( + "backup root locked", + ) as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalRealpath(path); + }); + + try { + let thrown: unknown; + try { + assertNamedBackupRestorePath(backupPath, backupRoot); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(isNamedBackupPathValidationTransientError(thrown)).toBe(true); + expect(isNamedBackupContainmentError(thrown)).toBe(false); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("rejects named backup listings whose resolved paths escape the backups directory", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const escapedEntry = { + name: "../escaped-entry.json", + isFile: () => true, + isSymbolicLink: () => false, + } as unknown as Awaited>[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + await expect(listNamedBackups()).rejects.toThrow(/escapes backup directory/i); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); + + it("ignores symlink-like named backup entries that point outside the backups root", async () => { + const backupRoot = join(dirname(testStoragePath), "backups"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); + await fs.mkdir(backupRoot, { recursive: true }); + await fs.writeFile( + externalBackupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, "readdir"); + const escapedEntry = { + name: "escaped-link.json", + isFile: () => false, + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; + readdirSpy.mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + return [escapedEntry] as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, + ); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while listing backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("rethrows unreadable backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EPERM", + }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EAGAIN", + }); + expect(readdirSpy).toHaveBeenCalledTimes(7); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EBUSY backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir", + refreshToken: "ref-retry-list-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-list-dir", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while listing backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-not-empty", + refreshToken: "ref-retry-list-dir-not-empty", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-not-empty"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory not empty yet", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-not-empty", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("retries transient EPERM backup directory errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-list-dir-eperm", + refreshToken: "ref-retry-list-dir-eperm", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-list-dir-eperm"); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "retry-list-dir-eperm", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + const backups: Awaited>[] = []; + for ( + let index = 0; + index <= NAMED_BACKUP_LIST_CONCURRENCY; + index += 1 + ) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `chunk-boundary-${index}`, + refreshToken: `ref-chunk-boundary-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + backups.push( + await createNamedBackup(`chunk-boundary-${String(index).padStart(2, "0")}`), + ); + } + + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + const originalReadFile = fs.readFile.bind(fs); + const secondChunkBackup = backups.at(-1); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path, options] = args; + if ( + String(path) === backupRoot && + typeof options === "object" && + options?.withFileTypes === true + ) { + const entries = await originalReaddir( + ...(args as Parameters), + ); + return [...entries].sort((left, right) => + left.name.localeCompare(right.name), + ) as Awaited>; + } + return originalReaddir(...(args as Parameters)); + }); + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === secondChunkBackup?.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const listedBackups = await listNamedBackups(); + expect(listedBackups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); + expect(listedBackups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: `chunk-boundary-${String( + NAMED_BACKUP_LIST_CONCURRENCY, + ).padStart(2, "0")}`, + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + expect( + readFileSpy.mock.calls.filter( + ([path]) => String(path) === secondChunkBackup?.path, + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient EAGAIN backup directory errors while restoring backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-restore-dir", + refreshToken: "ref-retry-restore-dir", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-restore-dir"); + await clearAccounts(); + const backupRoot = join(dirname(testStoragePath), "backups"); + const originalReaddir = fs.readdir.bind(fs); + let busyFailures = 0; + const readdirSpy = vi + .spyOn(fs, "readdir") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupRoot && busyFailures === 0) { + busyFailures += 1; + const error = new Error( + "backup directory busy", + ) as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReaddir(...(args as Parameters)); + }); + + try { + const result = await restoreNamedBackup("retry-restore-dir"); + expect(result.total).toBe(1); + expect(busyFailures).toBe(1); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("throws file-not-found when a manually named backup disappears after assessment", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Manual Backup.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual-missing", + refreshToken: "ref-manual-missing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.eligibleForRestore).toBe(true); + const storageBeforeRestore = await loadAccounts(); + expect(storageBeforeRestore?.accounts ?? []).toHaveLength(0); + + await removeWithRetry(backupPath, { force: true }); + + await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( + /Import file not found/, + ); + expect(await loadAccounts()).toEqual(storageBeforeRestore); + }); + + it("retries transient EBUSY backup read errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-read", + refreshToken: "ref-retry-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-read"); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup file busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-read", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("retries transient backup stat EAGAIN errors while listing backups", async () => { + let statSpy: ReturnType | undefined; + try { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-stat", + refreshToken: "ref-retry-stat", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("retry-stat"); + const originalStat = fs.stat.bind(fs); + let busyFailures = 0; + statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup stat busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalStat(...(args as Parameters)); + }); + + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + statSpy?.mockRestore(); + } + }); + + it("sorts backups with invalid timestamps after finite timestamps", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "valid-backup", + refreshToken: "ref-valid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const validBackup = await createNamedBackup("valid-backup"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "nan-backup", + refreshToken: "ref-nan-backup", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + const nanBackup = await createNamedBackup("nan-backup"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + const stats = await originalStat(...(args as Parameters)); + if (String(path) === nanBackup.path) { + return { + ...stats, + mtimeMs: Number.NaN, + } as Awaited>; + } + return stats; + }); + + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); + + it("reuses freshly listed backup candidates for the first restore assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "cached-backup", + refreshToken: "ref-cached-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("cached-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map(); + + try { + await listNamedBackups({ candidateCache }); + await assessNamedBackupRestore("cached-backup", { + currentStorage: null, + candidateCache, + }); + + const firstPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(firstPassReads).toHaveLength(1); + + await assessNamedBackupRestore("cached-backup", { currentStorage: null }); + + const secondPassReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(secondPassReads).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("ignores invalid externally provided candidate cache entries", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "external-cache-backup", + refreshToken: "ref-external-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("external-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map([ + [ + backup.path, + { + normalized: { version: 3 }, + storedVersion: 3, + schemaErrors: [], + }, + ], + ]); + + try { + const assessment = await assessNamedBackupRestore( + "external-cache-backup", + { + currentStorage: null, + candidateCache, + }, + ); + expect(assessment).toEqual( + expect.objectContaining({ + eligibleForRestore: true, + backup: expect.objectContaining({ + name: "external-cache-backup", + path: backup.path, + }), + }), + ); + expect( + readFileSpy.mock.calls.filter(([path]) => path === backup.path), + ).toHaveLength(1); + expect(candidateCache.has(backup.path)).toBe(false); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps per-call named-backup caches isolated across concurrent listings", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "isolated-cache-backup", + refreshToken: "ref-isolated-cache-backup", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + const backup = await createNamedBackup("isolated-cache-backup"); + const readFileSpy = vi.spyOn(fs, "readFile"); + const firstCandidateCache = new Map(); + const secondCandidateCache = new Map(); + + try { + await Promise.all([ + listNamedBackups({ candidateCache: firstCandidateCache }), + listNamedBackups({ candidateCache: secondCandidateCache }), + ]); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: firstCandidateCache, + }); + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + candidateCache: secondCandidateCache, + }); + + const cachedReads = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(cachedReads).toHaveLength(2); + + await assessNamedBackupRestore("isolated-cache-backup", { + currentStorage: null, + }); + + const rereadCalls = readFileSpy.mock.calls.filter( + ([path]) => path === backup.path, + ); + expect(rereadCalls).toHaveLength(3); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("limits concurrent backup reads while listing backups", async () => { + const backupPaths: string[] = []; + const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; + for (let index = 0; index < totalBackups; index += 1) { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: `concurrency-${index}`, + refreshToken: `ref-concurrency-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }, + ], + }); + const backup = await createNamedBackup(`concurrency-${index}`); + backupPaths.push(backup.path); + } + + const originalReadFile = fs.readFile.bind(fs); + const delayedPaths = new Set(backupPaths); + let activeReads = 0; + let peakReads = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (delayedPaths.has(String(path))) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + return await originalReadFile( + ...(args as Parameters), + ); + } finally { + activeReads -= 1; + } + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const backups = await listNamedBackups(); + expect(backups).toHaveLength(totalBackups); + expect(peakReads).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, + ); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("serializes concurrent restores so only one succeeds when the limit is tight", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-a-account", + refreshToken: "ref-backup-a-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-a"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-b-account", + refreshToken: "ref-backup-b-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("backup-b"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `current-${index}`, + refreshToken: `ref-current-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + + const assessmentA = await assessNamedBackupRestore("backup-a"); + const assessmentB = await assessNamedBackupRestore("backup-b"); + expect(assessmentA.eligibleForRestore).toBe(true); + expect(assessmentB.eligibleForRestore).toBe(true); + + const results = await Promise.allSettled([ + restoreNamedBackup("backup-a"), + restoreNamedBackup("backup-b"), + ]); + const succeeded = results.filter( + (result): result is PromiseFulfilledResult<{ + imported: number; + skipped: number; + total: number; + }> => result.status === "fulfilled", + ); + const failed = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + expect(succeeded).toHaveLength(1); + expect(failed).toHaveLength(1); + expect(String(failed[0]?.reason)).toContain("Import would exceed maximum"); + + const restored = await loadAccounts(); + expect(restored?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + }); }); describe("filename migration (TDD)", () => { From 1d7ed865a1c3482e19837d7d5d14b0fb4300c9c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:41:55 +0800 Subject: [PATCH 03/76] feat(auth): prompt for recovery on startup --- docs/getting-started.md | 2 + docs/reference/commands.md | 2 - docs/reference/public-api.md | 6 - docs/reference/storage-paths.md | 4 - docs/troubleshooting.md | 2 + docs/upgrade.md | 6 + lib/cli.ts | 6 +- lib/codex-manager.ts | 408 +++-- lib/storage.ts | 784 ++++----- lib/ui/copy.ts | 19 - test/cli.test.ts | 10 - test/codex-manager-cli.test.ts | 2511 +++++++++++++------------- test/recovery.test.ts | 2935 +++++++++++++++++++------------ test/storage.test.ts | 1613 +---------------- 14 files changed, 3691 insertions(+), 4617 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 2de9337b..45337911 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,6 +49,8 @@ Expected flow: 4. Return to the terminal when the browser step completes. 5. Confirm the account appears in the saved account list. +If interactive `codex auth login` starts with zero saved accounts and recoverable named backups in your `backups/` directory, the login flow will prompt you to restore before opening OAuth. Confirm to launch the existing restore manager; skip to proceed with a fresh login. The prompt is suppressed in non-interactive/fallback flows and after same-session `fresh` or `reset` actions. + Verify the new account: ```bash diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f7a9cf0a..36c735f9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,7 +26,6 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | -| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -112,7 +111,6 @@ codex auth report --live --json Repair and recovery: ```bash -codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a76eb85a..865189ff 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,12 +37,6 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. -Current additive compatibility note: - -- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. -- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. -- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. - ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 8ea743ec..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,10 +109,6 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. -Direct entrypoint: - -- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. - --- ## oc-chatgpt Target Paths diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0c0bbe86..b31aa95c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,6 +18,8 @@ If the account pool is still not usable: codex auth login ``` +If `codex auth login` starts with no saved accounts and recoverable named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after same-session fresh/reset flows. + --- ## Verify Install And Routing diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d..6f2d453d 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -62,6 +62,12 @@ After source selection, environment variables still override individual setting For day-to-day operator use, prefer stable overrides documented in [configuration.md](configuration.md). For maintainer/debug flows, see advanced/internal controls in [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md). +### Startup Recovery Prompt + +Interactive `codex auth login` now offers named-backup recovery before OAuth only when the session starts with zero saved accounts and at least one recoverable named backup. + +The prompt is intentionally skipped in fallback/non-interactive login paths and after same-session `fresh` or `reset` actions so an intentional wipe does not immediately re-offer restore state. + --- ## Legacy Compatibility diff --git a/lib/cli.ts b/lib/cli.ts index 67c304db..b0a81b35 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -26,6 +26,10 @@ export function isNonInteractiveMode(): boolean { return false; } +export function isInteractiveLoginMenuAvailable(): boolean { + return !isNonInteractiveMode() && isTTY(); +} + export async function promptAddAnotherAccount( currentCount: number, ): Promise { @@ -259,7 +263,7 @@ export async function promptLoginMode( return { mode: "add" }; } - if (!isTTY()) { + if (!isInteractiveLoginMenuAvailable()) { return promptLoginModeFallback(existingAccounts); } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 798aee92..f4a4d5b8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -10,7 +10,12 @@ import { } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { + isInteractiveLoginMenuAvailable, + promptAddAnotherAccount, + promptLoginMode, + type ExistingAccountInfo, +} from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -58,12 +63,11 @@ import { } from "./quota-cache.js"; import { assessNamedBackupRestore, + getActionableNamedBackupRestores, getNamedBackupsDirectoryPath, - isNamedBackupContainmentError, - isNamedBackupPathValidationTransientError, listNamedBackups, - NAMED_BACKUP_ASSESS_CONCURRENCY, - restoreAssessedNamedBackup, + NAMED_BACKUP_LIST_CONCURRENCY, + restoreNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -137,9 +141,7 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (timestamp === null || timestamp === undefined || timestamp === 0) - return null; - if (!Number.isFinite(timestamp)) return null; + if (timestamp === null || timestamp === undefined) return null; const days = Math.floor((Date.now() - timestamp) / 86_400_000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; @@ -331,7 +333,6 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", - " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3957,44 +3958,82 @@ async function handleManageAction( } } +type StartupRecoveryAction = + | "continue-with-oauth" + | "open-empty-storage-menu" + | "show-recovery-prompt"; + +export function resolveStartupRecoveryAction( + recoveryState: Awaited>, + recoveryScanFailed: boolean, +): StartupRecoveryAction { + if (recoveryState.assessments.length > 0) { + return "show-recovery-prompt"; + } + return recoveryScanFailed + ? "continue-with-oauth" + : "open-empty-storage-menu"; +} + async function runAuthLogin(): Promise { setStoragePath(null); + let suppressRecoveryPrompt = false; + let recoveryPromptAttempted = false; + let allowEmptyStorageMenu = false; + let pendingRecoveryState: Awaited< + ReturnType + > | null = null; let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; loginFlow: while (true) { - while (true) { - const existingStorage = await loadAccounts(); - const currentStorage = existingStorage ?? createEmptyAccountStorage(); - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + let existingStorage = await loadAccounts(); + const canOpenEmptyStorageMenu = + allowEmptyStorageMenu && isInteractiveLoginMenuAvailable(); + if ( + (existingStorage && existingStorage.accounts.length > 0) || + canOpenEmptyStorageMenu + ) { + const menuAllowsEmptyStorage = canOpenEmptyStorageMenu; + allowEmptyStorageMenu = false; + pendingRecoveryState = null; + while (true) { + existingStorage = await loadAccounts(); + if (!existingStorage || existingStorage.accounts.length === 0) { + if (!menuAllowsEmptyStorage) { + break; + } + } + const currentStorage = existingStorage ?? createEmptyAccountStorage(); + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); } - } const flaggedStorage = await loadFlaggedAccounts(); const menuResult = await promptLoginMode( @@ -4009,18 +4048,6 @@ async function runAuthLogin(): Promise { console.log("Cancelled."); return 0; } - const modeRequiresDrainedQuotaRefresh = - menuResult.mode === "check" || - menuResult.mode === "deep-check" || - menuResult.mode === "forecast" || - menuResult.mode === "fix" || - menuResult.mode === "restore-backup"; - if (modeRequiresDrainedQuotaRefresh) { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - } if (menuResult.mode === "check") { await runActionPanel("Quick Check", "Checking local session + live status", async () => { await runHealthCheck({ forceRefresh: false, liveProbe: true }); @@ -4057,6 +4084,10 @@ async function runAuthLogin(): Promise { } if (menuResult.mode === "restore-backup") { try { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } await runBackupRestoreManager(displaySettings); } catch (error) { const message = @@ -4090,6 +4121,7 @@ async function runAuthLogin(): Promise { } finally { destructiveActionInFlight = false; } + suppressRecoveryPrompt = true; continue; } if (menuResult.mode === "reset") { @@ -4121,6 +4153,7 @@ async function runAuthLogin(): Promise { } finally { destructiveActionInFlight = false; } + suppressRecoveryPrompt = true; continue; } if (menuResult.mode === "manage") { @@ -4138,9 +4171,91 @@ async function runAuthLogin(): Promise { break; } } + } const refreshedStorage = await loadAccounts(); const existingCount = refreshedStorage?.accounts.length ?? 0; + const canPromptForRecovery = + !suppressRecoveryPrompt && + !recoveryPromptAttempted && + existingCount === 0 && + isInteractiveLoginMenuAvailable(); + if (canPromptForRecovery) { + recoveryPromptAttempted = true; + let recoveryState: Awaited< + ReturnType + > | null = pendingRecoveryState; + pendingRecoveryState = null; + if (recoveryState === null) { + let recoveryScanFailed = false; + let scannedRecoveryState: Awaited< + ReturnType + >; + 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.`, + ); + } + } + } let forceNewLogin = existingCount > 0; while (true) { const tokenResult = await runOAuthFlow(forceNewLogin); @@ -4608,57 +4723,57 @@ export async function autoSyncActiveAccountToCodex(): Promise { type BackupMenuAction = | { type: "restore"; - assessment: Awaited>; + assessment: BackupRestoreAssessment; } | { type: "back" }; -async function runBackupRestoreManager( - displaySettings: DashboardDisplaySettings, -): Promise { - const backupDir = getNamedBackupsDirectoryPath(); - // Reuse only within this list -> assess flow so storage.ts can safely treat - // the cache contents as LoadedBackupCandidate entries. - const candidateCache = new Map(); +type BackupRestoreAssessment = Awaited< + ReturnType +>; + +type BackupRestoreManagerResult = "restored" | "dismissed"; + +function getRedactedFilesystemErrorLabel(error: unknown): string { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string" && code.trim().length > 0) { + return code; + } + if (error instanceof Error && error.name && error.name !== "Error") { + return error.name; + } + return "UNKNOWN"; +} + +async function loadBackupRestoreManagerAssessments(): Promise< + BackupRestoreAssessment[] +> { let backups: Awaited>; try { - backups = await listNamedBackups({ candidateCache }); + backups = await listNamedBackups(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (isNamedBackupContainmentError(error)) { - console.error( - `Backup validation failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - } else if (isNamedBackupPathValidationTransientError(error)) { - console.error(collapseWhitespace(message) || "unknown error"); - } else { - console.error( - `Could not read backup directory: ${ - collapseWhitespace(message) || "unknown error" - }`, - ); - } - return false; + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + return []; } if (backups.length === 0) { - console.log(`No named backups found. Place backup files in ${backupDir}.`); - return true; + return []; } const currentStorage = await loadAccounts(); - const assessments: Awaited>[] = []; - const assessmentFailures: string[] = []; + const assessments: BackupRestoreAssessment[] = []; for ( let index = 0; index < backups.length; - index += NAMED_BACKUP_ASSESS_CONCURRENCY + index += NAMED_BACKUP_LIST_CONCURRENCY ) { - const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); + const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => - assessNamedBackupRestore(backup.name, { - currentStorage, - candidateCache, - }), + assessNamedBackupRestore(backup.name, { currentStorage }), ), ); for (const [resultIndex, result] of settledAssessments.entries()) { @@ -4666,29 +4781,32 @@ async function runBackupRestoreManager( 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" + }`, ); } } + + return assessments; +} + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, + assessmentsOverride?: BackupRestoreAssessment[], +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + const assessments = + assessmentsOverride ?? (await loadBackupRestoreManagerAssessments()); if (assessments.length === 0) { - console.error( - `Could not assess any named backups in ${backupDir}: ${ - assessmentFailures.join("; ") || "all assessments failed" - }`, - ); - return false; + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return "dismissed"; } const items: MenuItem[] = assessments.map((assessment) => { @@ -4737,85 +4855,43 @@ async function runBackupRestoreManager( }); if (!selection || selection.type === "back") { - return true; + return "dismissed"; } - let latestAssessment: Awaited>; + let latestAssessment: BackupRestoreAssessment; try { latestAssessment = await assessNamedBackupRestore( selection.assessment.backup.name, { currentStorage: await loadAccounts() }, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, ); - return false; + return "dismissed"; } if (!latestAssessment.eligibleForRestore) { console.log(latestAssessment.error ?? "Backup is not eligible for restore."); - return false; + return "dismissed"; } - const netNewAccounts = latestAssessment.imported ?? 0; - const confirmMessage = UI_COPY.mainMenu.restoreBackupConfirm( - latestAssessment.backup.name, - netNewAccounts, - latestAssessment.backup.accountCount ?? 0, - latestAssessment.currentAccountCount, - latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount, - ); + const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; const confirmed = await confirm(confirmMessage); - if (!confirmed) return true; + if (!confirmed) return "dismissed"; try { - const result = await restoreAssessedNamedBackup(latestAssessment); - if (!result.changed) { - console.log("All accounts in this backup already exist"); - return true; - } - if (result.imported === 0) { - console.log( - UI_COPY.mainMenu.restoreBackupRefreshSuccess( - latestAssessment.backup.name, - ), - ); - } else { - console.log( - UI_COPY.mainMenu.restoreBackupSuccess( - latestAssessment.backup.name, - result.imported, - result.skipped, - result.total, - ), - ); - } - try { - const synced = await autoSyncActiveAccountToCodex(); - if (!synced) { - console.warn( - "Backup restored, but Codex CLI auth state could not be synced.", - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - `Backup restored, but Codex CLI auth sync failed: ${ - collapseWhitespace(message) || "unknown error" - }`, - ); - } - return true; + const result = await restoreNamedBackup(latestAssessment.backup.name); + console.log( + `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + ); + return "restored"; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const collapsedMessage = collapseWhitespace(message) || "unknown error"; - console.error( - /exceed maximum/i.test(collapsedMessage) - ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` - : `Restore failed: ${collapsedMessage}`, + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, ); - return false; + return "dismissed"; } } @@ -4847,6 +4923,10 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "login") { return runAuthLogin(); } + if (command === "restore-backup") { + await runBackupRestoreManager(startupDisplaySettings); + return 0; + } if (command === "list" || command === "status") { await showAccountStatus(); return 0; @@ -4879,20 +4959,6 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } - if (command === "restore-backup") { - setStoragePath(null); - try { - const completedWithoutFailure = - await runBackupRestoreManager(startupDisplaySettings); - return completedWithoutFailure ? 0 : 1; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - return 1; - } - } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index 60720967..35261661 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,13 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { - existsSync, - lstatSync, - promises as fs, - realpathSync, - type Dirent, -} from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -53,15 +47,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; -// Max total wait across 6 sleeps is about 1.26 s with proportional jitter. -// That's acceptable for transient AV/file-lock recovery, but it also bounds how -// long the interactive restore menu can pause while listing or assessing backups. -const TRANSIENT_FILESYSTEM_MAX_ATTEMPTS = 7; -const TRANSIENT_FILESYSTEM_BASE_DELAY_MS = 10; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; -// Each assessment does more I/O than a listing pass, so keep a lower ceiling to -// reduce transient AV/file-lock pressure on Windows restore menus. -export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -148,75 +134,74 @@ export interface BackupRestoreAssessment { currentAccountCount: number; mergedAccountCount: number | null; imported: number | null; - // Accounts already present in current storage. Metadata-only refreshes can - // still report them here because they are merged rather than newly imported. skipped: number | null; wouldExceedLimit: boolean; eligibleForRestore: boolean; error?: string; } -type LoadedBackupCandidate = { +export interface ActionableNamedBackupRecoveries { + assessments: BackupRestoreAssessment[]; + allAssessments: BackupRestoreAssessment[]; + totalBackups: number; +} + +interface LoadedBackupCandidate { normalized: AccountStorageV3 | null; storedVersion: unknown; schemaErrors: string[]; error?: string; -}; +} -type NamedBackupCandidateCache = Map; +interface NamedBackupScanEntry { + backup: NamedBackupMetadata; + candidate: LoadedBackupCandidate; +} -class BackupContainmentError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "BackupContainmentError"; - } +interface NamedBackupScanResult { + backups: NamedBackupScanEntry[]; + totalBackups: number; } -class BackupPathValidationTransientError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "BackupPathValidationTransientError"; - } +interface NamedBackupMetadataListingResult { + backups: NamedBackupMetadata[]; + totalBackups: number; } -function isLoadedBackupCandidate( - candidate: unknown, -): candidate is LoadedBackupCandidate { - if (!candidate || typeof candidate !== "object") { - return false; - } - const typedCandidate = candidate as { - normalized?: unknown; - storedVersion?: unknown; - schemaErrors?: unknown; - error?: unknown; +function createUnloadedBackupCandidate(): LoadedBackupCandidate { + return { + normalized: null, + storedVersion: null, + schemaErrors: [], }; - const normalized = typedCandidate.normalized; - return ( - "storedVersion" in typedCandidate && - Array.isArray(typedCandidate.schemaErrors) && - (normalized === null || - (typeof normalized === "object" && - normalized !== null && - Array.isArray((normalized as { accounts?: unknown }).accounts))) && - (typedCandidate.error === undefined || - typeof typedCandidate.error === "string") - ); } -function getCachedNamedBackupCandidate( - candidateCache: NamedBackupCandidateCache | undefined, - backupPath: string, -): LoadedBackupCandidate | undefined { - const candidate = candidateCache?.get(backupPath); - if (candidate === undefined) { - return undefined; +function getBackupRestoreAssessmentErrorLabel(error: unknown): string { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string" && code.trim().length > 0) { + return code; } - if (isLoadedBackupCandidate(candidate)) { - return candidate; + if (error instanceof Error && error.name && error.name !== "Error") { + return error.name; } - candidateCache?.delete(backupPath); - return undefined; + return "UNKNOWN"; +} + +function buildFailedBackupRestoreAssessment( + backup: NamedBackupMetadata, + currentStorage: AccountStorageV3 | null, + error: unknown, +): BackupRestoreAssessment { + return { + backup, + currentAccountCount: currentStorage?.accounts.length ?? 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: getBackupRestoreAssessmentErrorLabel(error), + }; } /** @@ -1657,20 +1642,17 @@ export async function getRestoreAssessment(): Promise { }; } -export async function listNamedBackups( - options: { candidateCache?: Map } = {}, -): Promise { +async function scanNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); - const candidateCache = options.candidateCache; try { const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); const backupEntries = entries - .filter((entry) => entry.isFile()) + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) .filter((entry) => entry.name.toLowerCase().endsWith(".json")); - const backups: NamedBackupMetadata[] = []; - let transientValidationError: BackupPathValidationTransientError | undefined; + const backups: NamedBackupScanEntry[] = []; + const totalBackups = backupEntries.length; for ( let index = 0; index < backupEntries.length; @@ -1680,67 +1662,45 @@ export async function listNamedBackups( index, index + NAMED_BACKUP_LIST_CONCURRENCY, ); - const chunkResults = await Promise.allSettled( - chunk.map(async (entry) => { - const path = assertNamedBackupRestorePath( - resolvePath(join(backupRoot, entry.name)), - backupRoot, - ); - const candidate = await loadBackupCandidate(path); - candidateCache?.set(path, candidate); - return buildNamedBackupMetadata( - entry.name.slice(0, -".json".length), - path, - { candidate }, - ); - }), + backups.push( + ...(await Promise.all( + chunk.map(async (entry) => { + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + const candidate = await loadBackupCandidate(path); + const backup = await buildNamedBackupMetadata(name, path, { + candidate, + }); + return { backup, candidate }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to scan named backup", { + name, + path, + error: String(error), + }); + } + return null; + } + }), + )).filter( + (entry): entry is NamedBackupScanEntry => entry !== null, + ), ); - for (const [chunkIndex, result] of chunkResults.entries()) { - if (result.status === "fulfilled") { - backups.push(result.value); - continue; - } - if (isNamedBackupContainmentError(result.reason)) { - throw result.reason; - } - if ( - !transientValidationError && - isNamedBackupPathValidationTransientError(result.reason) - ) { - transientValidationError = result.reason; - } - log.warn("Skipped named backup during listing", { - path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), - error: String(result.reason), - }); - } } - if (backups.length === 0 && transientValidationError) { - throw transientValidationError; - } - return backups.sort((left, right) => { - // Treat epoch (0), null, and non-finite mtimes as "unknown" so the - // sort order matches the restore hints, which also suppress them. - const leftUpdatedAt = left.updatedAt; - const leftTime = - typeof leftUpdatedAt === "number" && - Number.isFinite(leftUpdatedAt) && - leftUpdatedAt !== 0 - ? leftUpdatedAt - : 0; - const rightUpdatedAt = right.updatedAt; - const rightTime = - typeof rightUpdatedAt === "number" && - Number.isFinite(rightUpdatedAt) && - rightUpdatedAt !== 0 - ? rightUpdatedAt - : 0; - return rightTime - leftTime; - }); + return { + backups: backups.sort( + (left, right) => + (right.backup.updatedAt ?? 0) - (left.backup.updatedAt ?? 0), + ), + totalBackups, + }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { - return []; + return { backups: [], totalBackups: 0 }; } log.warn("Failed to list named backups", { path: backupRoot, @@ -1750,47 +1710,188 @@ export async function listNamedBackups( } } +async function listNamedBackupsWithoutLoading(): Promise { + const backupRoot = getNamedBackupRoot(getStoragePath()); + try { + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + const backups: NamedBackupMetadata[] = []; + let totalBackups = 0; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + totalBackups += 1; + + const path = resolvePath(join(backupRoot, entry.name)); + const name = entry.name.slice(0, -".json".length); + try { + backups.push( + await buildNamedBackupMetadata(name, path, { + candidate: createUnloadedBackupCandidate(), + }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to build named backup metadata", { + name, + path, + error: String(error), + }); + } + } + } + + return { + backups: backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)), + totalBackups, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to list named backups", { + path: backupRoot, + error: String(error), + }); + } + return { backups: [], totalBackups: 0 }; + } +} + +export async function listNamedBackups(): Promise { + const scanResult = await scanNamedBackups(); + return scanResult.backups.map((entry) => entry.backup); +} + 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" || code === "EAGAIN"; } async function retryTransientFilesystemOperation( operation: () => Promise, ): Promise { - 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), - ); + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); } - attempt += 1; } + + throw new Error("Retry loop exhausted unexpectedly"); } export function getNamedBackupsDirectoryPath(): string { return getNamedBackupRoot(getStoragePath()); } +export async function getActionableNamedBackupRestores( + options: { + currentStorage?: AccountStorageV3 | null; + backups?: NamedBackupMetadata[]; + assess?: typeof assessNamedBackupRestore; + } = {}, +): Promise { + const usesFastPath = + options.backups === undefined && options.assess === undefined; + const scannedBackupResult = usesFastPath + ? await scanNamedBackups() + : { backups: [], totalBackups: 0 }; + const listedBackupResult = + !usesFastPath && options.backups === undefined + ? await listNamedBackupsWithoutLoading() + : { backups: [], totalBackups: 0 }; + const scannedBackups = scannedBackupResult.backups; + const backups = + options.backups ?? + (usesFastPath + ? scannedBackups.map((entry) => entry.backup) + : listedBackupResult.backups); + const totalBackups = usesFastPath + ? scannedBackupResult.totalBackups + : options.backups?.length ?? listedBackupResult.totalBackups; + if (totalBackups === 0) { + return { assessments: [], allAssessments: [], totalBackups: 0 }; + } + if (usesFastPath && scannedBackups.length === 0) { + return { assessments: [], allAssessments: [], totalBackups }; + } + + const currentStorage = + options.currentStorage === undefined + ? await loadAccounts() + : options.currentStorage; + const actionable: BackupRestoreAssessment[] = []; + const allAssessments: BackupRestoreAssessment[] = []; + const maybePushActionable = (assessment: BackupRestoreAssessment): void => { + if ( + assessment.eligibleForRestore && + !assessment.wouldExceedLimit && + assessment.imported !== null && + assessment.imported > 0 + ) { + actionable.push(assessment); + } + }; + const recordAssessment = (assessment: BackupRestoreAssessment): void => { + allAssessments.push(assessment); + maybePushActionable(assessment); + }; + + if (usesFastPath) { + for (const entry of scannedBackups) { + try { + const assessment = assessNamedBackupRestoreCandidate( + entry.backup, + entry.candidate, + currentStorage, + ); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: entry.backup.name, + path: entry.backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment( + entry.backup, + currentStorage, + error, + ), + ); + } + } + return { assessments: actionable, allAssessments, totalBackups }; + } + + const assess = options.assess ?? assessNamedBackupRestore; + for (const backup of backups) { + try { + const assessment = await assess(backup.name, { currentStorage }); + recordAssessment(assessment); + } catch (error) { + log.warn("Failed to assess named backup restore candidate", { + name: backup.name, + path: backup.path, + error: String(error), + }); + allAssessments.push( + buildFailedBackupRestoreAssessment(backup, currentStorage, error), + ); + } + } + + return { assessments: actionable, allAssessments, totalBackups }; +} + export async function createNamedBackup( name: string, options: { force?: boolean } = {}, @@ -1806,17 +1907,10 @@ export async function createNamedBackup( export async function assessNamedBackupRestore( name: string, - options: { - currentStorage?: AccountStorageV3 | null; - candidateCache?: Map; - } = {}, + options: { currentStorage?: AccountStorageV3 | null } = {}, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); - const candidateCache = options.candidateCache; - const candidate = - getCachedNamedBackupCandidate(candidateCache, backupPath) ?? - (await loadBackupCandidate(backupPath)); - candidateCache?.delete(backupPath); + const candidate = await loadBackupCandidate(backupPath); const backup = await buildNamedBackupMetadata( basename(backupPath).slice(0, -".json".length), backupPath, @@ -1826,10 +1920,15 @@ export async function assessNamedBackupRestore( options.currentStorage !== undefined ? options.currentStorage : await loadAccounts(); + return assessNamedBackupRestoreCandidate(backup, candidate, currentStorage); +} + +function assessNamedBackupRestoreCandidate( + backup: NamedBackupMetadata, + candidate: LoadedBackupCandidate, + currentStorage: AccountStorageV3 | null, +): BackupRestoreAssessment { const currentAccounts = currentStorage?.accounts ?? []; - // Baseline merge math on a deduplicated current snapshot so pre-existing - // duplicate rows in storage cannot produce negative import counts. - const currentDeduplicatedAccounts = deduplicateAccounts([...currentAccounts]); if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { return { @@ -1844,24 +1943,17 @@ export async function assessNamedBackupRestore( }; } - const incomingDeduplicatedAccounts = deduplicateAccounts([ - ...candidate.normalized.accounts, - ]); 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)); return { backup, @@ -1870,34 +1962,18 @@ export async function assessNamedBackupRestore( imported, skipped, wouldExceedLimit, - eligibleForRestore: !wouldExceedLimit && changed, + eligibleForRestore: !wouldExceedLimit, error: wouldExceedLimit ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` - : !changed - ? "All accounts in this backup already exist" - : undefined, + : undefined, }; } export async function restoreNamedBackup( name: string, -): Promise { - const assessment = await assessNamedBackupRestore(name); - return restoreAssessedNamedBackup(assessment); -} - -export async function restoreAssessedNamedBackup( - assessment: Pick, -): Promise { - if (!assessment.eligibleForRestore) { - throw new Error( - assessment.error ?? "Backup is not eligible for restore.", - ); - } - const resolvedPath = await resolveNamedBackupRestorePath( - assessment.backup.name, - ); - return importAccounts(resolvedPath); +): Promise<{ imported: number; total: number; skipped: number }> { + const backupPath = await resolveNamedBackupRestorePath(name); + return importAccounts(backupPath); } function parseAndNormalizeStorage(data: unknown): { @@ -1913,70 +1989,6 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } -export type ImportAccountsResult = { - imported: number; - total: number; - skipped: number; - // Runtime always includes this field; it stays optional in the public type so - // older compatibility callers that only model the legacy shape do not break. - changed?: boolean; -}; - -function normalizeStoragePathForComparison(path: string): string { - const resolved = resolvePath(path); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -function canonicalizeComparisonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => canonicalizeComparisonValue(entry)); - } - if (!value || typeof value !== "object") { - return value; - } - - const record = value as Record; - return Object.fromEntries( - Object.keys(record) - .sort() - .map((key) => [key, canonicalizeComparisonValue(record[key])] as const), - ); -} - -function stableStringifyForComparison(value: unknown): string { - return JSON.stringify(canonicalizeComparisonValue(value)); -} - -function haveEquivalentAccountRows( - left: readonly unknown[], - right: readonly unknown[], -): boolean { - // deduplicateAccounts() keeps the last occurrence of duplicates, so incoming - // rows win when we compare merged restore data against the current snapshot. - // That keeps index-aligned comparison correct for restore no-op detection. - if (left.length !== right.length) { - return false; - } - for (let index = 0; index < left.length; index += 1) { - if ( - stableStringifyForComparison(left[index]) !== - stableStringifyForComparison(right[index]) - ) { - return false; - } - } - return true; -} - -const namedBackupContainmentFs = { - lstat(path: string) { - return lstatSync(path); - }, - realpath(path: string) { - return realpathSync.native(path); - }, -}; - async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -1993,19 +2005,11 @@ async function loadBackupCandidate(path: string): Promise loadAccountsFromPath(path), ); } catch (error) { - const errorMessage = - error instanceof SyntaxError - ? `Invalid JSON in import file: ${path}` - : (error as NodeJS.ErrnoException).code === "ENOENT" - ? `Import file not found: ${path}` - : error instanceof Error - ? error.message - : String(error); return { normalized: null, storedVersion: undefined, schemaErrors: [], - error: errorMessage, + error: String(error), }; } } @@ -2035,12 +2039,28 @@ async function findExistingNamedBackupPath( ? requested : `${requested}.json`; const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - let entries: Dirent[]; try { - entries = await retryTransientFilesystemOperation(() => + const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -2053,153 +2073,25 @@ async function findExistingNamedBackupPath( throw error; } - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - return undefined; } -function resolvePathForNamedBackupContainment(path: string): string { - const resolvedPath = resolvePath(path); - let existingPrefix = resolvedPath; - const unresolvedSegments: string[] = []; - while (true) { - try { - namedBackupContainmentFs.lstat(existingPrefix); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - const parentPath = dirname(existingPrefix); - if (parentPath === existingPrefix) { - return resolvedPath; - } - unresolvedSegments.unshift(basename(existingPrefix)); - existingPrefix = parentPath; - continue; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } - } - try { - const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); - return unresolvedSegments.reduce( - (currentPath, segment) => join(currentPath, segment), - canonicalPrefix, - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return resolvedPath; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } -} - -export function assertNamedBackupRestorePath( - path: string, - backupRoot: string, -): string { - const resolvedPath = resolvePath(path); - const resolvedBackupRoot = resolvePath(backupRoot); - let backupRootIsSymlink = false; - try { - backupRootIsSymlink = - namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - backupRootIsSymlink = false; - } else if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } else { - throw error; - } - } - if (backupRootIsSymlink) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - const canonicalBackupRoot = - resolvePathForNamedBackupContainment(resolvedBackupRoot); - const containedPath = resolvePathForNamedBackupContainment(resolvedPath); - const relativePath = relative(canonicalBackupRoot, containedPath); - const firstSegment = relativePath.split(/[\\/]/)[0]; - if ( - relativePath.length === 0 || - firstSegment === ".." || - isAbsolute(relativePath) - ) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - return containedPath; -} - -export function isNamedBackupContainmentError(error: unknown): boolean { - return ( - error instanceof BackupContainmentError || - (error instanceof Error && /escapes backup directory/i.test(error.message)) - ); -} - -export function isNamedBackupPathValidationTransientError( - error: unknown, -): error is BackupPathValidationTransientError { - return ( - error instanceof BackupPathValidationTransientError || - (error instanceof Error && - /^Backup path validation failed(\.|:|\b)/i.test(error.message)) - ); -} - -export async function resolveNamedBackupRestorePath(name: string): Promise { - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); +async function resolveNamedBackupRestorePath(name: string): Promise { const existingPath = await findExistingNamedBackupPath(name); if (existingPath) { - return assertNamedBackupRestorePath(existingPath, backupRoot); + return existingPath; } + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; - const baseName = requestedWithExtension.slice(0, -".json".length); - let builtPath: string; try { - builtPath = buildNamedBackupPath(requested); + return buildNamedBackupPath(name); } catch (error) { - // buildNamedBackupPath rejects names with special characters even when the - // requested backup name is a plain filename inside the backups directory. - // In that case, reporting ENOENT is clearer than surfacing the filename - // validator, but only when no separator/traversal token is present. + const baseName = requestedWithExtension.toLowerCase().endsWith(".json") + ? requestedWithExtension.slice(0, -".json".length) + : requestedWithExtension; if ( requested.length > 0 && basename(requestedWithExtension) === requestedWithExtension && @@ -2212,7 +2104,6 @@ export async function resolveNamedBackupRestorePath(name: string): Promise> } = {}, + opts: { candidate?: LoadedBackupCandidate } = {}, ): Promise { const candidate = opts.candidate ?? (await loadBackupCandidate(path)); let stats: { @@ -3032,10 +2923,9 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); - const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); + const currentStoragePath = getStoragePath(); const storage = transactionState?.active - ? normalizeStoragePathForComparison(transactionState.storagePath) === - currentStoragePath + ? transactionState.storagePath === currentStoragePath ? transactionState.snapshot : (() => { throw new Error( @@ -3078,7 +2968,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise { +): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); // Check file exists with friendly error @@ -3086,17 +2976,7 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - let content: string; - try { - content = await retryTransientFilesystemOperation(() => - fs.readFile(resolvedPath, "utf-8"), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`Import file not found: ${resolvedPath}`); - } - throw error; - } + const content = await fs.readFile(resolvedPath, "utf-8"); let imported: unknown; try { @@ -3114,49 +2994,23 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, - changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; - // Keep import counts anchored to a deduplicated current snapshot for the - // same reason as assessNamedBackupRestore. - const existingDeduplicatedAccounts = deduplicateAccounts([ - ...existingAccounts, - ]); - const incomingDeduplicatedAccounts = deduplicateAccounts([ - ...normalized.accounts, - ]); const existingActiveIndex = existing?.activeIndex ?? 0; - const merged = [ - ...existingDeduplicatedAccounts, - ...incomingDeduplicatedAccounts, - ]; - const deduplicatedAccounts = deduplicateAccounts(merged); - if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, - ); - } - const imported = - deduplicatedAccounts.length - existingDeduplicatedAccounts.length; - const skipped = Math.max( - 0, - incomingDeduplicatedAccounts.length - imported, - ); - const changed = !haveEquivalentAccountRows( - deduplicatedAccounts, - existingDeduplicatedAccounts, - ); + const merged = [...existingAccounts, ...normalized.accounts]; - if (!changed) { - return { - imported, - total: deduplicatedAccounts.length, - skipped, - changed, - }; + if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + const deduped = deduplicateAccounts(merged); + if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + ); + } } + const deduplicatedAccounts = deduplicateAccounts(merged); + const newStorage: AccountStorageV3 = { version: 3, accounts: deduplicatedAccounts, @@ -3165,12 +3019,10 @@ export async function importAccounts( }; await persist(newStorage); - return { - imported, - total: deduplicatedAccounts.length, - skipped, - changed, - }; + + const imported = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + return { imported, total: deduplicatedAccounts.length, skipped }; }); log.info("Imported accounts", { @@ -3178,17 +3030,7 @@ export async function importAccounts( imported: importedCount, skipped: skippedCount, total, - changed, }); - return { - imported: importedCount, - total, - skipped: skippedCount, - changed, - }; + return { imported: importedCount, total, skipped: skippedCount }; } - -export const __testOnly = { - namedBackupContainmentFs, -}; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 1b14d107..10f123e4 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -16,25 +16,6 @@ export const UI_COPY = { noSearchMatches: "No accounts match your search", recovery: "Recovery", restoreBackup: "Restore From Backup", - restoreBackupConfirm: ( - name: string, - netNewAccounts: number, - backupAccountCount: number, - currentAccountCount: number, - mergedAccountCount: number, - ) => - netNewAccounts === 0 - ? `Restore backup "${name}"? This will refresh stored metadata for matching existing account(s) in this backup.` - : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, - restoreBackupSuccess: ( - name: string, - imported: number, - skipped: number, - total: number, - ) => - `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, - restoreBackupRefreshSuccess: (name: string) => - `Restored backup "${name}". Refreshed stored metadata for matching existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,16 +716,6 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); - - mockRl.question.mockResolvedValueOnce("backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); - - mockRl.question.mockResolvedValueOnce("restore-backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 4f3c8cda..96406cbb 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,28 +1,23 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const MOCK_BACKUP_DIR = fileURLToPath( - new URL("./.vitest-mock-backups", import.meta.url), -); -const mockBackupPath = (name: string): string => - resolve(MOCK_BACKUP_DIR, `${name}.json`); - +const createAuthorizationFlowMock = vi.fn(); +const exchangeAuthorizationCodeMock = vi.fn(); +const startLocalOAuthServerMock = vi.fn(); const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const getActionableNamedBackupRestoresMock = vi.fn(); const listNamedBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); -const resolveNamedBackupRestorePathMock = vi.fn(); -const importAccountsMock = vi.fn(); -const restoreAssessedNamedBackupMock = vi.fn(); +const restoreNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); +const isInteractiveLoginMenuAvailableMock = vi.fn(() => true); const promptLoginModeMock = vi.fn(); const fetchCodexQuotaSnapshotMock = vi.fn(); const loadDashboardDisplaySettingsMock = vi.fn(); @@ -56,8 +51,8 @@ vi.mock("../lib/logger.js", () => ({ })); vi.mock("../lib/auth/auth.js", () => ({ - createAuthorizationFlow: vi.fn(), - exchangeAuthorizationCode: vi.fn(), + createAuthorizationFlow: createAuthorizationFlowMock, + exchangeAuthorizationCode: exchangeAuthorizationCodeMock, parseAuthorizationInput: vi.fn(), REDIRECT_URI: "http://localhost:1455/auth/callback", })); @@ -68,10 +63,11 @@ vi.mock("../lib/auth/browser.js", () => ({ })); vi.mock("../lib/auth/server.js", () => ({ - startLocalOAuthServer: vi.fn(), + startLocalOAuthServer: startLocalOAuthServerMock, })); vi.mock("../lib/cli.js", () => ({ + isInteractiveLoginMenuAvailable: isInteractiveLoginMenuAvailableMock, promptAddAnotherAccount: promptAddAnotherAccountMock, promptLoginMode: promptLoginModeMock, })); @@ -130,12 +126,11 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, listNamedBackups: listNamedBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, - resolveNamedBackupRestorePath: resolveNamedBackupRestorePathMock, - importAccounts: importAccountsMock, - restoreAssessedNamedBackup: restoreAssessedNamedBackupMock, + restoreNamedBackup: restoreNamedBackupMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -295,6 +290,42 @@ function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { return error; } +async function configureSuccessfulOAuthFlow(now = Date.now()): Promise { + const authModule = await import("../lib/auth/auth.js"); + const browserModule = await import("../lib/auth/browser.js"); + const serverModule = await import("../lib/auth/server.js"); + const mockedCreateAuthorizationFlow = vi.mocked( + authModule.createAuthorizationFlow, + ); + const mockedExchangeAuthorizationCode = vi.mocked( + authModule.exchangeAuthorizationCode, + ); + const mockedOpenBrowserUrl = vi.mocked(browserModule.openBrowserUrl); + const mockedStartLocalOAuthServer = vi.mocked( + serverModule.startLocalOAuthServer, + ); + + mockedCreateAuthorizationFlow.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + mockedExchangeAuthorizationCode.mockResolvedValue({ + type: "success", + access: "access-new", + refresh: "refresh-new", + expires: now + 7_200_000, + idToken: "id-token-new", + multiAccount: true, + }); + mockedOpenBrowserUrl.mockReturnValue(true); + mockedStartLocalOAuthServer.mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); +} + type SettingsTestAccount = { email: string; accountId: string; @@ -489,9 +520,13 @@ describe("codex manager cli commands", () => { withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); + createAuthorizationFlowMock.mockReset(); + exchangeAuthorizationCodeMock.mockReset(); + startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); - setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReset(); + isInteractiveLoginMenuAvailableMock.mockReturnValue(true); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); loadDashboardDisplaySettingsMock.mockReset(); @@ -532,15 +567,19 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); - resolveNamedBackupRestorePathMock.mockReset(); - importAccountsMock.mockReset(); - restoreAssessedNamedBackupMock.mockReset(); + restoreNamedBackupMock.mockReset(); confirmMock.mockReset(); + getActionableNamedBackupRestoresMock.mockReset(); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 0, + }); listNamedBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: null, sizeBytes: null, @@ -558,21 +597,12 @@ describe("codex manager cli commands", () => { eligibleForRestore: true, error: undefined, }); - getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); - resolveNamedBackupRestorePathMock.mockImplementation(async (name: string) => - mockBackupPath(name), - ); - importAccountsMock.mockResolvedValue({ + getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); + restoreNamedBackupMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1, - changed: true, }); - restoreAssessedNamedBackupMock.mockImplementation(async (assessment) => - importAccountsMock( - await resolveNamedBackupRestorePathMock(assessment.backup.name), - ), - ); confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( async (handler) => { @@ -633,6 +663,21 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); selectMock.mockResolvedValue(undefined); + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { verifier: "test-verifier" }, + state: "test-state", + url: "https://example.com/oauth", + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + type: "failed", + reason: "unknown", + message: "not configured", + }); + startLocalOAuthServerMock.mockResolvedValue({ + ready: false, + waitForCode: vi.fn(), + close: vi.fn(), + }); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); @@ -667,6 +712,7 @@ describe("codex manager cli commands", () => { }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -687,6 +733,7 @@ describe("codex manager cli commands", () => { it("prints implemented 41-feature matrix", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -705,6 +752,7 @@ describe("codex manager cli commands", () => { it("prints auth help when subcommand is --help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -820,6 +868,47 @@ describe("codex manager cli commands", () => { }); }); + it("restores a named backup from direct auth restore-backup command", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -2694,134 +2783,942 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); }); - it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + it("offers backup recovery before OAuth when actionable backups exist", async () => { + setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue({ + let storageState: { + version: number; + activeIndex: number; + activeIndexByFamily: { codex: number }; + accounts: Array<{ + email?: string; + refreshToken: string; + addedAt: number; + lastUsed: number; + enabled?: boolean; + }>; + } | null = null; + loadAccountsMock.mockImplementation(async () => + storageState ? structuredClone(storageState) : null, + ); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockImplementation(async () => { + storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "refresh-restored", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + return { imported: 1, skipped: 0, total: 1 }; + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + createAuthorizationFlowMock.mockRejectedValue( + new Error("oauth flow should be skipped when restoring backup"), + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + + it("continues into OAuth when startup recovery is declined", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "alpha@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-alpha", - accessToken: "access-alpha", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - { - email: "beta@example.com", - accountId: "shared-workspace", - refreshToken: "refresh-beta", - accessToken: "access-beta", - expiresAt: now + 60 * 60 * 1000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); }); - loadDashboardDisplaySettingsMock.mockResolvedValue({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - menuAutoFetchLimits: true, - menuSortEnabled: false, - menuSortMode: "manual", - menuSortPinCurrent: true, - menuSortQuickSwitchVisibleRow: true, + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const secondAssessment = { + ...assessment, + backup: { + ...assessment.backup, + name: "startup-backup-2", + path: "/mock/backups/startup-backup-2.json", + }, + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment, secondAssessment], + allAssessments: [assessment, secondAssessment], + totalBackups: 2, }); - fetchCodexQuotaSnapshotMock - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }); + confirmMock.mockResolvedValue(false); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: { - "alpha@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }, - "beta@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }, - }, - }); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledWith( + "Found 2 recoverable backups out of 2 total (2 backups) in /mock/backups. Restore now?", + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); - it("keeps login loop running when settings action is selected", async () => { + it("shows the empty storage menu before OAuth when startup recovery finds backups but none are actionable", async () => { + setInteractiveTTY(true); const now = Date.now(); - const storage = { + let storageState = { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [], + allAssessments: [], + totalBackups: 2, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "add" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(confirmMock).not.toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Sign-In Method", + }); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock.mock.invocationCallOrder[0]).toBeLessThan( + createAuthorizationFlowMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("shows all startup-scanned backups in the restore manager before re-prompting", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + const invalidAssessment = { + backup: { + name: "stale-backup", + path: "/mock/backups/stale-backup.json", + createdAt: null, + updatedAt: now - 60_000, + sizeBytes: 64, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "Backup is empty or invalid", + }, + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "Backup is empty or invalid", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment, invalidAssessment], + totalBackups: 2, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + const restoreManagerCall = selectMock.mock.calls.find( + ([, options]) => options?.message === "Restore From Backup", + ); + expect(restoreManagerCall).toBeDefined(); + expect(restoreManagerCall?.[1]).toMatchObject({ + message: "Restore From Backup", + }); + expect(restoreManagerCall?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "startup-backup", + disabled: false, + }), + expect.objectContaining({ + label: "stale-backup", + disabled: true, + }), + ]), + ); + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after backing out of the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after cancelling restore inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(selectMock).toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + + it("re-prompts startup recovery after restore fails inside the backup browser", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(3); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "startup-backup" (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("skips startup restore prompt in fallback login mode", async () => { + setInteractiveTTY(true); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("skips startup restore prompt when login starts non-interactive", async () => { + setInteractiveTTY(false); + isInteractiveLoginMenuAvailableMock.mockReturnValue(false); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + + it("falls back to OAuth when startup recovery scan throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + getActionableNamedBackupRestoresMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery scan failed (EBUSY). Continuing with OAuth.", + ); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery re-assessment throws EBUSY", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 1, + }); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + assessNamedBackupRestoreMock.mockRejectedValueOnce( + makeErrnoError("resource busy", "EBUSY"), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await configureSuccessfulOAuthFlow(now); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "startup-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ accounts: [] }), + }), + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to re-assess backup "startup-backup" before restore (EBUSY).', + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when the startup recovery prompt throws", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + confirmMock.mockRejectedValueOnce( + makeErrnoError( + "no such file or directory, open '/mock/settings.json'", + "ENOENT", + ), + ); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("falls back to OAuth when startup recovery display settings load fails before confirm", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + const assessment = { + backup: { + name: "startup-backup", + path: "/mock/backups/startup-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: "", + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: "", + }; + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + allAssessments: [assessment], + totalBackups: 2, + }); + loadDashboardDisplaySettingsMock + .mockResolvedValueOnce({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }) + .mockImplementationOnce(async () => { + throw makeErrnoError( + "no such file or directory, open '/mock/dashboard-settings.json'", + "ENOENT", + ); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + await configureSuccessfulOAuthFlow(now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(getNamedBackupsDirectoryPathMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Startup recovery prompt failed (ENOENT). Continuing with OAuth.", + ); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it.each([ + { mode: "fresh", action: deleteSavedAccountsMock }, + { mode: "reset", action: resetLocalStateMock }, + ] as const)( + "suppresses startup restore prompt after deliberate $mode action in the same login session", + async ({ mode, action }) => { + setInteractiveTTY(true); + const now = Date.now(); + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: now, + lastUsed: now, + }, + ], + }; + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 2 + ? structuredClone(populatedStorage) + : structuredClone(emptyStorage); + }); + promptLoginModeMock.mockResolvedValueOnce( + mode === "fresh" + ? { mode: "fresh", deleteAll: true } + : { mode: "reset" }, + ); + selectMock.mockResolvedValueOnce("cancel"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(action).toHaveBeenCalledTimes(1); + expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }, + ); + + it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + }, + }); + }); + + it("keeps login loop running when settings action is selected", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, addedAt: now - 1_000, lastUsed: now - 1_000, enabled: true, @@ -3385,271 +4282,14 @@ describe("codex manager cli commands", () => { expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); expect(savePluginConfigMock).toHaveBeenCalledTimes(1); expect(savePluginConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - preemptiveQuotaEnabled: expect.any(Boolean), - preemptiveQuotaRemainingPercent5h: expect.any(Number), - }), - ); - }); - - it("restores a named backup from the login recovery menu", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - }); - - it("restores a named backup from the direct restore-backup command", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(0); - expect(setStoragePathMock).toHaveBeenCalledWith(null); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - }); - - it("returns a non-zero exit code when the direct restore-backup command fails", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - importAccountsMock.mockRejectedValueOnce(new Error("backup locked")); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(1); - expect(promptLoginModeMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith("Restore failed: backup locked"); - } finally { - errorSpy.mockRestore(); - } - }); - - it("returns a non-zero exit code when every direct restore assessment fails", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - listNamedBackupsMock.mockResolvedValue([ - { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]); - assessNamedBackupRestoreMock.mockRejectedValueOnce( - makeErrnoError("backup busy", "EBUSY"), - ); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - - expect(exitCode).toBe(1); - expect(selectMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Could not assess any named backups in"), - ); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("named-backup: backup busy"), - ); - } finally { - errorSpy.mockRestore(); - } + expect.objectContaining({ + preemptiveQuotaEnabled: expect.any(Boolean), + preemptiveQuotaRemainingPercent5h: expect.any(Number), + }), + ); }); - it("rejects a restore when the backup root changes before the final import path check", async () => { + it("restores a named backup from the login recovery menu", async () => { setInteractiveTTY(true); const now = Date.now(); loadAccountsMock.mockResolvedValue({ @@ -3672,7 +4312,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3692,59 +4332,36 @@ describe("codex manager cli commands", () => { }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - resolveNamedBackupRestorePathMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), - ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledOnce(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(errorSpy).toHaveBeenCalledWith( - "Restore failed: Backup path escapes backup directory", - ); - } finally { - errorSpy.mockRestore(); - } + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("offers backup restore from the login menu when no accounts are saved", async () => { setInteractiveTTY(true); const now = Date.now(); - const restoredStorage = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "restored@example.com", - accountId: "acc_restored", - refreshToken: "refresh-restored", - accessToken: "access-restored", - expiresAt: now + 3_600_000, - addedAt: now - 500, - lastUsed: now - 500, - enabled: true, - }, - ], - }; - loadAccountsMock - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue(restoredStorage); + loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3779,17 +4396,7 @@ describe("codex manager cli commands", () => { "named-backup", expect.objectContaining({ currentStorage: null }), ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "acc_restored", - email: "restored@example.com", - refreshToken: "refresh-restored", - accessToken: "access-restored", - }), - ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("does not restore a named backup when confirmation is declined", async () => { @@ -3815,7 +4422,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3855,8 +4462,7 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledOnce(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(importAccountsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); }); it("catches restore failures and returns to the login menu", async () => { @@ -3882,147 +4488,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockRejectedValueOnce( - new Error(`Import file not found: ${mockBackupPath("named-backup")}`), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - const restoreFailureCalls = [ - ...errorSpy.mock.calls, - ...logSpy.mock.calls, - ].flat(); - expect(restoreFailureCalls).toContainEqual( - expect.stringContaining("Restore failed: Import file not found"), - ); - } finally { - errorSpy.mockRestore(); - logSpy.mockRestore(); - } - }); - - it("adds actionable guidance when a confirmed restore exceeds the account limit", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockRejectedValueOnce( - new Error("Import would exceed maximum of 10 accounts (would have 11)"), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(errorSpy).toHaveBeenCalledWith( - "Restore failed: Import would exceed maximum of 10 accounts (would have 11). Close other Codex instances and try again.", - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("treats post-confirm duplicate-only restores as a no-op", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -4037,112 +4503,19 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockResolvedValueOnce({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(logSpy).toHaveBeenCalledWith( - "All accounts in this backup already exist", - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Restored backup "named-backup"'), - ); - } finally { - logSpy.mockRestore(); - } - }); - - it("catches backup listing failures and returns to the login menu", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - makeErrnoError( - "EPERM: operation not permitted, scandir '/mock/backups'", - "EPERM", - ), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Could not read backup directory: EPERM: operation not permitted", - ), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("reports backup validation failures separately from directory read failures", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Backup validation failed: Backup path escapes backup directory", - ), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("surfaces transient backup path validation failures with retry guidance", async () => { - setInteractiveTTY(true); - listNamedBackupsMock.mockRejectedValueOnce( - new Error("Backup path validation failed. Try again."), + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + restoreNamedBackupMock.mockRejectedValueOnce( + new Error("Import file not found: /mock/backups/named-backup.json"), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -4150,43 +4523,30 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - "Backup path validation failed. Try again.", + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to restore backup "named-backup" (UNKNOWN).', + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("/mock/backups/named-backup.json"), ); } finally { - errorSpy.mockRestore(); + warnSpy.mockRestore(); } }); - it("propagates containment errors from batch backup assessment and returns to the login menu", async () => { + it("catches backup listing failures and returns to the login menu", async () => { setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const now = Date.now(); - listNamedBackupsMock.mockResolvedValue([ - { - name: "escaped-backup", - path: mockBackupPath("escaped-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]); - assessNamedBackupRestoreMock.mockRejectedValueOnce( - new Error("Backup path escapes backup directory"), + listNamedBackupsMock.mockRejectedValueOnce( + makeErrnoError( + "EPERM: operation not permitted, scandir '/mock/backups'", + "EPERM", + ), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -4194,19 +4554,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining( - "Restore failed: Backup path escapes backup directory", + "Could not read backup directory: EPERM: operation not permitted", ), ); - expect(warnSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Skipped backup assessment for "escaped-backup"'), - ); } finally { errorSpy.mockRestore(); - warnSpy.mockRestore(); } }); @@ -4217,7 +4574,7 @@ describe("codex manager cli commands", () => { const healthyAssessment = { backup: { name: "healthy-backup", - path: mockBackupPath("healthy-backup"), + path: "/mock/backups/healthy-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -4239,7 +4596,7 @@ describe("codex manager cli commands", () => { { ...healthyAssessment.backup, name: "broken-backup", - path: mockBackupPath("broken-backup"), + path: "/mock/backups/broken-backup.json", }, healthyAssessment.backup, ]); @@ -4265,9 +4622,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("healthy-backup"), - ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("healthy-backup"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( 'Skipped backup assessment for "broken-backup": backup directory busy', @@ -4281,14 +4636,13 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - const { NAMED_BACKUP_ASSESS_CONCURRENCY } = + const { NAMED_BACKUP_LIST_CONCURRENCY } = await vi.importActual( "../lib/storage.js", ); - const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; - const backups = Array.from({ length: totalBackups }, (_value, index) => ({ + const backups = Array.from({ length: 9 }, (_value, index) => ({ name: `named-backup-${index + 1}`, - path: mockBackupPath(`named-backup-${index + 1}`), + path: `/mock/backups/named-backup-${index + 1}.json`, createdAt: null, updatedAt: Date.now() + index, sizeBytes: 128, @@ -4351,7 +4705,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); expect(maxInFlight).toBeLessThanOrEqual( - NAMED_BACKUP_ASSESS_CONCURRENCY, + NAMED_BACKUP_LIST_CONCURRENCY, ); }); @@ -4378,7 +4732,7 @@ describe("codex manager cli commands", () => { const initialAssessment = { backup: { name: "named-backup", - path: mockBackupPath("named-backup"), + path: "/mock/backups/named-backup.json", createdAt: null, updatedAt: now, sizeBytes: 128, @@ -4436,204 +4790,9 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("add 1 new account(s)"), + expect.stringContaining("into 3 current (4 after dedupe)"), ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - }); - - it("uses metadata refresh wording when a restore only updates existing accounts", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - { - email: "same@example.com", - accountId: "acc_same", - refreshToken: "refresh-same", - accessToken: "access-same", - expiresAt: now + 3_600_000, - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - ], - }); - const assessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 2, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 2, - mergedAccountCount: 2, - imported: 0, - skipped: 2, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); - importAccountsMock.mockResolvedValueOnce({ - imported: 0, - skipped: 2, - total: 2, - changed: true, - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment, - }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining( - "refresh stored metadata for matching existing account(s)", - ), - ); - expect(confirmMock).not.toHaveBeenCalledWith( - expect.stringContaining("for 2 existing account(s)"), - ); - expect(importAccountsMock).toHaveBeenCalledWith( - mockBackupPath("named-backup"), - ); - expect(logSpy).toHaveBeenCalledWith( - 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Imported 0, skipped 2"), - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); - } finally { - logSpy.mockRestore(); - } - }); - - it("returns to the login menu when backup reassessment becomes ineligible", async () => { - setInteractiveTTY(true); - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "settings@example.com", - accountId: "acc_settings", - refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const initialAssessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 0, - mergedAccountCount: 1, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - const refreshedAssessment = { - ...initialAssessment, - currentAccountCount: 1, - mergedAccountCount: 1, - imported: 0, - skipped: 1, - eligibleForRestore: false, - error: "All accounts in this backup already exist", - }; - listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); - assessNamedBackupRestoreMock - .mockResolvedValueOnce(initialAssessment) - .mockResolvedValueOnce(refreshedAssessment); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith( - "All accounts in this backup already exist", - ); - } finally { - logSpy.mockRestore(); - } + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); it("returns to the login menu when backup reassessment fails before confirmation", async () => { @@ -4648,91 +4807,20 @@ describe("codex manager cli commands", () => { email: "settings@example.com", accountId: "acc_settings", refreshToken: "refresh-settings", - accessToken: "access-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - const initialAssessment = { - backup: { - name: "named-backup", - path: mockBackupPath("named-backup"), - createdAt: null, - updatedAt: now, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - currentAccountCount: 1, - mergedAccountCount: 2, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }; - listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); - assessNamedBackupRestoreMock - .mockResolvedValueOnce(initialAssessment) - .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), - }), - ); - expect(confirmMock).not.toHaveBeenCalled(); - expect(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Restore failed: backup busy"), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it("shows epoch backup timestamps in restore hints", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const assessment = { + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const initialAssessment = { backup: { - name: "epoch-backup", - path: mockBackupPath("epoch-backup"), + name: "named-backup", + path: "/mock/backups/named-backup.json", createdAt: null, - updatedAt: 0, + updatedAt: now, sizeBytes: 128, version: 3, accountCount: 1, @@ -4740,133 +4828,68 @@ describe("codex manager cli commands", () => { valid: true, loadError: undefined, }, - currentAccountCount: 0, - mergedAccountCount: 1, + currentAccountCount: 1, + mergedAccountCount: 2, imported: 1, skipped: 0, wouldExceedLimit: false, eligibleForRestore: true, error: undefined, }; - listNamedBackupsMock.mockResolvedValue([assessment.backup]); - assessNamedBackupRestoreMock.mockResolvedValue(assessment); + listNamedBackupsMock.mockResolvedValue([initialAssessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(initialAssessment) + .mockRejectedValueOnce(makeErrnoError("backup busy", "EBUSY")); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "back" }); + selectMock.mockResolvedValueOnce({ + type: "restore", + assessment: initialAssessment, + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("1 account"); - expect(backupItems?.[0]?.hint).not.toContain("updated "); - }); - - it("formats recent backup timestamps in restore hints", async () => { - setInteractiveTTY(true); - loadAccountsMock.mockResolvedValue(null); - const now = Date.UTC(2026, 0, 10, 12, 0, 0); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); - const backups = [ - { - name: "today-backup", - path: mockBackupPath("today-backup"), - createdAt: null, - updatedAt: now - 1_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "yesterday-backup", - path: mockBackupPath("yesterday-backup"), - createdAt: null, - updatedAt: now - 1.5 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "three-days-backup", - path: mockBackupPath("three-days-backup"), - createdAt: null, - updatedAt: now - 3 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - { - name: "older-backup", - path: mockBackupPath("older-backup"), - createdAt: null, - updatedAt: now - 8 * 86_400_000, - sizeBytes: 128, - version: 3, - accountCount: 1, - schemaErrors: [], - valid: true, - loadError: undefined, - }, - ]; - const assessmentsByName = new Map( - backups.map((backup) => [ - backup.name, - { - backup, - currentAccountCount: 0, - mergedAccountCount: 1, - imported: 1, - skipped: 0, - wouldExceedLimit: false, - eligibleForRestore: true, - error: undefined, - }, - ]), + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to re-assess backup "named-backup" before restore (EBUSY).', + ), ); - listNamedBackupsMock.mockResolvedValue(backups); - assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { - return assessmentsByName.get(name) ?? assessmentsByName.get(backups[0].name)!; - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "restore-backup" }) - .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ type: "back" }); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("updated today"); - expect(backupItems?.[1]?.hint).toContain("updated yesterday"); - expect(backupItems?.[2]?.hint).toContain("updated 3d ago"); - expect(backupItems?.[3]?.hint).toContain("updated "); - } finally { - nowSpy.mockRestore(); - } }); - it("suppresses invalid backup timestamps in restore hints", async () => { + it("shows epoch backup timestamps in restore hints", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { - name: "nan-backup", - path: mockBackupPath("nan-backup"), + name: "epoch-backup", + path: "/mock/backups/epoch-backup.json", createdAt: null, - updatedAt: Number.NaN, + updatedAt: 0, sizeBytes: 128, version: 3, accountCount: 1, @@ -4894,8 +4917,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("1 account"); - expect(backupItems?.[0]?.hint).not.toContain("updated "); + expect(backupItems?.[0]?.hint).toContain( + `updated ${new Date(0).toLocaleDateString()}`, + ); }); it("shows experimental settings in the settings hub", async () => { @@ -6035,142 +6059,48 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); - it("waits for an in-flight menu quota refresh before starting quick check", async () => { + it("waits for an in-flight menu quota refresh before opening backup restore manager", async () => { const now = Date.now(); - const menuStorage = { - version: 3 as const, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "alpha@example.com", - accountId: "acc-alpha", - accessToken: "access-alpha", - expiresAt: now + 3_600_000, - refreshToken: "refresh-alpha", - addedAt: now, - lastUsed: now, - enabled: true, - }, - { - email: "beta@example.com", - accountId: "acc-beta", - accessToken: "access-beta", - expiresAt: now + 3_600_000, - refreshToken: "refresh-beta", - addedAt: now, - lastUsed: now, - enabled: true, - }, - ], - }; - const quickCheckStorage = { - ...menuStorage, - accounts: [menuStorage.accounts[0]!], - }; - let loadAccountsCalls = 0; - loadAccountsMock.mockImplementation(async () => { - loadAccountsCalls += 1; - return structuredClone( - loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, - ); - }); - loadDashboardDisplaySettingsMock.mockResolvedValue({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - menuAutoFetchLimits: true, - menuShowFetchStatus: true, - menuQuotaTtlMs: 60_000, - menuSortEnabled: true, - menuSortMode: "ready-first", - menuSortPinCurrent: true, - menuSortQuickSwitchVisibleRow: true, - }); - let currentQuotaCache: { - byAccountId: Record; - byEmail: Record; - } = { - byAccountId: {}, - byEmail: {}, - }; - loadQuotaCacheMock.mockImplementation(async () => - structuredClone(currentQuotaCache), - ); - saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { - currentQuotaCache = structuredClone(value); - }); - const firstFetchStarted = createDeferred(); - const secondFetchStarted = createDeferred(); - const releaseFirstFetch = createDeferred(); - const releaseSecondFetch = createDeferred(); - let fetchCallCount = 0; - fetchCodexQuotaSnapshotMock.mockImplementation( - async (input: { accountId: string }) => { - fetchCallCount += 1; - if (fetchCallCount === 1) { - firstFetchStarted.resolve(); - await releaseFirstFetch.promise; - } else if (fetchCallCount === 2) { - secondFetchStarted.resolve(input.accountId); - await releaseSecondFetch.promise; - } - return { - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }; + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, }, - ); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "check" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const runPromise = runCodexMultiAuthCli(["auth", "login"]); - - await firstFetchStarted.promise; - await Promise.resolve(); - - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); - - releaseFirstFetch.resolve(); - - const secondAccountId = await secondFetchStarted.promise; - expect(secondAccountId).toBe("acc-beta"); - - releaseSecondFetch.resolve(); - - const exitCode = await runPromise; - - expect(exitCode).toBe(0); - expect(Object.keys(currentQuotaCache.byEmail)).toEqual( - expect.arrayContaining(["alpha@example.com", "beta@example.com"]), - ); - } finally { - logSpy.mockRestore(); - } - }); + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; - it("waits for an in-flight menu quota refresh before starting backup restore", async () => { - setInteractiveTTY(true); - const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "restore@example.com", - accountId: "acc-restore", - accessToken: "access-restore", + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", expiresAt: now + 3_600_000, - refreshToken: "refresh-restore", + refreshToken: "refresh-first", addedAt: now, lastUsed: now, enabled: true, @@ -6191,60 +6121,45 @@ describe("codex manager cli commands", () => { menuSortPinCurrent: true, menuSortQuickSwitchVisibleRow: true, }); - let currentQuotaCache: { - byAccountId: Record; - byEmail: Record; - } = { + loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, byEmail: {}, - }; - loadQuotaCacheMock.mockImplementation(async () => - structuredClone(currentQuotaCache), - ); - saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { - currentQuotaCache = structuredClone(value); }); - const fetchStarted = createDeferred(); - const releaseFetch = createDeferred(); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); fetchCodexQuotaSnapshotMock.mockImplementation(async () => { fetchStarted.resolve(); - await releaseFetch.promise; - return { - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }; + return fetchDeferred.promise; }); - listNamedBackupsMock.mockResolvedValue([]); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const runPromise = runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); - await fetchStarted.promise; - await Promise.resolve(); + await fetchStarted.promise; + await Promise.resolve(); - expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); - releaseFetch.resolve(); + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); - const exitCode = await runPromise; + const exitCode = await runPromise; - expect(exitCode).toBe(0); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( - listNamedBackupsMock.mock.invocationCallOrder[0] ?? - Number.POSITIVE_INFINITY, - ); - } finally { - logSpy.mockRestore(); - } + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + restoreNamedBackupMock.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY, + ); }); it("skips a second destructive action while reset is already running", async () => { diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 6176d6ac..54f572c8 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -1,1119 +1,1900 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - detectErrorType, - isRecoverableError, - getRecoveryToastContent, - getRecoverySuccessToast, - getRecoveryFailureToast, - createSessionRecoveryHook, + createSessionRecoveryHook, + detectErrorType, + getRecoveryFailureToast, + getRecoverySuccessToast, + getRecoveryToastContent, + isRecoverableError, } from "../lib/recovery"; +afterEach(() => { + vi.restoreAllMocks(); +}); + vi.mock("../lib/recovery/storage.js", () => ({ - readParts: vi.fn(() => []), - findMessagesWithThinkingBlocks: vi.fn(() => []), - findMessagesWithOrphanThinking: vi.fn(() => []), - findMessageByIndexNeedingThinking: vi.fn(() => null), - prependThinkingPart: vi.fn(() => false), - stripThinkingParts: vi.fn(() => false), + readParts: vi.fn(() => []), + findMessagesWithThinkingBlocks: vi.fn(() => []), + findMessagesWithOrphanThinking: vi.fn(() => []), + findMessageByIndexNeedingThinking: vi.fn(() => null), + prependThinkingPart: vi.fn(() => false), + stripThinkingParts: vi.fn(() => false), })); import { - readParts, - findMessagesWithThinkingBlocks, - findMessagesWithOrphanThinking, - findMessageByIndexNeedingThinking, - prependThinkingPart, - stripThinkingParts, + findMessageByIndexNeedingThinking, + findMessagesWithOrphanThinking, + findMessagesWithThinkingBlocks, + prependThinkingPart, + readParts, + stripThinkingParts, } from "../lib/recovery/storage.js"; const mockedReadParts = vi.mocked(readParts); -const mockedFindMessagesWithThinkingBlocks = vi.mocked(findMessagesWithThinkingBlocks); -const mockedFindMessagesWithOrphanThinking = vi.mocked(findMessagesWithOrphanThinking); -const mockedFindMessageByIndexNeedingThinking = vi.mocked(findMessageByIndexNeedingThinking); +const mockedFindMessagesWithThinkingBlocks = vi.mocked( + findMessagesWithThinkingBlocks, +); +const mockedFindMessagesWithOrphanThinking = vi.mocked( + findMessagesWithOrphanThinking, +); +const mockedFindMessageByIndexNeedingThinking = vi.mocked( + findMessageByIndexNeedingThinking, +); const mockedPrependThinkingPart = vi.mocked(prependThinkingPart); const mockedStripThinkingParts = vi.mocked(stripThinkingParts); +async function removeWithRetry(targetPath: string): Promise { + 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)); + } + } +} + function createMockClient() { - return { - session: { - prompt: vi.fn().mockResolvedValue({}), - abort: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - }, - tui: { - showToast: vi.fn().mockResolvedValue({}), - }, - }; + return { + session: { + prompt: vi.fn().mockResolvedValue({}), + abort: vi.fn().mockResolvedValue({}), + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + tui: { + showToast: vi.fn().mockResolvedValue({}), + }, + }; } describe("detectErrorType", () => { - describe("tool_result_missing detection", () => { - it("detects tool_use without tool_result error", () => { - const error = { - type: "invalid_request_error", - message: "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59" - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects tool_use/tool_result mismatch error", () => { - const error = "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("detects error from string message", () => { - const error = "tool_use without matching tool_result"; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - }); - - describe("thinking_block_order detection", () => { - it("detects thinking first block error", () => { - const error = "thinking must be the first block in the message"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking must start with error", () => { - const error = "Response must start with thinking block"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking preceeding error", () => { - const error = "thinking block preceeding tool use is required"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("detects thinking expected/found error", () => { - const error = "Expected thinking block but found text"; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - }); - - describe("thinking_disabled_violation detection", () => { - it("detects thinking disabled error", () => { - const error = "thinking is disabled for this model and cannot contain thinking blocks"; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - }); - - describe("non-recoverable errors", () => { - it("returns null for prompt too long error", () => { - const error = { message: "Prompt is too long" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for context length exceeded error", () => { - const error = "context length exceeded"; - expect(detectErrorType(error)).toBeNull(); - }); - - it("returns null for generic errors", () => { - expect(detectErrorType("Something went wrong")).toBeNull(); - expect(detectErrorType({ message: "Unknown error" })).toBeNull(); - expect(detectErrorType(null)).toBeNull(); - expect(detectErrorType(undefined)).toBeNull(); - }); - - it("returns null for rate limit errors", () => { - const error = { message: "Rate limit exceeded. Retry after 5s" }; - expect(detectErrorType(error)).toBeNull(); - }); - - it("handles error with circular reference gracefully (line 50 coverage)", () => { - const circularError: Record = { name: "CircularError" }; - circularError.self = circularError; - expect(detectErrorType(circularError)).toBeNull(); - }); - }); + describe("tool_result_missing detection", () => { + it("detects tool_use without tool_result error", () => { + const error = { + type: "invalid_request_error", + message: + "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59", + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects tool_use/tool_result mismatch error", () => { + const error = + "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("detects error from string message", () => { + const error = "tool_use without matching tool_result"; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + }); + + describe("thinking_block_order detection", () => { + it("detects thinking first block error", () => { + const error = "thinking must be the first block in the message"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking must start with error", () => { + const error = "Response must start with thinking block"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking preceeding error", () => { + const error = "thinking block preceeding tool use is required"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("detects thinking expected/found error", () => { + const error = "Expected thinking block but found text"; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + }); + + describe("thinking_disabled_violation detection", () => { + it("detects thinking disabled error", () => { + const error = + "thinking is disabled for this model and cannot contain thinking blocks"; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + }); + + describe("non-recoverable errors", () => { + it("returns null for prompt too long error", () => { + const error = { message: "Prompt is too long" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for context length exceeded error", () => { + const error = "context length exceeded"; + expect(detectErrorType(error)).toBeNull(); + }); + + it("returns null for generic errors", () => { + expect(detectErrorType("Something went wrong")).toBeNull(); + expect(detectErrorType({ message: "Unknown error" })).toBeNull(); + expect(detectErrorType(null)).toBeNull(); + expect(detectErrorType(undefined)).toBeNull(); + }); + + it("returns null for rate limit errors", () => { + const error = { message: "Rate limit exceeded. Retry after 5s" }; + expect(detectErrorType(error)).toBeNull(); + }); + + it("handles error with circular reference gracefully (line 50 coverage)", () => { + const circularError: Record = { name: "CircularError" }; + circularError.self = circularError; + expect(detectErrorType(circularError)).toBeNull(); + }); + }); +}); + +describe("getActionableNamedBackupRestores (override)", () => { + it("accepts injected backups and assessor", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "invalid-backup", + path: "/mock/backups/invalid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 0, + schemaErrors: [], + valid: false, + loadError: "invalid", + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess: async (name: string) => { + if (name === "valid-backup") { + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + } + + return { + backup: mockBackups[0], + currentAccountCount: 0, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + eligibleForRestore: false, + error: "invalid", + }; + }, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + }); + it("passes explicit null currentStorage through without reloading accounts", async () => { + const storage = await import("../lib/storage.js"); + const loadAccountsSpy = vi.spyOn(storage, "loadAccounts"); + const mockBackups = [ + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (_name: string, options?: { currentStorage?: unknown }) => ({ + backup: mockBackups[0], + currentAccountCount: options?.currentStorage === null ? 0 : 99, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.assessments).toHaveLength(1); + expect(assess).toHaveBeenCalledWith("valid-backup", { + currentStorage: null, + }); + expect(loadAccountsSpy).not.toHaveBeenCalled(); + loadAccountsSpy.mockRestore(); + }); + + it("keeps actionable backups when another assessment throws", async () => { + const storage = await import("../lib/storage.js"); + const mockBackups = [ + { + name: "broken-backup", + path: "/mock/backups/broken.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "valid-backup", + path: "/mock/backups/valid.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + ]; + const assess = vi.fn(async (name: string) => { + if (name === "broken-backup") { + throw new Error("backup locked"); + } + + return { + backup: mockBackups[1], + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + }); + + const result = await storage.getActionableNamedBackupRestores({ + backups: mockBackups, + assess, + currentStorage: null, + }); + + expect(result.totalBackups).toBe(mockBackups.length); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect(assess).toHaveBeenCalledTimes(2); + }); + +}); + +describe("getActionableNamedBackupRestores (storage-backed paths)", () => { + let testWorkDir: string; + let testStoragePath: string; + + beforeEach(async () => { + testWorkDir = await fs.mkdtemp(join(tmpdir(), "recovery-backups-")); + testStoragePath = join(testWorkDir, "accounts.json"); + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + const storage = await import("../lib/storage.js"); + storage.setStoragePathDirect(null); + await removeWithRetry(testWorkDir); + }); + + it("scans named backups by default and returns actionable restores", 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: "restored@example.com", + refreshToken: "restore-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("startup-fast-path"); + await storage.saveAccounts(emptyStorage); + + const result = await storage.getActionableNamedBackupRestores(); + + expect(result.totalBackups).toBe(1); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "startup-fast-path", + ]); + expect(result.assessments[0]?.imported).toBe(1); + }); + + 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) => { + 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(); + } + }); + + it("keeps actionable backups when fast-path metadata stat 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 backupDir = storage.getNamedBackupsDirectoryPath(); + const lockedBackupPath = join(backupDir, "locked-backup.json"); + const validBackupPath = join(backupDir, "valid-backup.json"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackupPath || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + + try { + const result = await storage.getActionableNamedBackupRestores({ + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + "locked-backup", + ]); + expect(result.assessments.map((item) => item.imported)).toEqual([1, 1]); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackupPath, validBackupPath]), + ); + } finally { + statSpy.mockRestore(); + } + }); + + it("does not pre-read backups when a custom assessor is injected", 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: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + const readFileSpy = vi.spyOn(fs, "readFile"); + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps injected-assessor backups when metadata stat 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: "first@example.com", + refreshToken: "first-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await storage.createNamedBackup("first-backup"); + await storage.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "second@example.com", + refreshToken: "second-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await storage.createNamedBackup("second-backup"); + await storage.saveAccounts(emptyStorage); + + const backups = await storage.listNamedBackups(); + const backupByName = new Map(backups.map((backup) => [backup.name, backup])); + const lockedBackup = backupByName.get("first-backup"); + const secondBackup = backupByName.get("second-backup"); + expect(lockedBackup).toBeDefined(); + expect(secondBackup).toBeDefined(); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation( + (async (...args: Parameters) => { + const [path] = args; + const normalizedPath = + typeof path === "string" ? path.replaceAll("\\", "/") : String(path); + if ( + path === lockedBackup?.path || + normalizedPath.endsWith("/locked-backup.json") + ) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(...args); + }) as typeof fs.stat, + ); + const readFileSpy = vi.spyOn(fs, "readFile"); + const assess = vi.fn(async (name: string) => ({ + backup: backupByName.get(name)!, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + })); + + try { + const result = await storage.getActionableNamedBackupRestores({ + assess, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments).toHaveLength(2); + expect( + assess.mock.calls.map(([name]) => name).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first-backup", "second-backup"]); + expect(readFileSpy).not.toHaveBeenCalled(); + expect(statSpy.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([lockedBackup?.path, secondBackup?.path]), + ); + } finally { + statSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("keeps actionable backups when default assessment 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) => { + 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, + ); + + const result = await storage.getActionableNamedBackupRestores({ + backups, + currentStorage: emptyStorage, + }); + + expect(result.totalBackups).toBe(2); + expect(result.assessments.map((item) => item.backup.name)).toEqual([ + "valid-backup", + ]); + expect( + result.allAssessments + .map((item) => item.backup.name) + .sort((left, right) => left.localeCompare(right)), + ).toEqual(["locked-backup", "valid-backup"]); + expect( + result.allAssessments.find((item) => item.backup.name === "locked-backup"), + ).toMatchObject({ + eligibleForRestore: false, + error: expect.stringContaining("busy"), + }); + const readPaths = readFileSpy.mock.calls.map(([path]) => path); + expect(readPaths).toEqual( + expect.arrayContaining([lockedBackup?.path, validBackup?.path]), + ); + expect(readPaths.filter((path) => path === lockedBackup?.path)).toHaveLength(5); + expect(readPaths.filter((path) => path === validBackup?.path)).toHaveLength(1); + }); + +}); + +describe("resolveStartupRecoveryAction", () => { + it("re-enters the empty storage menu instead of OAuth when backups exist but none are actionable", async () => { + const { resolveStartupRecoveryAction } = await import( + "../lib/codex-manager.js" + ); + + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).toBe("open-empty-storage-menu"); + expect( + resolveStartupRecoveryAction( + { assessments: [], totalBackups: 2 }, + false, + ), + ).not.toBe("continue-with-oauth"); + }); + + 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"); + }); }); describe("isRecoverableError", () => { - it("returns true for tool_result_missing", () => { - const error = "tool_use without tool_result"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_block_order", () => { - const error = "thinking must be the first block"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns true for thinking_disabled_violation", () => { - const error = "thinking is disabled and cannot contain thinking"; - expect(isRecoverableError(error)).toBe(true); - }); - - it("returns false for non-recoverable errors", () => { - expect(isRecoverableError("Prompt is too long")).toBe(false); - expect(isRecoverableError("context length exceeded")).toBe(false); - expect(isRecoverableError("Generic error")).toBe(false); - expect(isRecoverableError(null)).toBe(false); - }); + it("returns true for tool_result_missing", () => { + const error = "tool_use without tool_result"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_block_order", () => { + const error = "thinking must be the first block"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns true for thinking_disabled_violation", () => { + const error = "thinking is disabled and cannot contain thinking"; + expect(isRecoverableError(error)).toBe(true); + }); + + it("returns false for non-recoverable errors", () => { + expect(isRecoverableError("Prompt is too long")).toBe(false); + expect(isRecoverableError("context length exceeded")).toBe(false); + expect(isRecoverableError("Generic error")).toBe(false); + expect(isRecoverableError(null)).toBe(false); + }); }); describe("context error message patterns", () => { - describe("prompt too long patterns", () => { - const promptTooLongPatterns = [ - "Prompt is too long", - "prompt is too long for this model", - "The prompt is too long", - ]; - - it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("context length exceeded patterns", () => { - const contextLengthPatterns = [ - "context length exceeded", - "context_length_exceeded", - "maximum context length", - "exceeds the maximum context window", - ]; - - it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { - expect(isRecoverableError(msg)).toBe(false); - expect(detectErrorType(msg)).toBeNull(); - }); - }); - - describe("tool pairing error patterns", () => { - const toolPairingPatterns = [ - "tool_use ids were found without tool_result blocks immediately after", - "Each tool_use block must have a corresponding tool_result", - "tool_use without matching tool_result", - ]; - - it.each(toolPairingPatterns)("'%s' is detected as tool_result_missing", (msg) => { - expect(detectErrorType(msg)).toBe("tool_result_missing"); - expect(isRecoverableError(msg)).toBe(true); - }); - }); + describe("prompt too long patterns", () => { + const promptTooLongPatterns = [ + "Prompt is too long", + "prompt is too long for this model", + "The prompt is too long", + ]; + + it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("context length exceeded patterns", () => { + const contextLengthPatterns = [ + "context length exceeded", + "context_length_exceeded", + "maximum context length", + "exceeds the maximum context window", + ]; + + it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { + expect(isRecoverableError(msg)).toBe(false); + expect(detectErrorType(msg)).toBeNull(); + }); + }); + + describe("tool pairing error patterns", () => { + const toolPairingPatterns = [ + "tool_use ids were found without tool_result blocks immediately after", + "Each tool_use block must have a corresponding tool_result", + "tool_use without matching tool_result", + ]; + + it.each( + toolPairingPatterns, + )("'%s' is detected as tool_result_missing", (msg) => { + expect(detectErrorType(msg)).toBe("tool_result_missing"); + expect(isRecoverableError(msg)).toBe(true); + }); + }); }); describe("getRecoveryToastContent", () => { - it("returns tool crash recovery for tool_result_missing", () => { - const content = getRecoveryToastContent("tool_result_missing"); - expect(content.title).toBe("Tool Crash Recovery"); - expect(content.message).toBe("Injecting cancelled tool results..."); - }); - - it("returns thinking block recovery for thinking_block_order", () => { - const content = getRecoveryToastContent("thinking_block_order"); - expect(content.title).toBe("Thinking Block Recovery"); - expect(content.message).toBe("Fixing message structure..."); - }); - - it("returns thinking strip recovery for thinking_disabled_violation", () => { - const content = getRecoveryToastContent("thinking_disabled_violation"); - expect(content.title).toBe("Thinking Strip Recovery"); - expect(content.message).toBe("Stripping thinking blocks..."); - }); - - it("returns generic recovery for null error type", () => { - const content = getRecoveryToastContent(null); - expect(content.title).toBe("Session Recovery"); - expect(content.message).toBe("Attempting to recover session..."); - }); + it("returns tool crash recovery for tool_result_missing", () => { + const content = getRecoveryToastContent("tool_result_missing"); + expect(content.title).toBe("Tool Crash Recovery"); + expect(content.message).toBe("Injecting cancelled tool results..."); + }); + + it("returns thinking block recovery for thinking_block_order", () => { + const content = getRecoveryToastContent("thinking_block_order"); + expect(content.title).toBe("Thinking Block Recovery"); + expect(content.message).toBe("Fixing message structure..."); + }); + + it("returns thinking strip recovery for thinking_disabled_violation", () => { + const content = getRecoveryToastContent("thinking_disabled_violation"); + expect(content.title).toBe("Thinking Strip Recovery"); + expect(content.message).toBe("Stripping thinking blocks..."); + }); + + it("returns generic recovery for null error type", () => { + const content = getRecoveryToastContent(null); + expect(content.title).toBe("Session Recovery"); + expect(content.message).toBe("Attempting to recover session..."); + }); }); describe("getRecoverySuccessToast", () => { - it("returns success toast content", () => { - const content = getRecoverySuccessToast(); - expect(content.title).toBe("Session Recovered"); - expect(content.message).toBe("Continuing where you left off..."); - }); + it("returns success toast content", () => { + const content = getRecoverySuccessToast(); + expect(content.title).toBe("Session Recovered"); + expect(content.message).toBe("Continuing where you left off..."); + }); }); describe("getRecoveryFailureToast", () => { - it("returns failure toast content", () => { - const content = getRecoveryFailureToast(); - expect(content.title).toBe("Recovery Failed"); - expect(content.message).toBe("Please retry or start a new session."); - }); + it("returns failure toast content", () => { + const content = getRecoveryFailureToast(); + expect(content.title).toBe("Recovery Failed"); + expect(content.message).toBe("Please retry or start a new session."); + }); }); describe("createSessionRecoveryHook", () => { - it("returns null when sessionRecovery is disabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: false, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).toBeNull(); - }); - - it("returns hook object when sessionRecovery is enabled", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook).not.toBeNull(); - expect(hook?.handleSessionRecovery).toBeTypeOf("function"); - expect(hook?.isRecoverableError).toBeTypeOf("function"); - expect(hook?.setOnAbortCallback).toBeTypeOf("function"); - expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); - }); - - it("hook.isRecoverableError delegates to module function", () => { - const ctx = { client: {} as never, directory: "/test" }; - const config = { sessionRecovery: true, autoResume: false }; - const hook = createSessionRecoveryHook(ctx, config); - expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); - expect(hook?.isRecoverableError("generic error")).toBe(false); - }); + it("returns null when sessionRecovery is disabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: false, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).toBeNull(); + }); + + it("returns hook object when sessionRecovery is enabled", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook).not.toBeNull(); + expect(hook?.handleSessionRecovery).toBeTypeOf("function"); + expect(hook?.isRecoverableError).toBeTypeOf("function"); + expect(hook?.setOnAbortCallback).toBeTypeOf("function"); + expect(hook?.setOnRecoveryCompleteCallback).toBeTypeOf("function"); + }); + + it("hook.isRecoverableError delegates to module function", () => { + const ctx = { client: {} as never, directory: "/test" }; + const config = { sessionRecovery: true, autoResume: false }; + const hook = createSessionRecoveryHook(ctx, config); + expect(hook?.isRecoverableError("tool_use without tool_result")).toBe(true); + expect(hook?.isRecoverableError("generic error")).toBe(false); + }); }); describe("error message extraction edge cases", () => { - it("handles nested error.data.error structure", () => { - const error = { - data: { - error: { - message: "tool_use without tool_result found" - } - } - }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles error.data.message structure", () => { - const error = { - data: { - message: "thinking must be the first block" - } - }; - expect(detectErrorType(error)).toBe("thinking_block_order"); - }); - - it("handles deeply nested error objects", () => { - const error = { - error: { - message: "thinking is disabled and cannot contain thinking blocks" - } - }; - expect(detectErrorType(error)).toBe("thinking_disabled_violation"); - }); - - it("falls back to JSON stringify for non-standard errors", () => { - const error = { custom: "tool_use without tool_result" }; - expect(detectErrorType(error)).toBe("tool_result_missing"); - }); - - it("handles empty object", () => { - expect(detectErrorType({})).toBeNull(); - }); - - it("handles number input", () => { - expect(detectErrorType(42)).toBeNull(); - }); - - it("handles array input", () => { - expect(detectErrorType(["tool_use", "tool_result"])).toBe("tool_result_missing"); - }); + it("handles nested error.data.error structure", () => { + const error = { + data: { + error: { + message: "tool_use without tool_result found", + }, + }, + }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles error.data.message structure", () => { + const error = { + data: { + message: "thinking must be the first block", + }, + }; + expect(detectErrorType(error)).toBe("thinking_block_order"); + }); + + it("handles deeply nested error objects", () => { + const error = { + error: { + message: "thinking is disabled and cannot contain thinking blocks", + }, + }; + expect(detectErrorType(error)).toBe("thinking_disabled_violation"); + }); + + it("falls back to JSON stringify for non-standard errors", () => { + const error = { custom: "tool_use without tool_result" }; + expect(detectErrorType(error)).toBe("tool_result_missing"); + }); + + it("handles empty object", () => { + expect(detectErrorType({})).toBeNull(); + }); + + it("handles number input", () => { + expect(detectErrorType(42)).toBeNull(); + }); + + it("handles array input", () => { + expect(detectErrorType(["tool_use", "tool_result"])).toBe( + "tool_result_missing", + ); + }); }); describe("handleSessionRecovery", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns false when info is null", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery(null as never); - expect(result).toBe(false); - }); - - it("returns false when role is not assistant", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "user", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when no error property", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when error is not recoverable", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "generic error that is not recoverable", - sessionID: "session-1", - } as never); - expect(result).toBe(false); - }); - - it("returns false when sessionID is missing", async () => { - const client = createMockClient(); - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - } as never); - expect(result).toBe(false); - }); - - it("calls onAbortCallback when set", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const abortCallback = vi.fn(); - hook?.setOnAbortCallback(abortCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(abortCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls session.abort on recovery", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.abort).toHaveBeenCalledWith({ path: { id: "session-1" } }); - }); - - it("shows toast notification on recovery attempt", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.tui.showToast).toHaveBeenCalledWith({ - body: { - title: "Tool Crash Recovery", - message: "Injecting cancelled tool results...", - variant: "warning", - }, - }); - }); - - describe("tool_result_missing recovery", () => { - it("injects tool_result parts for tool_use parts in message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "tool-1", name: "read" }, - { type: "tool_use", id: "tool-2", name: "write" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "tool-1", content: "Operation cancelled by user (ESC pressed)" }, - { type: "tool_result", tool_use_id: "tool-2", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("reads parts from storage when parts array is empty", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedReadParts.mockReturnValue([ - { type: "tool", callID: "tool-1", tool: "read" }, - { type: "tool", callID: "tool-2", tool: "write" }, - ] as never); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); - expect(result).toBe(true); - }); - - it("returns false when no tool_use parts found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "text", text: "Hello" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("returns false when prompt injection fails", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.session.prompt.mockRejectedValue(new Error("Prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - }); - - describe("thinking_block_order recovery", () => { - it("uses message index from error to find target message", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.5: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith("session-1", 5); - expect(mockedPrependThinkingPart).toHaveBeenCalledWith("session-1", "msg-target"); - expect(result).toBe(true); - }); - - it("falls back to findMessagesWithOrphanThinking when no index", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue(["orphan-1", "orphan-2"]); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith("session-1"); - expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no orphan messages found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); - mockedFindMessagesWithOrphanThinking.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "build", - model: "gpt-5", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("thinking_disabled_violation recovery", () => { - it("strips thinking blocks from messages", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-with-thinking-1", "msg-with-thinking-2"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith("session-1"); - expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); - expect(result).toBe(true); - }); - - it("returns false when no messages with thinking blocks found", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("resumes session when autoResume is enabled", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "explore", model: "gpt-5.1" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); - mockedStripThinkingParts.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [{ type: "text", text: "[session recovered - continuing previous task]" }], - agent: "explore", - model: "gpt-5.1", - }, - query: { directory: "/test" }, - }); - }); - }); - - describe("callback handling", () => { - it("calls onRecoveryCompleteCallback on success", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - - it("calls onRecoveryCompleteCallback on failure", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const completeCallback = vi.fn(); - hook?.setOnRecoveryCompleteCallback(completeCallback); - - await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(completeCallback).toHaveBeenCalledWith("session-1"); - }); - }); - - describe("deduplication", () => { - it("prevents duplicate processing of same message ID", async () => { - const client = createMockClient(); - - let resolveFirst: () => void; - const firstPromise = new Promise((r) => { resolveFirst = r; }); - - client.session.messages.mockImplementation(async () => { - await firstPromise; - return { - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }; - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const info = { - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never; - - const first = hook?.handleSessionRecovery(info); - const second = hook?.handleSessionRecovery(info); - - resolveFirst!(); - - const [result1, result2] = await Promise.all([first, second]); - - expect(result1).toBe(true); - expect(result2).toBe(false); - }); - }); - - describe("error handling", () => { - it("returns false when failed message not found in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "different-msg", role: "assistant" }, - parts: [], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("finds assistant message ID from session when not provided", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - { info: { id: "msg-assistant", role: "assistant" }, parts: [{ type: "tool_use", id: "tool-1" }] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(true); - }); - - it("returns false when no assistant message found and none in session", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-user", role: "user" }, parts: [] }, - ], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - } as never); - - expect(result).toBe(false); - }); - - it("handles exception in recovery logic gracefully", async () => { - const client = createMockClient(); - client.session.abort.mockResolvedValue({}); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "tool_use", id: "tool-1", name: "read" }], - }], - }); - client.tui.showToast.mockRejectedValue(new Error("Toast error")); - client.session.prompt.mockRejectedValue(new Error("Prompt error")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - }); - - it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [{ - info: { id: "msg-1", role: "assistant" }, - parts: [ - { type: "tool_use", id: "", name: "read" }, - { type: "tool_use", name: "write" }, - { type: "tool_use", id: null, name: "delete" }, - { type: "tool_use", id: "valid-id", name: "exec" }, - ], - }], - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "tool_use without tool_result", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "session-1" }, - body: { - parts: [ - { type: "tool_result", tool_use_id: "valid-id", content: "Operation cancelled by user (ESC pressed)" }, - ], - }, - }); - }); - - it("continues recovery when resumeSession fails (line 226 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" } }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - client.session.prompt.mockRejectedValue(new Error("Resume prompt failed")); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(mockedPrependThinkingPart).toHaveBeenCalled(); - expect(client.session.prompt).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it("handles session with no user messages (line 198 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-0", role: "assistant" }, parts: [] }, - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); - mockedPrependThinkingPart.mockReturnValue(true); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: true } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "messages.1: thinking must be the first block", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(true); - const promptCall = client.session.prompt.mock.calls[0]; - expect(promptCall[0].body.agent).toBeUndefined(); - expect(promptCall[0].body.model).toBeUndefined(); - }); - - it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { - const client = createMockClient(); - client.session.messages.mockResolvedValue({ - data: [ - { info: { id: "msg-1", role: "assistant" }, parts: [] }, - ], - }); - - mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { - throw new Error("Storage access error"); - }); - - const hook = createSessionRecoveryHook( - { client: client as never, directory: "/test" }, - { sessionRecovery: true, autoResume: false } - ); - - const result = await hook?.handleSessionRecovery({ - role: "assistant", - error: "thinking is disabled and cannot contain thinking blocks", - sessionID: "session-1", - id: "msg-1", - } as never); - - expect(result).toBe(false); - mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false when info is null", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery(null as never); + expect(result).toBe(false); + }); + + it("returns false when role is not assistant", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "user", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when no error property", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when error is not recoverable", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "generic error that is not recoverable", + sessionID: "session-1", + } as never); + expect(result).toBe(false); + }); + + it("returns false when sessionID is missing", async () => { + const client = createMockClient(); + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + } as never); + expect(result).toBe(false); + }); + + it("calls onAbortCallback when set", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const abortCallback = vi.fn(); + hook?.setOnAbortCallback(abortCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(abortCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls session.abort on recovery", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.abort).toHaveBeenCalledWith({ + path: { id: "session-1" }, + }); + }); + + it("shows toast notification on recovery attempt", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.tui.showToast).toHaveBeenCalledWith({ + body: { + title: "Tool Crash Recovery", + message: "Injecting cancelled tool results...", + variant: "warning", + }, + }); + }); + + describe("tool_result_missing recovery", () => { + it("injects tool_result parts for tool_use parts in message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "tool-1", name: "read" }, + { type: "tool_use", id: "tool-2", name: "write" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Operation cancelled by user (ESC pressed)", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("reads parts from storage when parts array is empty", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedReadParts.mockReturnValue([ + { type: "tool", callID: "tool-1", tool: "read" }, + { type: "tool", callID: "tool-2", tool: "write" }, + ] as never); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedReadParts).toHaveBeenCalledWith("msg-1"); + expect(result).toBe(true); + }); + + it("returns false when no tool_use parts found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "text", text: "Hello" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("returns false when prompt injection fails", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.session.prompt.mockRejectedValue(new Error("Prompt failed")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + }); + + describe("thinking_block_order recovery", () => { + it("uses message index from error to find target message", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.5: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessageByIndexNeedingThinking).toHaveBeenCalledWith( + "session-1", + 5, + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledWith( + "session-1", + "msg-target", + ); + expect(result).toBe(true); + }); + + it("falls back to findMessagesWithOrphanThinking when no index", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([ + "orphan-1", + "orphan-2", + ]); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithOrphanThinking).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedPrependThinkingPart).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no orphan messages found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue(null); + mockedFindMessagesWithOrphanThinking.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "build", + model: "gpt-5", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("thinking_disabled_violation recovery", () => { + it("strips thinking blocks from messages", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([ + "msg-with-thinking-1", + "msg-with-thinking-2", + ]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedFindMessagesWithThinkingBlocks).toHaveBeenCalledWith( + "session-1", + ); + expect(mockedStripThinkingParts).toHaveBeenCalledTimes(2); + expect(result).toBe(true); + }); + + it("returns false when no messages with thinking blocks found", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("resumes session when autoResume is enabled", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { + id: "msg-0", + role: "user", + agent: "explore", + model: "gpt-5.1", + }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessagesWithThinkingBlocks.mockReturnValue(["msg-1"]); + mockedStripThinkingParts.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "text", + text: "[session recovered - continuing previous task]", + }, + ], + agent: "explore", + model: "gpt-5.1", + }, + query: { directory: "/test" }, + }); + }); + }); + + describe("callback handling", () => { + it("calls onRecoveryCompleteCallback on success", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + + it("calls onRecoveryCompleteCallback on failure", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const completeCallback = vi.fn(); + hook?.setOnRecoveryCompleteCallback(completeCallback); + + await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(completeCallback).toHaveBeenCalledWith("session-1"); + }); + }); + + describe("deduplication", () => { + it("prevents duplicate processing of same message ID", async () => { + const client = createMockClient(); + + let resolveFirst: () => void; + const firstPromise = new Promise((r) => { + resolveFirst = r; + }); + + client.session.messages.mockImplementation(async () => { + await firstPromise; + return { + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }; + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const info = { + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never; + + const first = hook?.handleSessionRecovery(info); + const second = hook?.handleSessionRecovery(info); + + resolveFirst!(); + + const [result1, result2] = await Promise.all([first, second]); + + expect(result1).toBe(true); + expect(result2).toBe(false); + }); + }); + + describe("error handling", () => { + it("returns false when failed message not found in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "different-msg", role: "assistant" }, + parts: [], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("finds assistant message ID from session when not provided", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-user", role: "user" }, parts: [] }, + { + info: { id: "msg-assistant", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1" }], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(true); + }); + + it("returns false when no assistant message found and none in session", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-user", role: "user" }, parts: [] }], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + } as never); + + expect(result).toBe(false); + }); + + it("handles exception in recovery logic gracefully", async () => { + const client = createMockClient(); + client.session.abort.mockResolvedValue({}); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [{ type: "tool_use", id: "tool-1", name: "read" }], + }, + ], + }); + client.tui.showToast.mockRejectedValue(new Error("Toast error")); + client.session.prompt.mockRejectedValue(new Error("Prompt error")); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + }); + + it("filters out tool_use parts with falsy id (line 98 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-1", role: "assistant" }, + parts: [ + { type: "tool_use", id: "", name: "read" }, + { type: "tool_use", name: "write" }, + { type: "tool_use", id: null, name: "delete" }, + { type: "tool_use", id: "valid-id", name: "exec" }, + ], + }, + ], + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "tool_use without tool_result", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "session-1" }, + body: { + parts: [ + { + type: "tool_result", + tool_use_id: "valid-id", + content: "Operation cancelled by user (ESC pressed)", + }, + ], + }, + }); + }); + + it("continues recovery when resumeSession fails (line 226 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { + info: { id: "msg-0", role: "user", agent: "build", model: "gpt-5" }, + }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + client.session.prompt.mockRejectedValue( + new Error("Resume prompt failed"), + ); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(mockedPrependThinkingPart).toHaveBeenCalled(); + expect(client.session.prompt).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("handles session with no user messages (line 198 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [ + { info: { id: "msg-0", role: "assistant" }, parts: [] }, + { info: { id: "msg-1", role: "assistant" }, parts: [] }, + ], + }); + + mockedFindMessageByIndexNeedingThinking.mockReturnValue("msg-target"); + mockedPrependThinkingPart.mockReturnValue(true); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: true }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "messages.1: thinking must be the first block", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(true); + const promptCall = client.session.prompt.mock.calls[0]; + expect(promptCall[0].body.agent).toBeUndefined(); + expect(promptCall[0].body.model).toBeUndefined(); + }); + + it("returns false when thinking_disabled_violation recovery throws (lines 401-402 coverage)", async () => { + const client = createMockClient(); + client.session.messages.mockResolvedValue({ + data: [{ info: { id: "msg-1", role: "assistant" }, parts: [] }], + }); + + mockedFindMessagesWithThinkingBlocks.mockImplementation(() => { + throw new Error("Storage access error"); + }); + + const hook = createSessionRecoveryHook( + { client: client as never, directory: "/test" }, + { sessionRecovery: true, autoResume: false }, + ); + + const result = await hook?.handleSessionRecovery({ + role: "assistant", + error: "thinking is disabled and cannot contain thinking blocks", + sessionID: "session-1", + id: "msg-1", + } as never); + + expect(result).toBe(false); + mockedFindMessagesWithThinkingBlocks.mockReturnValue([]); + }); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7e37dcfa..33bd8c33 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,15 +1,13 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { - __testOnly, assessNamedBackupRestore, - assertNamedBackupRestorePath, buildNamedBackupPath, clearAccounts, clearFlaggedAccounts, @@ -24,14 +22,10 @@ import { NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, - isNamedBackupContainmentError, - isNamedBackupPathValidationTransientError, listNamedBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, - resolveNamedBackupRestorePath, - restoreAssessedNamedBackup, restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, @@ -404,41 +398,6 @@ describe("storage", () => { ).rejects.toThrow(/different storage path/); }); - it("allows exporting inside an active transaction when the storage path only differs by case on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "transactional-export-same-path", - refreshToken: "ref-transactional-export-same-path", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const casedStoragePath = testStoragePath.toUpperCase(); - - try { - await expect( - withAccountStorageTransaction(async () => { - setStoragePathDirect(casedStoragePath); - try { - await exportAccounts(exportPath); - } finally { - setStoragePathDirect(testStoragePath); - } - }), - ).resolves.toBeUndefined(); - expect(existsSync(exportPath)).toBe(true); - } finally { - platformSpy.mockRestore(); - } - }); - it("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -475,243 +434,6 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); - it("should skip persisting duplicate-only imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - const existing = { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }; - await saveAccounts(existing); - await fs.writeFile(exportPath, JSON.stringify(existing)); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should treat deduplicated current snapshots as a no-op import", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should deduplicate incoming backup rows before reporting skipped imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await clearAccounts(); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - lastUsed: 2, - }); - }); - - it("should persist duplicate-only imports when they refresh stored metadata", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "existing", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - - it("should skip semantically identical duplicate-only imports even when key order differs", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -757,12 +479,7 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ - imported: 1, - total: 3, - skipped: 0, - changed: true, - }); + expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -813,12 +530,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -864,12 +576,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1248,78 +955,17 @@ describe("storage", () => { ); }); - it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { - const nearLimitAccounts = Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, - (_, index) => ({ - accountId: `existing-${index}`, - refreshToken: `ref-existing-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: nearLimitAccounts, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-one", - refreshToken: "ref-extra-one", - addedAt: 10_000, - lastUsed: 10_000, - }, - ], - }), - ); - - const first = await importAccounts(exportPath); - expect(first).toMatchObject({ - imported: 1, - skipped: 0, - total: ACCOUNT_LIMITS.MAX_ACCOUNTS, - changed: true, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-two", - refreshToken: "ref-extra-two", - addedAt: 20_000, - lastUsed: 20_000, - }, - ], - }), - ); - - await expect(importAccounts(exportPath)).rejects.toThrow( - /exceed maximum/, - ); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); - expect( - loaded?.accounts.some((account) => account.accountId === "extra-two"), - ).toBe(false); - }); - it("should fail export when no accounts exist", async () => { - const storageModule = await import("../lib/storage.js"); - storageModule.setStoragePathDirect(testStoragePath); - await storageModule.clearAccounts(); - await expect(storageModule.exportAccounts(exportPath)).rejects.toThrow( + const { exportAccounts } = await import("../lib/storage.js"); + const isolatedStorageDir = join( + testWorkDir, + "empty-export-" + Math.random().toString(36).slice(2), + ); + const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); + const isolatedExportPath = join(isolatedStorageDir, "export.json"); + await fs.mkdir(isolatedStorageDir, { recursive: true }); + setStoragePathDirect(isolatedStoragePath); + await expect(exportAccounts(isolatedExportPath)).rejects.toThrow( /No accounts to export/, ); }); @@ -1332,51 +978,6 @@ describe("storage", () => { ); }); - it("retries transient import read errors before parsing the backup", async () => { - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-import-read", - refreshToken: "ref-retry-import-read", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - const originalReadFile = fs.readFile.bind(fs); - let busyFailures = 0; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === exportPath && busyFailures === 0) { - busyFailures += 1; - const error = new Error("import file busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const result = await importAccounts(exportPath); - expect(result).toMatchObject({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(busyFailures).toBe(1); - } finally { - readFileSpy.mockRestore(); - } - }); - it("should fail import when file contains invalid JSON", async () => { const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); @@ -1544,95 +1145,6 @@ describe("storage", () => { ); }); - it("lists named backups across the chunk boundary", async () => { - const expectedNames: string[] = []; - for ( - let index = 0; - index <= NAMED_BACKUP_LIST_CONCURRENCY; - index += 1 - ) { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: `basic-chunk-${index}`, - refreshToken: `ref-basic-chunk-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }, - ], - }); - const name = `basic-chunk-${String(index).padStart(2, "0")}`; - expectedNames.push(name); - await createNamedBackup(name); - } - - const backups = await listNamedBackups(); - - expect(backups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); - expect(backups).toEqual( - expect.arrayContaining( - expectedNames.map((name) => - expect.objectContaining({ - name, - accountCount: 1, - valid: true, - }), - ), - ), - ); - }); - - it("returns a contained fallback path for missing named backups", async () => { - const requestedName = " missing-backup "; - const resolvedPath = - await resolveNamedBackupRestorePath(requestedName); - - expect(resolvedPath).toBe(buildNamedBackupPath("missing-backup")); - await expect(importAccounts(resolvedPath)).rejects.toThrow( - /Import file not found/, - ); - }); - - it("maps read-time ENOENT back to the import file-not-found contract", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "read-race", - refreshToken: "ref-read-race", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const backup = await createNamedBackup("read-race"); - const originalReadFile = fs.readFile.bind(fs); - let injectedEnoent = false; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backup.path && !injectedEnoent) { - injectedEnoent = true; - const error = new Error("backup disappeared") as NodeJS.ErrnoException; - error.code = "ENOENT"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - await expect(importAccounts(backup.path)).rejects.toThrow( - `Import file not found: ${backup.path}`, - ); - } finally { - readFileSpy.mockRestore(); - } - }); - it("assesses eligibility and restores a named backup", async () => { await saveAccounts({ version: 3, @@ -1701,248 +1213,6 @@ describe("storage", () => { expect(assessment.eligibleForRestore).toBe(true); }); - it("deduplicates incoming backup rows when assessing restore counts", async () => { - const backupPath = join( - dirname(testStoragePath), - "backups", - "internal-duplicates.json", - ); - await fs.mkdir(dirname(backupPath), { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-account", - email: "duplicate-account@example.com", - refreshToken: "ref-duplicate-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-account", - email: "duplicate-account@example.com", - refreshToken: "ref-duplicate-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - "utf-8", - ); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore("internal-duplicates"); - - expect(assessment.imported).toBe(1); - expect(assessment.skipped).toBe(0); - expect(assessment.mergedAccountCount).toBe(1); - expect(assessment.eligibleForRestore).toBe(true); - }); - - it("rejects duplicate-only backups with nothing new to restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("already-present"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - refreshToken: "ref-existing-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("already-present"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect(restoreNamedBackup("already-present")).rejects.toThrow( - "All accounts in this backup already exist", - ); - }); - - it("treats deduplicated current snapshots as a no-op restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("repair-current-duplicates"); - - const assessment = await assessNamedBackupRestore( - "repair-current-duplicates", - { - currentStorage: { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }, - }, - ); - expect(assessment.currentAccountCount).toBe(2); - expect(assessment.mergedAccountCount).toBe(1); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect( - restoreNamedBackup("repair-current-duplicates"), - ).rejects.toThrow("All accounts in this backup already exist"); - }); - - it("treats identical accounts in a different backup order as a no-op restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "first-account", - email: "first@example.com", - refreshToken: "ref-first-account", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "second-account", - email: "second@example.com", - refreshToken: "ref-second-account", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await createNamedBackup("reversed-order"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "second-account", - email: "second@example.com", - refreshToken: "ref-second-account", - addedAt: 2, - lastUsed: 2, - }, - { - accountId: "first-account", - email: "first@example.com", - refreshToken: "ref-first-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("reversed-order"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(2); - expect(assessment.eligibleForRestore).toBe(false); - expect(assessment.error).toBe("All accounts in this backup already exist"); - - await expect(restoreNamedBackup("reversed-order")).rejects.toThrow( - "All accounts in this backup already exist", - ); - }); - - it("keeps metadata-only backups eligible for restore", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }); - await createNamedBackup("metadata-refresh"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing-account", - email: "existing@example.com", - refreshToken: "ref-existing-account", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const assessment = await assessNamedBackupRestore("metadata-refresh"); - expect(assessment.imported).toBe(0); - expect(assessment.skipped).toBe(1); - expect(assessment.eligibleForRestore).toBe(true); - expect(assessment.error).toBeUndefined(); - - const restoreResult = await restoreNamedBackup("metadata-refresh"); - expect(restoreResult).toMatchObject({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - - const restored = await loadAccounts(); - expect(restored?.accounts[0]).toMatchObject({ - accountId: "existing-account", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - it("restores manually named backups that already exist inside the backups directory", async () => { const backupPath = join( dirname(testStoragePath), @@ -2051,36 +1321,6 @@ describe("storage", () => { expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); - it("re-resolves an assessed named backup before the final import", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "deleted-helper", - refreshToken: "ref-deleted-helper", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - const backup = await createNamedBackup("deleted-helper-assessment"); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore( - "deleted-helper-assessment", - ); - expect(assessment.eligibleForRestore).toBe(true); - - await removeWithRetry(backup.path, { force: true }); - - await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow( - /Import file not found/, - ); - expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); - }); - it("throws when a named backup becomes invalid JSON after assessment", async () => { await saveAccounts({ version: 3, @@ -2121,44 +1361,36 @@ describe("storage", () => { }, ); - it("allows backup filenames that begin with dots when they stay inside the backups directory", async () => { + it("ignores symlink-like named backup entries that point outside the backups root", async () => { const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "..notes.json"); + const externalBackupPath = join(testWorkDir, "outside-backup.json"); await fs.mkdir(backupRoot, { recursive: true }); await fs.writeFile( - backupPath, + externalBackupPath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [ { - accountId: "leading-dot-backup", - refreshToken: "ref-leading-dot-backup", + accountId: "outside-manual-backup", + refreshToken: "ref-outside-manual-backup", addedAt: 1, - lastUsed: 2, + lastUsed: 1, }, ], }), "utf-8", ); - const assessment = await assessNamedBackupRestore("..notes"); - expect(assessment.eligibleForRestore).toBe(true); - - const result = await restoreNamedBackup("..notes"); - expect(result.imported).toBe(1); - expect((await loadAccounts())?.accounts).toHaveLength(1); - }); - - it("rejects matched backup entries whose resolved path escapes the backups directory", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); const originalReaddir = fs.readdir.bind(fs); const readdirSpy = vi.spyOn(fs, "readdir"); const escapedEntry = { - name: "../escaped-entry.json", + name: "escaped-link.json", isFile: () => true, - isSymbolicLink: () => false, - } as unknown as Awaited>[number]; + isSymbolicLink: () => true, + } as unknown as Awaited< + ReturnType + >[number]; readdirSpy.mockImplementation(async (...args) => { const [path, options] = args; if ( @@ -2172,332 +1404,20 @@ describe("storage", () => { }); try { - await expect(assessNamedBackupRestore("../escaped-entry")).rejects.toThrow( - /escapes backup directory/i, + const backups = await listNamedBackups(); + expect(backups).toEqual([]); + await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( + /not a regular backup file/i, ); - await expect(restoreNamedBackup("../escaped-entry")).rejects.toThrow( - /escapes backup directory/i, + await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( + /not a regular backup file/i, ); } finally { readdirSpy.mockRestore(); } }); - it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const outsideRoot = join(testWorkDir, "outside"); - const linkedRoot = join(backupRoot, "linked"); - const outsideBackupPath = join(outsideRoot, "escape.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.mkdir(outsideRoot, { recursive: true }); - await fs.writeFile( - outsideBackupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "linked-escape", - refreshToken: "ref-linked-escape", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - await fs.symlink( - resolve(outsideRoot), - linkedRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedRoot, "escape.json"), - backupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rejects missing files beneath symlinked backup subdirectories", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const outsideRoot = join(testWorkDir, "outside-missing"); - const linkedRoot = join(backupRoot, "linked-missing"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.mkdir(outsideRoot, { recursive: true }); - await fs.symlink( - resolve(outsideRoot), - linkedRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedRoot, "missing.json"), - backupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rejects symlinked backup roots during restore path validation", async () => { - const canonicalBackupRoot = join(testWorkDir, "canonical-backups"); - const linkedBackupRoot = join(testWorkDir, "linked-backups"); - const backupPath = join(canonicalBackupRoot, "linked-root.json"); - await fs.mkdir(canonicalBackupRoot, { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "linked-root", - refreshToken: "ref-linked-root", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - await fs.symlink( - resolve(canonicalBackupRoot), - linkedBackupRoot, - process.platform === "win32" ? "junction" : "dir", - ); - - expect(() => - assertNamedBackupRestorePath( - join(linkedBackupRoot, "linked-root.json"), - linkedBackupRoot, - ), - ).toThrow(/escapes backup directory/i); - }); - - it("rethrows realpath containment errors for existing backup paths", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "locked-path", - refreshToken: "ref-locked-path", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupPath)) { - const error = new Error( - "backup path locked", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalRealpath(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("classifies transient realpath errors for the backup root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; - - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error( - "backup root busy", - ) as NodeJS.ErrnoException; - error.code = transientCode; - throw error; - } - return originalRealpath(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("classifies transient lstat errors for the backup root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const originalLstat = __testOnly.namedBackupContainmentFs.lstat; - const lstatSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "lstat") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error("backup root locked") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalLstat(path); - }); - - try { - expect(() => - assertNamedBackupRestorePath(backupPath, backupRoot), - ).toThrow("Backup path validation failed. Try again."); - } finally { - lstatSpy.mockRestore(); - } - }); - - it("classifies transient backup path validation errors separately from containment escapes", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const backupPath = join(backupRoot, "pending", "locked.json"); - await fs.mkdir(backupRoot, { recursive: true }); - const transientCode = process.platform === "win32" ? "EPERM" : "EAGAIN"; - const originalRealpath = __testOnly.namedBackupContainmentFs.realpath; - const realpathSpy = vi - .spyOn(__testOnly.namedBackupContainmentFs, "realpath") - .mockImplementation((path) => { - if (String(path) === resolve(backupRoot)) { - const error = new Error( - "backup root locked", - ) as NodeJS.ErrnoException; - error.code = transientCode; - throw error; - } - return originalRealpath(path); - }); - - try { - let thrown: unknown; - try { - assertNamedBackupRestorePath(backupPath, backupRoot); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(Error); - expect(isNamedBackupPathValidationTransientError(thrown)).toBe(true); - expect(isNamedBackupContainmentError(thrown)).toBe(false); - } finally { - realpathSpy.mockRestore(); - } - }); - - it("rejects named backup listings whose resolved paths escape the backups directory", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - const readdirSpy = vi.spyOn(fs, "readdir"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const escapedEntry = { - name: "../escaped-entry.json", - isFile: () => true, - isSymbolicLink: () => false, - } as unknown as Awaited>[number]; - readdirSpy.mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - return [escapedEntry] as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - await expect(listNamedBackups()).rejects.toThrow(/escapes backup directory/i); - expect(readFileSpy).not.toHaveBeenCalled(); - } finally { - readFileSpy.mockRestore(); - readdirSpy.mockRestore(); - } - }); - - it("ignores symlink-like named backup entries that point outside the backups root", async () => { - const backupRoot = join(dirname(testStoragePath), "backups"); - const externalBackupPath = join(testWorkDir, "outside-backup.json"); - await fs.mkdir(backupRoot, { recursive: true }); - await fs.writeFile( - externalBackupPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "outside-manual-backup", - refreshToken: "ref-outside-manual-backup", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - "utf-8", - ); - - const originalReaddir = fs.readdir.bind(fs); - const readdirSpy = vi.spyOn(fs, "readdir"); - const escapedEntry = { - name: "escaped-link.json", - isFile: () => false, - isSymbolicLink: () => true, - } as unknown as Awaited< - ReturnType - >[number]; - readdirSpy.mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - return [escapedEntry] as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual([]); - await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow( - /not a regular backup file/i, - ); - await expect(restoreNamedBackup("escaped-link")).rejects.toThrow( - /not a regular backup file/i, - ); - } finally { - readdirSpy.mockRestore(); - } - }); - - it("rethrows unreadable backup directory errors while listing backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); + it("rethrows unreadable backup directory errors while listing backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; @@ -2505,17 +1425,12 @@ describe("storage", () => { try { await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); - expect(readdirSpy).toHaveBeenCalledTimes(1); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("rethrows unreadable backup directory errors while restoring backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); + it("rethrows unreadable backup directory errors while restoring backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; @@ -2525,37 +1440,12 @@ describe("storage", () => { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ code: "EPERM", }); - expect(readdirSpy).toHaveBeenCalledTimes(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("linux"); - const readdirSpy = vi.spyOn(fs, "readdir"); - const error = new Error("backup directory busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - readdirSpy.mockRejectedValue(error); - - try { - await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ - code: "EAGAIN", - }); - expect(readdirSpy).toHaveBeenCalledTimes(7); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("retries transient EBUSY backup directory errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); + it("retries transient backup directory errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2595,202 +1485,12 @@ describe("storage", () => { ]), ); expect(busyFailures).toBe(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries transient EAGAIN backup directory errors while listing backups", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-list-dir-not-empty", - refreshToken: "ref-retry-list-dir-not-empty", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("retry-list-dir-not-empty"); - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backupRoot && busyFailures === 0) { - busyFailures += 1; - const error = new Error( - "backup directory not empty yet", - ) as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "retry-list-dir-not-empty", - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); } }); - it("retries transient EPERM backup directory errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-list-dir-eperm", - refreshToken: "ref-retry-list-dir-eperm", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await createNamedBackup("retry-list-dir-eperm"); - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backupRoot && busyFailures === 0) { - busyFailures += 1; - const error = new Error( - "backup directory busy", - ) as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - return originalReaddir(...(args as Parameters)); - }); - - try { - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "retry-list-dir-eperm", - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); - } finally { - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); - const backups: Awaited>[] = []; - for ( - let index = 0; - index <= NAMED_BACKUP_LIST_CONCURRENCY; - index += 1 - ) { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: `chunk-boundary-${index}`, - refreshToken: `ref-chunk-boundary-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }, - ], - }); - backups.push( - await createNamedBackup(`chunk-boundary-${String(index).padStart(2, "0")}`), - ); - } - - const backupRoot = join(dirname(testStoragePath), "backups"); - const originalReaddir = fs.readdir.bind(fs); - const originalReadFile = fs.readFile.bind(fs); - const secondChunkBackup = backups.at(-1); - let busyFailures = 0; - const readdirSpy = vi - .spyOn(fs, "readdir") - .mockImplementation(async (...args) => { - const [path, options] = args; - if ( - String(path) === backupRoot && - typeof options === "object" && - options?.withFileTypes === true - ) { - const entries = await originalReaddir( - ...(args as Parameters), - ); - return [...entries].sort((left, right) => - left.name.localeCompare(right.name), - ) as Awaited>; - } - return originalReaddir(...(args as Parameters)); - }); - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === secondChunkBackup?.path && busyFailures === 0) { - busyFailures += 1; - const error = new Error("backup file busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const listedBackups = await listNamedBackups(); - expect(listedBackups).toHaveLength(NAMED_BACKUP_LIST_CONCURRENCY + 1); - expect(listedBackups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: `chunk-boundary-${String( - NAMED_BACKUP_LIST_CONCURRENCY, - ).padStart(2, "0")}`, - valid: true, - }), - ]), - ); - expect(busyFailures).toBe(1); - expect( - readFileSpy.mock.calls.filter( - ([path]) => String(path) === secondChunkBackup?.path, - ), - ).toHaveLength(2); - } finally { - readFileSpy.mockRestore(); - readdirSpy.mockRestore(); - platformSpy.mockRestore(); - } - }); - - it("retries transient EAGAIN backup directory errors while restoring backups", async () => { + it("retries transient backup directory errors while restoring backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2859,21 +1559,15 @@ describe("storage", () => { const assessment = await assessNamedBackupRestore("Manual Backup"); expect(assessment.eligibleForRestore).toBe(true); - const storageBeforeRestore = await loadAccounts(); - expect(storageBeforeRestore?.accounts ?? []).toHaveLength(0); await removeWithRetry(backupPath, { force: true }); await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( /Import file not found/, ); - expect(await loadAccounts()).toEqual(storageBeforeRestore); }); - it("retries transient EBUSY backup read errors while listing backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); + it("retries transient backup read errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2912,249 +1606,52 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { readFileSpy.mockRestore(); - platformSpy.mockRestore(); } }); - it("retries transient backup stat EAGAIN errors while listing backups", async () => { - let statSpy: ReturnType | undefined; - try { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-stat", - refreshToken: "ref-retry-stat", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const backup = await createNamedBackup("retry-stat"); - const originalStat = fs.stat.bind(fs); - let busyFailures = 0; - statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === backup.path && busyFailures === 0) { - busyFailures += 1; - const error = new Error("backup stat busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalStat(...(args as Parameters)); - }); - - const backups = await listNamedBackups(); - expect(backups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "retry-stat", valid: true }), - ]), - ); - expect(busyFailures).toBe(1); - } finally { - statSpy?.mockRestore(); - } - }); - - it("sorts backups with invalid timestamps after finite timestamps", async () => { + it("retries transient backup stat errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "valid-backup", - refreshToken: "ref-valid-backup", + accountId: "retry-stat", + refreshToken: "ref-retry-stat", addedAt: 1, lastUsed: 1, }, ], }); - const validBackup = await createNamedBackup("valid-backup"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "nan-backup", - refreshToken: "ref-nan-backup", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - const nanBackup = await createNamedBackup("nan-backup"); + const backup = await createNamedBackup("retry-stat"); const originalStat = fs.stat.bind(fs); + let busyFailures = 0; const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { const [path] = args; - const stats = await originalStat(...(args as Parameters)); - if (String(path) === nanBackup.path) { - return { - ...stats, - mtimeMs: Number.NaN, - } as Awaited>; + if (String(path) === backup.path && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup stat busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; } - return stats; + return originalStat(...(args as Parameters)); }); try { const backups = await listNamedBackups(); - expect(backups.map((backup) => backup.name)).toEqual([ - validBackup.name, - nanBackup.name, - ]); - } finally { - statSpy.mockRestore(); - } - }); - - it("reuses freshly listed backup candidates for the first restore assessment", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "cached-backup", - refreshToken: "ref-cached-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("cached-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const candidateCache = new Map(); - - try { - await listNamedBackups({ candidateCache }); - await assessNamedBackupRestore("cached-backup", { - currentStorage: null, - candidateCache, - }); - - const firstPassReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(firstPassReads).toHaveLength(1); - - await assessNamedBackupRestore("cached-backup", { currentStorage: null }); - - const secondPassReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(secondPassReads).toHaveLength(2); - } finally { - readFileSpy.mockRestore(); - } - }); - - it("ignores invalid externally provided candidate cache entries", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "external-cache-backup", - refreshToken: "ref-external-cache-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("external-cache-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const candidateCache = new Map([ - [ - backup.path, - { - normalized: { version: 3 }, - storedVersion: 3, - schemaErrors: [], - }, - ], - ]); - - try { - const assessment = await assessNamedBackupRestore( - "external-cache-backup", - { - currentStorage: null, - candidateCache, - }, - ); - expect(assessment).toEqual( - expect.objectContaining({ - eligibleForRestore: true, - backup: expect.objectContaining({ - name: "external-cache-backup", - path: backup.path, - }), - }), - ); - expect( - readFileSpy.mock.calls.filter(([path]) => path === backup.path), - ).toHaveLength(1); - expect(candidateCache.has(backup.path)).toBe(false); - } finally { - readFileSpy.mockRestore(); - } - }); - - it("keeps per-call named-backup caches isolated across concurrent listings", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "isolated-cache-backup", - refreshToken: "ref-isolated-cache-backup", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - const backup = await createNamedBackup("isolated-cache-backup"); - const readFileSpy = vi.spyOn(fs, "readFile"); - const firstCandidateCache = new Map(); - const secondCandidateCache = new Map(); - - try { - await Promise.all([ - listNamedBackups({ candidateCache: firstCandidateCache }), - listNamedBackups({ candidateCache: secondCandidateCache }), - ]); - - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - candidateCache: firstCandidateCache, - }); - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - candidateCache: secondCandidateCache, - }); - - const cachedReads = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, - ); - expect(cachedReads).toHaveLength(2); - - await assessNamedBackupRestore("isolated-cache-backup", { - currentStorage: null, - }); - - const rereadCalls = readFileSpy.mock.calls.filter( - ([path]) => path === backup.path, + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), ); - expect(rereadCalls).toHaveLength(3); + expect(busyFailures).toBe(1); } finally { - readFileSpy.mockRestore(); + statSpy.mockRestore(); } }); it("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; - const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; - for (let index = 0; index < totalBackups; index += 1) { + for (let index = 0; index < 12; index += 1) { await saveAccounts({ version: 3, activeIndex: 0, @@ -3196,7 +1693,7 @@ describe("storage", () => { try { const backups = await listNamedBackups(); - expect(backups).toHaveLength(totalBackups); + expect(backups).toHaveLength(12); expect(peakReads).toBeLessThanOrEqual( NAMED_BACKUP_LIST_CONCURRENCY, ); From bf80247798b5bd093d612a415859e076e40a2422 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 00:12:26 +0800 Subject: [PATCH 04/76] feat(sync): add sync center and status surface --- docs/reference/settings.md | 20 + lib/accounts.ts | 8 +- lib/codex-cli/sync.ts | 816 ++++++++++++- lib/codex-manager/settings-hub.ts | 431 ++++++- lib/live-account-sync.ts | 218 +++- lib/storage.ts | 29 +- lib/ui/copy.ts | 10 + test/accounts-edge.test.ts | 6 + test/accounts-load-from-disk.test.ts | 63 +- test/accounts.test.ts | 163 +++ test/codex-cli-sync.test.ts | 1605 +++++++++++++++++++++++++- test/codex-manager-cli.test.ts | 1154 +++++++++++++++++- test/live-account-sync.test.ts | 537 ++++++++- test/settings-hub-utils.test.ts | 332 ++++++ test/storage.test.ts | 18 - 15 files changed, 5273 insertions(+), 137 deletions(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index dfc9e8ee..aaede2c3 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -64,6 +64,26 @@ Controls display style: - `uiAccentColor` - `menuFocusStyle` +## Sync Center + +The settings hub includes a preview-first sync center for Codex CLI account sync. +See [upgrade notes](../upgrade.md) for sync workflow changes. + +Before applying sync, it shows: + +- target path +- current source path when available +- last sync result for this session +- preview summary (adds, updates, destination-only preserved accounts) +- destination-only preservation behavior +- backup and rollback context (`.bak`, `.bak.1`, `.bak.2`, `.wal`) + +Validation: + +- `npm run typecheck` +- `npm run build` +- `npm test` + --- ## Experimental diff --git a/lib/accounts.ts b/lib/accounts.ts index f870b6b3..15faad86 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -22,7 +22,11 @@ import { loadCodexCliState, type CodexCliTokenCacheEntry, } from "./codex-cli/state.js"; -import { syncAccountStorageFromCodexCli } from "./codex-cli/sync.js"; +import { + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, + syncAccountStorageFromCodexCli, +} from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; export { @@ -116,7 +120,9 @@ export class AccountManager { if (synced.changed && sourceOfTruthStorage) { try { await saveAccounts(sourceOfTruthStorage); + commitPendingCodexCliSyncRun(synced.pendingRun); } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); log.debug("Failed to persist Codex CLI source-of-truth sync", { error: String(error), }); diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index ba0257de..a5eff517 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,9 +1,39 @@ +import { promises as fs } from "node:fs"; import { createLogger } from "../logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; -import { type AccountStorageV3 } from "../storage.js"; -import { incrementCodexCliMetric } from "./observability.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + findMatchingAccountIndex, + getLastAccountsSaveTimestamp, + getStoragePath, + normalizeEmailKey, +} from "../storage.js"; +import { + incrementCodexCliMetric, + makeAccountFingerprint, +} from "./observability.js"; +import { sleep } from "../utils.js"; +import { + type CodexCliAccountSnapshot, + type CodexCliState, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "./state.js"; +import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); +const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); +export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; + +function createEmptyStorage(): AccountStorageV3 { + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; +} function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { return { @@ -16,6 +46,95 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } +export function formatRollbackPaths(targetPath: string): string[] { + return [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ]; +} + +export interface CodexCliSyncSummary { + sourceAccountCount: number; + targetAccountCountBefore: number; + targetAccountCountAfter: number; + addedAccountCount: number; + updatedAccountCount: number; + unchangedAccountCount: number; + destinationOnlyPreservedCount: number; + selectionChanged: boolean; +} + +export interface CodexCliSyncBackupContext { + enabled: boolean; + targetPath: string; + rollbackPaths: string[]; +} + +export interface CodexCliSyncPreview { + status: "ready" | "noop" | "disabled" | "unavailable" | "error"; + statusDetail: string; + sourcePath: string | null; + sourceAccountCount: number | null; + targetPath: string; + summary: CodexCliSyncSummary; + backup: CodexCliSyncBackupContext; + lastSync: CodexCliSyncRun | null; +} + +export interface CodexCliSyncRun { + outcome: "changed" | "noop" | "disabled" | "unavailable" | "error"; + runAt: number; + sourcePath: string | null; + targetPath: string; + summary: CodexCliSyncSummary; + message?: string; +} + +export interface PendingCodexCliSyncRun { + revision: number; + run: CodexCliSyncRun; +} + +type UpsertAction = "skipped" | "added" | "updated" | "unchanged"; + +interface UpsertResult { + action: UpsertAction; + matchedIndex?: number; +} + +interface ReconcileResult { + next: AccountStorageV3; + changed: boolean; + summary: CodexCliSyncSummary; +} + +let lastCodexCliSyncRun: CodexCliSyncRun | null = null; +let lastCodexCliSyncRunRevision = 0; +let nextCodexCliSyncRunRevision = 0; +const activePendingCodexCliSyncRunRevisions = new Set(); + +function createEmptySyncSummary(): CodexCliSyncSummary { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; +} + +function cloneCodexCliSyncRun(run: CodexCliSyncRun): CodexCliSyncRun { + return { + ...run, + summary: { ...run.summary }, + }; +} + function normalizeIndexCandidate(value: number, fallback: number): number { if (!Number.isFinite(value)) { return Number.isFinite(fallback) ? Math.trunc(fallback) : 0; @@ -23,6 +142,287 @@ function normalizeIndexCandidate(value: number, fallback: number): number { return Math.trunc(value); } +function allocateCodexCliSyncRunRevision(): number { + nextCodexCliSyncRunRevision += 1; + return nextCodexCliSyncRunRevision; +} + +function allocatePendingCodexCliSyncRunRevision(): number { + const revision = allocateCodexCliSyncRunRevision(); + activePendingCodexCliSyncRunRevisions.add(revision); + return revision; +} + +function markPendingCodexCliSyncRunCompleted(revision: number): boolean { + return activePendingCodexCliSyncRunRevisions.delete(revision); +} + +function publishCodexCliSyncRun( + run: CodexCliSyncRun, + revision: number, +): boolean { + if (revision <= lastCodexCliSyncRunRevision) { + return false; + } + lastCodexCliSyncRunRevision = revision; + lastCodexCliSyncRun = cloneCodexCliSyncRun(run); + return true; +} + +function buildSyncRunError( + run: CodexCliSyncRun, + error: unknown, +): CodexCliSyncRun { + return { + ...run, + outcome: "error", + message: error instanceof Error ? error.message : String(error), + }; +} + +function createSyncRun( + run: Omit, +): CodexCliSyncRun { + return { + ...run, + runAt: Date.now(), + }; +} + +function hasSourceStateOverride(options: { + sourceState?: CodexCliState | null; +}): boolean { + return Object.prototype.hasOwnProperty.call(options, "sourceState"); +} + +async function resolveCodexCliSyncState(options: { + forceRefresh?: boolean; + sourceState?: CodexCliState | null; +}): Promise { + if (hasSourceStateOverride(options)) { + return options.sourceState ?? null; + } + return loadCodexCliState({ + forceRefresh: options.forceRefresh, + }); +} + +export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { + return lastCodexCliSyncRun ? cloneCodexCliSyncRun(lastCodexCliSyncRun) : null; +} + +export function commitPendingCodexCliSyncRun( + pendingRun: PendingCodexCliSyncRun | null | undefined, +): void { + if (!pendingRun) return; + if (!markPendingCodexCliSyncRunCompleted(pendingRun.revision)) { + return; + } + publishCodexCliSyncRun( + { + ...pendingRun.run, + runAt: Date.now(), + }, + allocateCodexCliSyncRunRevision(), + ); +} + +export function commitCodexCliSyncRunFailure( + pendingRun: PendingCodexCliSyncRun | null | undefined, + error: unknown, +): void { + if (!pendingRun) return; + if (!markPendingCodexCliSyncRunCompleted(pendingRun.revision)) { + return; + } + publishCodexCliSyncRun( + buildSyncRunError( + { + ...pendingRun.run, + runAt: Date.now(), + }, + error, + ), + allocateCodexCliSyncRunRevision(), + ); +} + +export function __resetLastCodexCliSyncRunForTests(): void { + lastCodexCliSyncRun = null; + lastCodexCliSyncRunRevision = 0; + nextCodexCliSyncRunRevision = 0; + activePendingCodexCliSyncRunRevisions.clear(); +} + +function hasConflictingIdentity( + accounts: AccountMetadataV3[], + snapshot: CodexCliAccountSnapshot, +): boolean { + const normalizedEmail = normalizeEmailKey(snapshot.email); + for (const account of accounts) { + if (!account) continue; + if (snapshot.accountId && account.accountId === snapshot.accountId) { + return true; + } + if (snapshot.refreshToken && account.refreshToken === snapshot.refreshToken) { + return true; + } + if (normalizedEmail && normalizeEmailKey(account.email) === normalizedEmail) { + return true; + } + } + return false; +} + +function toStorageAccount( + snapshot: CodexCliAccountSnapshot, +): AccountMetadataV3 | null { + if (!snapshot.refreshToken) return null; + const now = Date.now(); + return { + accountId: snapshot.accountId, + accountIdSource: snapshot.accountId ? "token" : undefined, + email: snapshot.email, + refreshToken: snapshot.refreshToken, + accessToken: snapshot.accessToken, + expiresAt: snapshot.expiresAt, + enabled: true, + addedAt: now, + lastUsed: 0, + }; +} + +function upsertFromSnapshot( + accounts: AccountMetadataV3[], + snapshot: CodexCliAccountSnapshot, +): UpsertResult { + const nextAccount = toStorageAccount(snapshot); + if (!nextAccount) return { action: "skipped" }; + + const targetIndex = findMatchingAccountIndex(accounts, snapshot, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + + if (targetIndex === undefined) { + if (hasConflictingIdentity(accounts, snapshot)) { + return { action: "skipped" }; + } + accounts.push(nextAccount); + return { action: "added" }; + } + + const current = accounts[targetIndex]; + if (!current) return { action: "skipped" }; + + const merged: AccountMetadataV3 = { + ...current, + accountId: snapshot.accountId ?? current.accountId, + accountIdSource: snapshot.accountId + ? (current.accountIdSource ?? "token") + : current.accountIdSource, + email: snapshot.email ?? current.email, + refreshToken: snapshot.refreshToken ?? current.refreshToken, + accessToken: snapshot.accessToken ?? current.accessToken, + expiresAt: snapshot.expiresAt ?? current.expiresAt, + }; + + const changed = JSON.stringify(current) !== JSON.stringify(merged); + if (changed) { + accounts[targetIndex] = merged; + } + return { + action: changed ? "updated" : "unchanged", + matchedIndex: targetIndex, + }; +} + +function resolveActiveIndex( + accounts: AccountMetadataV3[], + activeAccountId: string | undefined, + activeEmail: string | undefined, +): number | undefined { + if (accounts.length === 0) return undefined; + if (!activeAccountId && !normalizeEmailKey(activeEmail)) return undefined; + return findMatchingAccountIndex( + accounts, + { + accountId: activeAccountId, + email: activeEmail, + refreshToken: undefined, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); +} + +function applyCodexCliSelection( + storage: AccountStorageV3, + index: number, +): void { + const previousActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const raw = storage.activeIndexByFamily[family]; + if ( + typeof raw === "number" && + normalizeIndexCandidate(raw, previousActiveIndex) === previousActiveIndex + ) { + storage.activeIndexByFamily[family] = index; + } + } +} + +async function getPersistedLocalSelectionTimestamp(): Promise { + let lastError: unknown; + for ( + let attempt = 0; + attempt < SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + const stats = await fs.stat(getStoragePath()); + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0; + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return 0; + } + if ( + typeof code !== "string" || + !RETRYABLE_SELECTION_TIMESTAMP_CODES.has(code) + ) { + return null; + } + if (attempt >= SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS - 1) { + log.debug("Exhausted retries reading persisted local selection timestamp", { + error: lastError instanceof Error ? lastError.message : String(lastError), + }); + return null; + } + await sleep(10 * 2 ** attempt); + } + } + return null; +} + +/** + * Normalize and clamp the global and per-family active account indexes to valid ranges. + * + * Mutates `storage` in-place: ensures `activeIndexByFamily` exists, clamps `activeIndex` to + * 0..(accounts.length - 1) (or 0 when there are no accounts), and resolves each family entry + * to a valid index within the same bounds. + * + * Concurrency: callers must synchronize externally when multiple threads/processes may write + * the same storage object. Filesystem notes: no platform-specific IO is performed here; when + * persisted to disk on Windows consumers should still ensure atomic writes. Token handling: + * this function does not read or modify authentication tokens and makes no attempt to redact + * sensitive fields. + * + * @param storage - The account storage object whose indexes will be normalized and clamped + */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { const count = storage.accounts.length; const normalizedActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); @@ -52,20 +452,398 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { } /** - * Preserves one-way mirror semantics for Codex CLI compatibility state. + * Return the `accountId` and `email` from the first snapshot marked active. + * + * @param snapshots - Array of Codex CLI account snapshots to search + * @returns The `accountId` and `email` from the first snapshot whose `isActive` is true; properties are omitted if no active snapshot is found + * + * Concurrency: pure and side-effect free; safe to call concurrently. + * Filesystem: behavior is independent of OS/filesystem semantics (including Windows). + * Security: only `accountId` and `email` are returned; other sensitive snapshot fields (for example tokens) are not exposed or returned by this function. + */ +function readActiveFromSnapshots(snapshots: CodexCliAccountSnapshot[]): { + accountId?: string; + email?: string; +} { + const active = snapshots.find((snapshot) => snapshot.isActive); + return { + accountId: active?.accountId, + email: active?.email, + }; +} + +/** + * Determines whether the Codex CLI's active-account selection should override the local selection. * - * Multi-auth storage is the canonical source of truth. Codex CLI account files are mirrors only - * and must never seed, merge into, or restore the canonical account pool. This helper is kept for - * older call sites that still use the historical reconcile entry point, but it now only normalizes - * the existing local indexes and never reads or applies Codex CLI account data. + * Considers the state's numeric `syncVersion` or `sourceUpdatedAtMs` and compares the derived Codex timestamp + * against local timestamps from recent account saves and last Codex selection writes. Concurrent writes or + * clock skew can affect this decision; filesystem timestamp granularity on Windows may reduce timestamp precision. + * This function only examines timestamps and identifiers in `state` and does not read or expose token values. * - * @param current - The current canonical AccountStorageV3, or null when no canonical storage exists. - * @returns The original storage when no local normalization is needed, a normalized clone when index - * values need clamping, or null when canonical storage is missing. + * @param state - Persisted Codex CLI state (may be undefined); the function reads `syncVersion` and `sourceUpdatedAtMs` when present + * @returns `true` if the Codex CLI selection should be applied (i.e., Codex state is newer or timestamps are unknown), `false` otherwise */ +function shouldApplyCodexCliSelection( + state: Awaited>, + persistedLocalTimestamp: number | null = 0, +): boolean { + if (!state) return false; + const hasSyncVersion = + typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion); + const codexVersion = hasSyncVersion + ? (state.syncVersion as number) + : typeof state.sourceUpdatedAtMs === "number" && + Number.isFinite(state.sourceUpdatedAtMs) + ? state.sourceUpdatedAtMs + : 0; + const inProcessLocalVersion = Math.max( + getLastAccountsSaveTimestamp(), + getLastCodexCliSelectionWriteTimestamp(), + ); + if (persistedLocalTimestamp === null && inProcessLocalVersion <= 0) { + return false; + } + const localVersion = Math.max( + inProcessLocalVersion, + persistedLocalTimestamp ?? 0, + ); + if (codexVersion <= 0) return localVersion <= 0; + if (localVersion <= 0) { + return persistedLocalTimestamp !== null; + } + // When only source mtime is available, require Codex to be at least as new as the + // local selection. A grace window here can overwrite a newer persisted local choice. + return codexVersion >= localVersion; +} + +function reconcileCodexCliState( + current: AccountStorageV3 | null, + state: NonNullable>>, + options: { persistedLocalTimestamp?: number | null } = {}, +): ReconcileResult { + const next = current ? cloneStorage(current) : createEmptyStorage(); + const targetAccountCountBefore = next.accounts.length; + const matchedExistingIndexes = new Set(); + const summary = createEmptySyncSummary(); + summary.sourceAccountCount = state.accounts.length; + summary.targetAccountCountBefore = targetAccountCountBefore; + + let changed = false; + for (const snapshot of state.accounts) { + const result = upsertFromSnapshot(next.accounts, snapshot); + if (result.action === "skipped") continue; + if ( + typeof result.matchedIndex === "number" && + result.matchedIndex >= 0 && + result.matchedIndex < targetAccountCountBefore + ) { + matchedExistingIndexes.add(result.matchedIndex); + } + if (result.action === "added") { + summary.addedAccountCount += 1; + changed = true; + continue; + } + if (result.action === "updated") { + summary.updatedAccountCount += 1; + changed = true; + continue; + } + summary.unchangedAccountCount += 1; + } + + summary.destinationOnlyPreservedCount = Math.max( + 0, + targetAccountCountBefore - matchedExistingIndexes.size, + ); + + if (next.accounts.length > 0) { + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); + const previousActive = next.activeIndex; + const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + const applyActiveFromCodex = shouldApplyCodexCliSelection( + state, + options.persistedLocalTimestamp, + ); + if (applyActiveFromCodex) { + const desiredIndex = resolveActiveIndex( + next.accounts, + state.activeAccountId ?? activeFromSnapshots.accountId, + state.activeEmail ?? activeFromSnapshots.email, + ); + if (typeof desiredIndex === "number") { + applyCodexCliSelection(next, desiredIndex); + } else if ( + state.activeAccountId || + state.activeEmail || + activeFromSnapshots.accountId || + activeFromSnapshots.email + ) { + log.debug( + "Skipped Codex CLI active selection overwrite due to ambiguous source selection", + { + operation: "reconcile-storage", + outcome: "selection-ambiguous", + }, + ); + } + } else { + log.debug( + "Skipped Codex CLI active selection overwrite due to newer local state", + { + operation: "reconcile-storage", + outcome: "local-newer", + }, + ); + } + normalizeStoredFamilyIndexes(next); + const currentFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + if ( + previousActive !== next.activeIndex || + previousFamilies !== currentFamilies + ) { + summary.selectionChanged = true; + changed = true; + } + } + + summary.targetAccountCountAfter = next.accounts.length; + return { next, changed, summary }; +} + +export async function previewCodexCliSync( + current: AccountStorageV3 | null, + options: { + forceRefresh?: boolean; + storageBackupEnabled?: boolean; + sourceState?: CodexCliState | null; + } = {}, +): Promise { + const targetPath = getStoragePath(); + const syncEnabled = isCodexCliSyncEnabled(); + const backup = { + enabled: options.storageBackupEnabled ?? true, + targetPath, + rollbackPaths: formatRollbackPaths(targetPath), + }; + const lastSync = getLastCodexCliSyncRun(); + const emptySummary = createEmptySyncSummary(); + emptySummary.targetAccountCountBefore = current?.accounts.length ?? 0; + emptySummary.targetAccountCountAfter = current?.accounts.length ?? 0; + try { + if (!syncEnabled) { + return { + status: "disabled", + statusDetail: "Codex CLI sync is disabled by environment override.", + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + const state = await resolveCodexCliSyncState(options); + if (!state) { + return { + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } + + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); + const status = reconciled.changed ? "ready" : "noop"; + const skippedAccountCount = Math.max( + 0, + reconciled.summary.sourceAccountCount - + reconciled.summary.addedAccountCount - + reconciled.summary.updatedAccountCount - + reconciled.summary.unchangedAccountCount, + ); + const statusDetail = reconciled.changed + ? `Preview ready: ${reconciled.summary.addedAccountCount} add, ${reconciled.summary.updatedAccountCount} update, ${reconciled.summary.destinationOnlyPreservedCount} destination-only preserved${ + skippedAccountCount > 0 ? `, ${skippedAccountCount} skipped` : "" + }.` + : skippedAccountCount > 0 + ? `Target already matches the current one-way sync result. ${skippedAccountCount} source account skipped due to conflicting or incomplete identity.` + : "Target already matches the current one-way sync result."; + return { + status, + statusDetail, + sourcePath: state.path, + sourceAccountCount: state.accounts.length, + targetPath, + summary: reconciled.summary, + backup, + lastSync, + }; + } catch (error) { + return { + status: "error", + statusDetail: error instanceof Error ? error.message : String(error), + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup, + lastSync, + }; + } +} + +/** + * Reconciles the provided local account storage with the Codex CLI state and returns the resulting storage and whether it changed. + * + * This operation: + * - Merges accounts from the Codex CLI state into a clone of `current` (or into a new empty storage when `current` is null). + * - May update the active account selection and per-family active indexes when the Codex CLI selection is considered applicable. + * - Preserves secrets and sensitive fields; any tokens written to storage are subject to the project's token-redaction rules and are not exposed in logs or metrics. + * + * Concurrency assumptions: + * - Caller is responsible for serializing concurrent writes to persistent storage; this function only returns an in-memory storage object and does not perform atomic file-level coordination. + * + * Windows filesystem notes: + * - When the caller persists the returned storage to disk on Windows, standard Windows file-locking and path-length semantics apply; this function does not perform Windows-specific path normalization. + * + * @param current - The current local AccountStorageV3, or `null` to indicate none exists. + * @returns An object containing: + * - `storage`: the reconciled AccountStorageV3 to persist (may be the original `current` when no changes were applied). + * - `changed`: `true` if the reconciled storage differs from `current`, `false` otherwise. + */ +export async function applyCodexCliSyncToStorage( + current: AccountStorageV3 | null, + options: { forceRefresh?: boolean; sourceState?: CodexCliState | null } = {}, +): Promise<{ + storage: AccountStorageV3 | null; + changed: boolean; + pendingRun: PendingCodexCliSyncRun | null; +}> { + incrementCodexCliMetric("reconcileAttempts"); + const targetPath = getStoragePath(); + try { + if (!isCodexCliSyncEnabled()) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "disabled", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "Codex CLI sync disabled by environment override.", + }), + allocateCodexCliSyncRunRevision(), + ); + return { storage: current, changed: false, pendingRun: null }; + } + + const state = await resolveCodexCliSyncState({ + ...options, + forceRefresh: options.forceRefresh ?? true, + }); + if (!state) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "unavailable", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: "No Codex CLI sync source was available.", + }), + allocateCodexCliSyncRunRevision(), + ); + return { storage: current, changed: false, pendingRun: null }; + } + + const reconciled = reconcileCodexCliState(current, state, { + persistedLocalTimestamp: await getPersistedLocalSelectionTimestamp(), + }); + const next = reconciled.next; + const changed = reconciled.changed; + const storage = + next.accounts.length === 0 ? (current ?? next) : next; + const syncRun = createSyncRun({ + outcome: changed ? "changed" : "noop", + sourcePath: state.path, + targetPath, + summary: reconciled.summary, + }); + + if (!changed) { + incrementCodexCliMetric("reconcileNoops"); + publishCodexCliSyncRun(syncRun, allocateCodexCliSyncRunRevision()); + } else { + incrementCodexCliMetric("reconcileChanges"); + } + + const activeFromSnapshots = readActiveFromSnapshots(state.accounts); + log.debug("Codex CLI reconcile completed", { + operation: "reconcile-storage", + outcome: changed ? "changed" : "noop", + accountCount: next.accounts.length, + activeAccountRef: makeAccountFingerprint({ + accountId: state.activeAccountId ?? activeFromSnapshots.accountId, + email: state.activeEmail ?? activeFromSnapshots.email, + }), + }); + return { + storage, + changed, + pendingRun: changed + ? { + revision: allocatePendingCodexCliSyncRunRevision(), + run: syncRun, + } + : null, + }; + } catch (error) { + incrementCodexCliMetric("reconcileFailures"); + publishCodexCliSyncRun( + createSyncRun({ + outcome: "error", + sourcePath: null, + targetPath, + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: current?.accounts.length ?? 0, + targetAccountCountAfter: current?.accounts.length ?? 0, + }, + message: error instanceof Error ? error.message : String(error), + }), + allocateCodexCliSyncRunRevision(), + ); + log.warn("Codex CLI reconcile failed", { + operation: "reconcile-storage", + outcome: "error", + error: String(error), + }); + return { storage: current, changed: false, pendingRun: null }; + } +} + export function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, -): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { +): Promise<{ + storage: AccountStorageV3 | null; + changed: boolean; + pendingRun: PendingCodexCliSyncRun | null; +}> { incrementCodexCliMetric("reconcileAttempts"); if (!current) { @@ -74,7 +852,7 @@ export function syncAccountStorageFromCodexCli( operation: "reconcile-storage", outcome: "canonical-missing", }); - return Promise.resolve({ storage: null, changed: false }); + return Promise.resolve({ storage: null, changed: false, pendingRun: null }); } const next = cloneStorage(current); @@ -87,15 +865,19 @@ export function syncAccountStorageFromCodexCli( previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {}); incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Skipped Codex CLI authority import; canonical storage remains authoritative", { - operation: "reconcile-storage", - outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", - accountCount: next.accounts.length, - }); + log.debug( + "Skipped Codex CLI authority import; canonical storage remains authoritative", + { + operation: "reconcile-storage", + outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", + accountCount: next.accounts.length, + }, + ); return Promise.resolve({ storage: changed ? next : current, changed, + pendingRun: null, }); } diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 172648d3..0c1fb28f 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,8 +1,28 @@ import { promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; +import { + type CodexCliState, + getCodexCliAccountsPath, + getCodexCliAuthPath, + getCodexCliConfigPath, + isCodexCliSyncEnabled, + loadCodexCliState, +} from "../codex-cli/state.js"; +import { + applyCodexCliSyncToStorage, + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, + type CodexCliSyncPreview, + type CodexCliSyncRun, + type CodexCliSyncSummary, + formatRollbackPaths, + getLastCodexCliSyncRun, + previewCodexCliSync, +} from "../codex-cli/sync.js"; import { getDefaultPluginConfig, + getStorageBackupEnabled, loadPluginConfig, savePluginConfig, } from "../config.js"; @@ -17,13 +37,22 @@ import { loadDashboardDisplaySettings, saveDashboardDisplaySettings, } from "../dashboard-settings.js"; +import { + getLastLiveAccountSyncSnapshot, + type LiveAccountSyncSnapshot, +} from "../live-account-sync.js"; import { applyOcChatgptSync, planOcChatgptSync, runNamedBackupExport, } from "../oc-chatgpt-orchestrator.js"; import { detectOcChatgptMultiAuthTarget } from "../oc-chatgpt-target-detection.js"; -import { loadAccounts, normalizeAccountStorage } from "../storage.js"; +import { + getStoragePath, + loadAccounts, + normalizeAccountStorage, + saveAccounts, +} from "../storage.js"; import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; @@ -264,6 +293,7 @@ type BackendSettingsHubAction = type SettingsHubAction = | { type: "account-list" } + | { type: "sync-center" } | { type: "summary-fields" } | { type: "behavior" } | { type: "theme" } @@ -271,6 +301,20 @@ type SettingsHubAction = | { type: "backend" } | { type: "back" }; +type SyncCenterAction = + | { type: "refresh" } + | { type: "apply" } + | { type: "back" }; + +interface SyncCenterOverviewContext { + accountsPath: string; + authPath: string; + configPath: string; + sourceAccountCount: number | null; + liveSync: LiveAccountSyncSnapshot; + syncEnabled: boolean; +} + type ExperimentalSettingsAction = | { type: "sync" } | { type: "backup" } @@ -280,7 +324,6 @@ type ExperimentalSettingsAction = | { type: "apply" } | { type: "save" } | { type: "back" }; - const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ { key: "liveAccountSync", @@ -782,7 +825,6 @@ async function readFileWithRetry(path: string): Promise { } } } - async function persistBackendConfigSelection( selected: PluginConfig, scope: string, @@ -1237,6 +1279,163 @@ function formatMenuQuotaTtl(ttlMs: number): string { return `${ttlMs}ms`; } +function formatSyncRunTime(run: CodexCliSyncRun | null): string { + if (!run) return "No sync applied in this session."; + return new Date(run.runAt).toISOString().replace("T", " "); +} + +function formatSyncRunOutcome(run: CodexCliSyncRun | null): string { + if (!run) return "none"; + if (run.outcome === "changed") return "applied changes"; + if (run.outcome === "noop") return "already aligned"; + if (run.outcome === "disabled") return "disabled"; + if (run.outcome === "unavailable") return "source missing"; + return run.message ? `error: ${run.message}` : "error"; +} + +function formatSyncSummary(summary: CodexCliSyncSummary): string { + return [ + `add ${summary.addedAccountCount}`, + `update ${summary.updatedAccountCount}`, + `preserve ${summary.destinationOnlyPreservedCount}`, + `after ${summary.targetAccountCountAfter}`, + ].join(" | "); +} + +function formatSyncTimestamp(timestamp: number | null | undefined): string { + if ( + typeof timestamp !== "number" || + !Number.isFinite(timestamp) || + timestamp <= 0 + ) { + return "none"; + } + return new Date(timestamp).toISOString().replace("T", " "); +} + +function formatSyncMtime(mtimeMs: number | null): string { + if ( + typeof mtimeMs !== "number" || + !Number.isFinite(mtimeMs) || + mtimeMs <= 0 + ) { + return "unknown"; + } + return new Date(Math.round(mtimeMs)).toISOString().replace("T", " "); +} + +function resolveSyncCenterContext( + sourceAccountCount: number | null, +): SyncCenterOverviewContext { + return { + accountsPath: getCodexCliAccountsPath(), + authPath: getCodexCliAuthPath(), + configPath: getCodexCliConfigPath(), + sourceAccountCount, + liveSync: getLastLiveAccountSyncSnapshot(), + syncEnabled: isCodexCliSyncEnabled(), + }; +} + +function formatSyncSourceLabel( + preview: CodexCliSyncPreview, + context: SyncCenterOverviewContext, +): string { + const normalizedSourcePath = normalizePathForComparison(preview.sourcePath); + const normalizedAccountsPath = normalizePathForComparison(context.accountsPath); + const normalizedAuthPath = normalizePathForComparison(context.authPath); + if (!context.syncEnabled) return "disabled by environment override"; + if (!normalizedSourcePath) return "not available"; + if (normalizedSourcePath === normalizedAccountsPath) + return "accounts.json active"; + if (normalizedSourcePath === normalizedAuthPath) + return "auth.json fallback active"; + return "custom source path active"; +} + +function normalizePathForComparison( + path: string | null | undefined, +): string | null { + if (typeof path !== "string" || path.length === 0) { + return null; + } + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/"); + const trimmed = + normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; + const isWindowsPath = path.includes("\\") || /^[a-z]:\//i.test(trimmed); + return isWindowsPath ? trimmed.toLowerCase() : trimmed; +} + +function buildSyncCenterOverview( + preview: CodexCliSyncPreview, + context: SyncCenterOverviewContext = resolveSyncCenterContext(null), +): Array<{ label: string; hint?: string }> { + const lastSync = preview.lastSync; + const activeSourceLabel = formatSyncSourceLabel(preview, context); + const liveSync = context.liveSync; + const liveSyncLabel = liveSync.running ? "running" : "idle"; + const liveSyncHint = liveSync.running + ? `Watching ${liveSync.path ?? preview.targetPath}. Reloads ${liveSync.reloadCount}, errors ${liveSync.errorCount}, last reload ${formatSyncTimestamp(liveSync.lastSyncAt)}, last seen mtime ${formatSyncMtime(liveSync.lastKnownMtimeMs)}.` + : `No live watcher is active in this process. When plugin mode runs with live sync enabled, it watches ${preview.targetPath} and reloads accounts after file changes.`; + const sourceStateHint = [ + `Active source: ${activeSourceLabel}.`, + `Accounts path: ${context.accountsPath}`, + `Auth path: ${context.authPath}`, + `Config path: ${context.configPath}`, + context.sourceAccountCount !== null + ? `Visible source accounts: ${context.sourceAccountCount}.` + : "No readable Codex CLI source is visible right now.", + ].join("\n"); + const selectionHint = preview.summary.selectionChanged + ? "When the Codex CLI source is newer, target selection follows activeAccountId first, then activeEmail or the active snapshot email. If local storage or a local Codex selection write is newer, the target keeps the local selection." + : "Selection precedence stays accountId first, then email, with newer local target state preserving its own active selection instead of being overwritten."; + return [ + { + label: `Status: ${preview.status}`, + hint: `${preview.statusDetail}\nLast sync: ${formatSyncRunOutcome(lastSync)} at ${formatSyncRunTime(lastSync)}`, + }, + { + label: `Target path: ${preview.targetPath}`, + hint: preview.sourcePath + ? `Source path: ${preview.sourcePath}` + : "Source path: not available", + }, + { + label: `Codex CLI source visibility: ${activeSourceLabel}`, + hint: sourceStateHint, + }, + { + label: `Live watcher: ${liveSyncLabel}`, + hint: liveSyncHint, + }, + { + label: "Preview mode: read-only until apply", + hint: "Refresh only re-reads the Codex CLI source and recomputes the one-way result. Apply writes the latest preview snapshot into the target path; refresh before apply if the Codex CLI files may have changed. It does not create a bidirectional merge.", + }, + { + label: `Preview summary: ${formatSyncSummary(preview.summary)}`, + hint: preview.summary.selectionChanged + ? "Active selection also updates to match the current Codex CLI source when that source is newer." + : "Active selection already matches the one-way sync result.", + }, + { + label: + "Selection precedence: accountId -> email -> preserve newer local choice", + hint: selectionHint, + }, + { + label: `Destination-only preservation: keep ${preview.summary.destinationOnlyPreservedCount} target-only account(s)`, + hint: "One-way sync never deletes accounts that exist only in the target storage.", + }, + { + label: `Pre-sync backup and rollback: ${preview.backup.enabled ? "enabled" : "disabled"}`, + hint: preview.backup.enabled + ? `Before apply, target writes can create ${preview.backup.rollbackPaths.join(", ")} so rollback has explicit recovery context if the sync result is not what you expected.` + : "Storage backups are currently disabled, so apply writes rely on the direct target write only.", + }, + ]; +} + function clampBackendNumberForTests(settingKey: string, value: number): number { const option = BACKEND_NUMBER_OPTION_BY_KEY.get( settingKey as BackendNumberSettingKey, @@ -1277,6 +1476,7 @@ const __testOnly = { clampBackendNumber: clampBackendNumberForTests, formatMenuLayoutMode, cloneDashboardSettings, + buildSyncCenterOverview, withQueuedRetry: withQueuedRetryForTests, loadExperimentalSyncTarget, promptExperimentalSettings, @@ -1291,6 +1491,7 @@ const __testOnly = { promptBehaviorSettings, promptThemeSettings, promptBackendSettings, + promptSyncCenter, }; /* c8 ignore start - interactive prompt flows are covered by integration tests */ @@ -2474,6 +2675,221 @@ async function promptBackendSettings( } } +async function promptSyncCenter(config: PluginConfig): Promise { + if (!input.isTTY || !output.isTTY) return; + const ui = getUiRuntimeOptions(); + const buildPreview = async ( + forceRefresh = false, + ): Promise<{ + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + }> => { + const current = await loadAccounts(); + const sourceState = isCodexCliSyncEnabled() + ? await loadCodexCliState({ forceRefresh }) + : null; + const preview = await previewCodexCliSync(current, { + forceRefresh, + sourceState, + storageBackupEnabled: getStorageBackupEnabled(config), + }); + return { + preview, + context: resolveSyncCenterContext(preview.sourceAccountCount), + sourceState, + }; + }; + const buildErrorState = ( + message: string, + previousPreview?: CodexCliSyncPreview, + ): { + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + } => { + if (previousPreview) { + return { + preview: { + ...previousPreview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: message, + }, + context: resolveSyncCenterContext(previousPreview.sourceAccountCount), + sourceState: null, + }; + } + + const targetPath = getStoragePath(); + const emptySummary: CodexCliSyncSummary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + return { + preview: { + status: "error", + statusDetail: message, + sourcePath: null, + sourceAccountCount: null, + targetPath, + summary: emptySummary, + backup: { + enabled: getStorageBackupEnabled(config), + targetPath, + rollbackPaths: formatRollbackPaths(targetPath), + }, + lastSync: getLastCodexCliSyncRun(), + }, + context: resolveSyncCenterContext(null), + sourceState: null, + }; + }; + const buildPreviewSafely = async ( + forceRefresh = false, + previousPreview?: CodexCliSyncPreview, + ): Promise<{ + preview: CodexCliSyncPreview; + context: SyncCenterOverviewContext; + sourceState: CodexCliState | null; + }> => { + try { + return await withQueuedRetry(getStoragePath(), async () => + buildPreview(forceRefresh), + ); + } catch (error) { + return buildErrorState( + `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, + previousPreview, + ); + } + }; + + let { preview, context, sourceState } = await buildPreviewSafely(true); + while (true) { + const overview = buildSyncCenterOverview(preview, context); + const items: MenuItem[] = [ + { + label: UI_COPY.settings.syncCenterOverviewHeading, + value: { type: "back" }, + kind: "heading", + }, + ...overview.map((item) => ({ + label: item.label, + hint: item.hint, + value: { type: "back" } as SyncCenterAction, + disabled: true, + color: "green" as const, + hideUnavailableSuffix: true, + })), + { label: "", value: { type: "back" }, separator: true }, + { + label: UI_COPY.settings.syncCenterActionsHeading, + value: { type: "back" }, + kind: "heading", + }, + { + label: UI_COPY.settings.syncCenterApply, + hint: "Applies the current preview to the target storage path.", + value: { type: "apply" }, + color: preview.status === "ready" ? "green" : "yellow", + disabled: preview.status !== "ready", + }, + { + label: UI_COPY.settings.syncCenterRefresh, + hint: "Re-read the source files and rebuild the sync preview.", + value: { type: "refresh" }, + color: "yellow", + }, + { + label: UI_COPY.settings.syncCenterBack, + value: { type: "back" }, + color: "red", + }, + ]; + + const result = await select(items, { + message: UI_COPY.settings.syncCenterTitle, + subtitle: UI_COPY.settings.syncCenterSubtitle, + help: UI_COPY.settings.syncCenterHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" }; + if (lower === "r") return { type: "refresh" }; + if (lower === "a" && preview.status === "ready") { + return { type: "apply" }; + } + return undefined; + }, + }); + + if (!result || result.type === "back") return; + if (result.type === "refresh") { + ({ preview, context, sourceState } = await buildPreviewSafely( + true, + preview, + )); + continue; + } + + try { + const synced = await withQueuedRetry(preview.targetPath, async () => { + const current = await loadAccounts(); + return applyCodexCliSyncToStorage(current, { + sourceState, + }); + }); + const storageBackupEnabled = getStorageBackupEnabled(config); + if (synced.changed && synced.storage) { + const syncedStorage = synced.storage; + try { + await withQueuedRetry(preview.targetPath, async () => + saveAccounts(syncedStorage, { + backupEnabled: storageBackupEnabled, + }), + ); + commitPendingCodexCliSyncRun(synced.pendingRun); + } catch (error) { + commitCodexCliSyncRunFailure(synced.pendingRun, error); + preview = { + ...preview, + lastSync: getLastCodexCliSyncRun(), + status: "error", + statusDetail: `Failed to save synced storage: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + continue; + } + } + ({ preview, context, sourceState } = await buildPreviewSafely( + true, + preview, + )); + } catch (error) { + preview = { + ...preview, + status: "error", + lastSync: getLastCodexCliSyncRun(), + statusDetail: `Failed to refresh sync center: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } +} + async function loadExperimentalSyncTarget(): Promise< | { kind: "blocked-ambiguous"; @@ -2881,6 +3297,11 @@ async function promptSettingsHub( value: { type: "account-list" }, color: "green", }, + { + label: UI_COPY.settings.syncCenter, + value: { type: "sync-center" }, + color: "green", + }, { label: UI_COPY.settings.summaryFields, value: { type: "summary-fields" }, @@ -2958,6 +3379,10 @@ async function configureUnifiedSettings( current = await configureDashboardDisplaySettings(current); continue; } + if (action.type === "sync-center") { + await promptSyncCenter(backendConfig); + continue; + } if (action.type === "summary-fields") { current = await configureStatuslineSettings(current); continue; diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index 245be892..b6e9816a 100644 --- a/lib/live-account-sync.ts +++ b/lib/live-account-sync.ts @@ -1,4 +1,4 @@ -import { promises as fs, watch as fsWatch, type FSWatcher } from "node:fs"; +import { type FSWatcher, promises as fs, watch as fsWatch } from "node:fs"; import { basename, dirname } from "node:path"; import { createLogger } from "./logger.js"; @@ -18,13 +18,66 @@ export interface LiveAccountSyncSnapshot { errorCount: number; } +const EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT: LiveAccountSyncSnapshot = { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, +}; + +let lastLiveAccountSyncSnapshot: LiveAccountSyncSnapshot = { + ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT, +}; +const activeLiveAccountSyncSnapshots = new Map(); +let lastStoppedLiveAccountSyncSnapshot: + | { instanceId: number; snapshot: LiveAccountSyncSnapshot } + | null = null; +let nextLiveAccountSyncInstanceId = 0; + +function refreshLastLiveAccountSyncSnapshot(): void { + let latestActiveInstanceId = -1; + let latestActiveSnapshot: LiveAccountSyncSnapshot | null = null; + for (const [instanceId, snapshot] of activeLiveAccountSyncSnapshots.entries()) { + if (instanceId > latestActiveInstanceId) { + latestActiveInstanceId = instanceId; + latestActiveSnapshot = snapshot; + } + } + if (latestActiveSnapshot) { + lastLiveAccountSyncSnapshot = { ...latestActiveSnapshot }; + return; + } + if (lastStoppedLiveAccountSyncSnapshot) { + lastLiveAccountSyncSnapshot = { + ...lastStoppedLiveAccountSyncSnapshot.snapshot, + }; + return; + } + lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; +} + +export function getLastLiveAccountSyncSnapshot(): LiveAccountSyncSnapshot { + return { ...lastLiveAccountSyncSnapshot }; +} + +export function __resetLastLiveAccountSyncSnapshotForTests(): void { + lastLiveAccountSyncSnapshot = { ...EMPTY_LIVE_ACCOUNT_SYNC_SNAPSHOT }; + activeLiveAccountSyncSnapshots.clear(); + lastStoppedLiveAccountSyncSnapshot = null; + nextLiveAccountSyncInstanceId = 0; +} + /** * Convert an fs.watch filename value to a UTF-8 string or null. * * @param filename - The value supplied by fs.watch listeners; may be a `string`, `Buffer`, or `null`. Buffers are decoded as UTF-8. * @returns `filename` as a UTF-8 string, or `null` when the input is `null`. */ -function normalizeFsWatchFilename(filename: string | Buffer | null): string | null { +function normalizeFsWatchFilename( + filename: string | Buffer | null, +): string | null { if (filename === null) return null; if (typeof filename === "string") return filename; return filename.toString("utf-8"); @@ -63,6 +116,7 @@ function summarizeWatchPath(path: string | null): string { * changes. Uses fs.watch + polling fallback for Windows reliability. */ export class LiveAccountSync { + private readonly instanceId: number; private readonly reload: () => Promise; private readonly debounceMs: number; private readonly pollIntervalMs: number; @@ -75,36 +129,54 @@ export class LiveAccountSync { private lastSyncAt: number | null = null; private reloadCount = 0; private errorCount = 0; - private reloadInFlight: Promise | null = null; + private generation = 0; + private reloadInFlight: { generation: number; promise: Promise } | null = + null; + private reloadQueued = false; - constructor(reload: () => Promise, options: LiveAccountSyncOptions = {}) { + constructor( + reload: () => Promise, + options: LiveAccountSyncOptions = {}, + ) { + this.instanceId = ++nextLiveAccountSyncInstanceId; this.reload = reload; this.debounceMs = Math.max(50, Math.floor(options.debounceMs ?? 250)); - this.pollIntervalMs = Math.max(500, Math.floor(options.pollIntervalMs ?? 2_000)); + this.pollIntervalMs = Math.max( + 500, + Math.floor(options.pollIntervalMs ?? 2_000), + ); } async syncToPath(path: string): Promise { if (!path) return; if (this.currentPath === path && this.running) return; this.stop(); - + const generation = this.generation; + const nextMtimeMs = await readMtimeMs(path); + if (generation !== this.generation) { + return; + } this.currentPath = path; - this.lastKnownMtimeMs = await readMtimeMs(path); + this.lastKnownMtimeMs = nextMtimeMs; const targetDir = dirname(path); const targetName = basename(path); try { - this.watcher = fsWatch(targetDir, { persistent: false }, (_eventType, filename) => { - const name = normalizeFsWatchFilename(filename); - if (!name) { - this.scheduleReload("watch"); - return; - } + this.watcher = fsWatch( + targetDir, + { persistent: false }, + (_eventType, filename) => { + const name = normalizeFsWatchFilename(filename); + if (!name) { + this.scheduleReload("watch"); + return; + } - if (name === targetName || name.startsWith(`${targetName}.`)) { - this.scheduleReload("watch"); - } - }); + if (name === targetName || name.startsWith(`${targetName}.`)) { + this.scheduleReload("watch"); + } + }, + ); } catch (error) { this.errorCount += 1; log.warn("Failed to start fs.watch for account storage", { @@ -116,14 +188,21 @@ export class LiveAccountSync { this.pollTimer = setInterval(() => { void this.pollOnce(); }, this.pollIntervalMs); - if (typeof this.pollTimer === "object" && "unref" in this.pollTimer && typeof this.pollTimer.unref === "function") { + if ( + typeof this.pollTimer === "object" && + "unref" in this.pollTimer && + typeof this.pollTimer.unref === "function" + ) { this.pollTimer.unref(); } this.running = true; + this.publishSnapshot(); } stop(): void { + this.generation += 1; + this.reloadQueued = false; this.running = false; if (this.watcher) { this.watcher.close(); @@ -137,6 +216,7 @@ export class LiveAccountSync { clearTimeout(this.debounceTimer); this.debounceTimer = null; } + this.publishSnapshot(); } getSnapshot(): LiveAccountSyncSnapshot { @@ -150,6 +230,25 @@ export class LiveAccountSync { }; } + private publishSnapshot(): void { + const snapshot = this.getSnapshot(); + if (snapshot.running) { + activeLiveAccountSyncSnapshots.set(this.instanceId, snapshot); + } else { + activeLiveAccountSyncSnapshots.delete(this.instanceId); + if ( + !lastStoppedLiveAccountSyncSnapshot || + this.instanceId >= lastStoppedLiveAccountSyncSnapshot.instanceId + ) { + lastStoppedLiveAccountSyncSnapshot = { + instanceId: this.instanceId, + snapshot, + }; + } + } + refreshLastLiveAccountSyncSnapshot(); + } + private scheduleReload(reason: "watch" | "poll"): void { if (!this.running) return; if (this.debounceTimer) { @@ -174,41 +273,76 @@ export class LiveAccountSync { path: summarizeWatchPath(this.currentPath), error: error instanceof Error ? error.message : String(error), }); + this.publishSnapshot(); } } private async runReload(reason: "watch" | "poll"): Promise { if (!this.running || !this.currentPath) return; const targetPath = this.currentPath; + const generation = this.generation; + if (this.reloadInFlight) { - await this.reloadInFlight; - return; + const inFlightReload = this.reloadInFlight; + if (inFlightReload.generation === generation) { + this.reloadQueued = true; + return; + } + await inFlightReload.promise; + if (this.reloadInFlight?.promise === inFlightReload.promise) { + this.reloadInFlight = null; + } + if (!this.running || !this.currentPath) return; + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } } - this.reloadInFlight = (async () => { + do { + this.reloadQueued = false; + const promise = (async () => { + try { + await this.reload(); + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + this.lastSyncAt = Date.now(); + this.reloadCount += 1; + this.lastKnownMtimeMs = await readMtimeMs(targetPath); + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + log.debug("Reloaded account manager from live storage update", { + reason, + path: summarizeWatchPath(targetPath), + }); + } catch (error) { + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + this.errorCount += 1; + log.warn("Live account sync reload failed", { + reason, + path: summarizeWatchPath(targetPath), + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + this.reloadInFlight = { generation, promise }; + try { - await this.reload(); - this.lastSyncAt = Date.now(); - this.reloadCount += 1; - this.lastKnownMtimeMs = await readMtimeMs(targetPath); - log.debug("Reloaded account manager from live storage update", { - reason, - path: summarizeWatchPath(targetPath), - }); - } catch (error) { - this.errorCount += 1; - log.warn("Live account sync reload failed", { - reason, - path: summarizeWatchPath(targetPath), - error: error instanceof Error ? error.message : String(error), - }); + await promise; + } finally { + if (this.reloadInFlight?.promise === promise) { + this.reloadInFlight = null; + this.publishSnapshot(); + } } - })(); - try { - await this.reloadInFlight; - } finally { - this.reloadInFlight = null; - } + if (!this.running || !this.currentPath) return; + if (generation !== this.generation || targetPath !== this.currentPath) { + return; + } + } while (this.reloadQueued); } } diff --git a/lib/storage.ts b/lib/storage.ts index 35261661..8424a888 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -382,6 +382,10 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } +export function isStorageBackupEnabled(): boolean { + return storageBackupEnabled; +} + function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } @@ -2358,12 +2362,24 @@ async function buildNamedBackupMetadata( }; } -async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { +/** + * Optional per-call overrides for account storage persistence. + * When omitted, `saveAccounts` uses the module-level backup policy. + */ +export interface SaveAccountsOptions { + backupEnabled?: boolean; +} + +async function saveAccountsUnlocked( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); + const backupEnabled = options.backupEnabled ?? storageBackupEnabled; try { await fs.mkdir(dirname(path), { recursive: true }); @@ -2395,7 +2411,7 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } } - if (storageBackupEnabled && existsSync(path)) { + if (backupEnabled && existsSync(path)) { try { await createRotatingAccountsBackup(path); } catch (backupError) { @@ -2569,11 +2585,16 @@ export async function withAccountAndFlaggedStorageTransaction( * Creates the Codex multi-auth storage directory if it doesn't exist. * Verifies file was written correctly and provides detailed error messages. * @param storage - Account storage data to save + * @param options - Optional per-call persistence overrides. Set `backupEnabled` + * to override the module-level backup policy for this save only. * @throws StorageError with platform-aware hints on failure */ -export async function saveAccounts(storage: AccountStorageV3): Promise { +export async function saveAccounts( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { return withStorageLock(async () => { - await saveAccountsUnlocked(storage); + await saveAccountsUnlocked(storage, options); }); } diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 10f123e4..552258b8 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -68,6 +68,7 @@ export const UI_COPY = { advancedTitle: "Advanced", exitTitle: "Back", accountList: "Account List View", + syncCenter: "Sync Center", summaryFields: "Summary Line", behavior: "Menu Behavior", theme: "Color Theme", @@ -111,6 +112,15 @@ export const UI_COPY = { backendSubtitle: "Tune sync, retry, and limit behavior", backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + syncCenterTitle: "Sync Center", + syncCenterSubtitle: + "Inspect source paths, watcher status, and one-way preview semantics before applying sync", + syncCenterHelp: "Enter Select | A Apply | R Refresh | Q Back", + syncCenterOverviewHeading: "Sync Overview", + syncCenterActionsHeading: "Actions", + syncCenterRefresh: "Refresh Preview", + syncCenterApply: "Apply Preview To Target", + syncCenterBack: "Back", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", backendCategoryHelp: diff --git a/test/accounts-edge.test.ts b/test/accounts-edge.test.ts index 31c34b07..8bf88950 100644 --- a/test/accounts-edge.test.ts +++ b/test/accounts-edge.test.ts @@ -5,6 +5,8 @@ const mockLoadAccounts = vi.fn(); const mockSaveAccounts = vi.fn(); const mockLoadCodexCliState = vi.fn(); const mockSyncAccountStorageFromCodexCli = vi.fn(); +const mockCommitPendingCodexCliSyncRun = vi.fn(); +const mockCommitCodexCliSyncRunFailure = vi.fn(); const mockSetCodexCliActiveSelection = vi.fn(); const mockSelectHybridAccount = vi.fn(); @@ -27,6 +29,8 @@ vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { }); vi.mock("../lib/codex-cli/sync.js", () => ({ + commitPendingCodexCliSyncRun: mockCommitPendingCodexCliSyncRun, + commitCodexCliSyncRunFailure: mockCommitCodexCliSyncRunFailure, syncAccountStorageFromCodexCli: mockSyncAccountStorageFromCodexCli, })); @@ -79,6 +83,8 @@ async function importAccountsModule() { describe("accounts edge branches", () => { beforeEach(() => { vi.clearAllMocks(); + mockCommitPendingCodexCliSyncRun.mockReset(); + mockCommitCodexCliSyncRunFailure.mockReset(); mockLoadAccounts.mockResolvedValue(null); mockSaveAccounts.mockResolvedValue(undefined); mockLoadCodexCliState.mockResolvedValue(null); diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 61c2b8b0..498a4d4c 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager } from "../lib/accounts.js"; +const { + commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailureMock, +} = vi.hoisted(() => ({ + commitPendingCodexCliSyncRunMock: vi.fn(), + commitCodexCliSyncRunFailureMock: vi.fn(), +})); + vi.mock("../lib/storage.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -11,6 +19,8 @@ vi.mock("../lib/storage.js", async (importOriginal) => { }); vi.mock("../lib/codex-cli/sync.js", () => ({ + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, syncAccountStorageFromCodexCli: vi.fn(), })); @@ -30,11 +40,14 @@ import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; describe("AccountManager loadFromDisk", () => { beforeEach(() => { vi.clearAllMocks(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); vi.mocked(loadAccounts).mockResolvedValue(null); vi.mocked(saveAccounts).mockResolvedValue(undefined); vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: false, storage: null, + pendingRun: null, }); vi.mocked(loadCodexCliState).mockResolvedValue(null); vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined); @@ -42,6 +55,25 @@ describe("AccountManager loadFromDisk", () => { it("persists Codex CLI source-of-truth storage when sync reports change", async () => { const now = Date.now(); + const pendingRun = { + revision: 1, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "source.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; const stored = { version: 3 as const, activeIndex: 0, @@ -60,17 +92,39 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun, }); const manager = await AccountManager.loadFromDisk(); expect(saveAccounts).toHaveBeenCalledWith(synced); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); expect(manager.getAccountCount()).toBe(2); expect(manager.getCurrentAccount()?.refreshToken).toBe("stored-refresh"); }); it("swallows source-of-truth persist failures and still returns a manager", async () => { const now = Date.now(); + const pendingRun = { + revision: 2, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "source.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 0, + targetAccountCountAfter: 1, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + }, + }; const synced = { version: 3 as const, activeIndex: 0, @@ -80,11 +134,18 @@ describe("AccountManager loadFromDisk", () => { vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ changed: true, storage: synced, + pendingRun, }); - vi.mocked(saveAccounts).mockRejectedValueOnce(new Error("forced persist failure")); + const saveError = new Error("forced persist failure"); + vi.mocked(saveAccounts).mockRejectedValueOnce(saveError); const manager = await AccountManager.loadFromDisk(); + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledWith( + pendingRun, + saveError, + ); expect(manager.getAccountCount()).toBe(1); expect(manager.getCurrentAccount()?.refreshToken).toBe("synced-refresh"); }); diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 5cf8f613..444e430e 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,4 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +const { + loadAccountsMock, + syncAccountStorageFromCodexCliMock, + commitPendingCodexCliSyncRunMock, + commitCodexCliSyncRunFailureMock, + loadCodexCliStateMock, +} = vi.hoisted(() => ({ + loadAccountsMock: vi.fn(), + syncAccountStorageFromCodexCliMock: vi.fn(), + commitPendingCodexCliSyncRunMock: vi.fn(), + commitCodexCliSyncRunFailureMock: vi.fn(), + loadCodexCliStateMock: vi.fn(), +})); import { AccountManager, extractAccountEmail, @@ -17,10 +30,47 @@ vi.mock("../lib/storage.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadAccounts: loadAccountsMock, saveAccounts: vi.fn().mockResolvedValue(undefined), }; }); +vi.mock("../lib/codex-cli/sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, + }; +}); + +vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCodexCliState: loadCodexCliStateMock, + }; +}); + +beforeEach(async () => { + const { saveAccounts } = await import("../lib/storage.js"); + loadAccountsMock.mockReset(); + syncAccountStorageFromCodexCliMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + loadCodexCliStateMock.mockReset(); + vi.mocked(saveAccounts).mockReset(); + vi.mocked(saveAccounts).mockResolvedValue(undefined); + loadAccountsMock.mockResolvedValue(null); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: null, + changed: false, + pendingRun: null, + }); + loadCodexCliStateMock.mockResolvedValue(null); +}); + describe("parseRateLimitReason", () => { it("returns quota for quota-related codes", () => { expect(parseRateLimitReason("usage_limit_reached")).toBe("quota"); @@ -190,6 +240,119 @@ describe("getAccountIdCandidates", () => { }); describe("AccountManager", () => { + it("commits a pending Codex CLI sync run only after loadFromDisk persists storage", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 1, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + + await AccountManager.loadFromDisk(); + + expect(mockSaveAccounts).toHaveBeenCalledTimes(1); + expect(mockSaveAccounts).toHaveBeenCalledWith(syncedStorage); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledWith(pendingRun); + expect(mockSaveAccounts.mock.invocationCallOrder[0]!).toBeLessThan( + commitPendingCodexCliSyncRunMock.mock.invocationCallOrder[0]!, + ); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + }); + + it("records loadFromDisk save failures as sync-run failures instead of successes", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now, lastUsed: now }, + ], + }; + const syncedStorage = { + ...stored, + accounts: [ + ...stored.accounts, + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + const pendingRun = { + revision: 2, + run: { + outcome: "changed" as const, + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }; + const saveError = new Error("save busy"); + loadAccountsMock.mockResolvedValue(stored); + syncAccountStorageFromCodexCliMock.mockResolvedValue({ + storage: syncedStorage, + changed: true, + pendingRun, + }); + + const { saveAccounts } = await import("../lib/storage.js"); + const mockSaveAccounts = vi.mocked(saveAccounts); + mockSaveAccounts.mockRejectedValueOnce(saveError); + + await AccountManager.loadFromDisk(); + + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledWith( + pendingRun, + saveError, + ); + }); + it("seeds from fallback auth when no storage exists", () => { const auth: OAuthAuthDetails = { type: "oauth", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 7d414a57..39ef4c25 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1,14 +1,23 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AccountStorageV3 } from "../lib/storage.js"; +import * as storageModule from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { + __resetLastCodexCliSyncRunForTests, + applyCodexCliSyncToStorage, + commitCodexCliSyncRunFailure, + commitPendingCodexCliSyncRun, getActiveSelectionForFamily, + getLastCodexCliSyncRun, + previewCodexCliSync, + SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; +import * as writerModule from "../lib/codex-cli/writer.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; @@ -40,6 +49,7 @@ describe("codex-cli sync", () => { let accountsPath: string; let authPath: string; let configPath: string; + let targetStoragePath: string; let previousPath: string | undefined; let previousAuthPath: string | undefined; let previousConfigPath: string | undefined; @@ -57,16 +67,22 @@ describe("codex-cli sync", () => { accountsPath = join(tempDir, "accounts.json"); authPath = join(tempDir, "auth.json"); configPath = join(tempDir, "config.toml"); + targetStoragePath = join(tempDir, "openai-codex-accounts.json"); process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; process.env.CODEX_CLI_AUTH_PATH = authPath; process.env.CODEX_CLI_CONFIG_PATH = configPath; process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); }); afterEach(async () => { + vi.restoreAllMocks(); + vi.resetModules(); clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; @@ -237,6 +253,1593 @@ describe("codex-cli sync", () => { expect(result.storage?.activeIndex).toBe(0); }); + it("previews one-way manual sync changes without mutating canonical storage", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a-new", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const snapshot = JSON.parse(JSON.stringify(current)) as AccountStorageV3; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + storageBackupEnabled: true, + }); + + expect(preview.status).toBe("ready"); + expect(preview.sourcePath).toBe(accountsPath); + expect(preview.summary.addedAccountCount).toBe(1); + expect(preview.summary.updatedAccountCount).toBe(1); + expect(preview.summary.destinationOnlyPreservedCount).toBe(1); + expect(preview.summary.selectionChanged).toBe(true); + expect(preview.backup.enabled).toBe(true); + expect(preview.backup.rollbackPaths).toContain(`${preview.targetPath}.bak`); + expect(preview.backup.rollbackPaths).toContain(`${preview.targetPath}.wal`); + const serializedPreview = JSON.stringify(preview); + expect(serializedPreview).not.toContain("access-a-new"); + expect(serializedPreview).not.toContain("refresh-a"); + expect(serializedPreview).not.toContain("access-c"); + expect(serializedPreview).not.toContain("refresh-c"); + expect(current).toEqual(snapshot); + }); + + it("skips ambiguous duplicate-email source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "dup@example.com", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "dup@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "dup@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("skips ambiguous duplicate-accountId source matches instead of overwriting a local account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.accounts).toEqual(current.accounts); + }); + + it("reports skipped ambiguous source snapshots in the preview summary", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "shared-id", + auth: { + tokens: { + access_token: "access-new", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "shared-id", + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "shared-id", + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.sourceAccountCount).toBe(1); + expect(preview.statusDetail).toContain("1 source account skipped"); + }); + + it("preserves the current selection when Codex CLI source has no active marker", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it("preserves a newer persisted local selection after restart when the target mtime is only 500ms newer", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.500Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it("preserves a newer persisted local selection on apply when the target mtime is only 500ms newer", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.500Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + }); + + it("preserves a newer local selection when Codex state has no timestamp metadata", async () => { + const state = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + }, + ], + }; + const targetTime = new Date("2026-03-13T00:00:05.000Z"); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue(state); + + try { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + } finally { + loadStateSpy.mockRestore(); + } + }); + + it("preserves the local selection when the persisted target timestamp is temporarily unreadable", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue("\0busy-target"); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + }); + + it.each(["EBUSY", "EPERM"] as const)( + "preserves the local selection when reading the persisted target timestamp fails with %s", + async (code) => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const statError = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + statError.code = code; + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + throw statError; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + expect(targetStatCalls).toBe(SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "does not let zero-version Codex state overwrite local selection when the target timestamp is unreadable with %s", + async (code) => { + const sourceState = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + }, + ], + }; + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + const error = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const result = await applyCodexCliSyncToStorage(current, { + sourceState, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + expect(targetStatCalls).toBe(SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "logs exhausted retries when reading the persisted target timestamp fails with %s", + async (code) => { + const debugSpy = vi.fn(); + vi.resetModules(); + vi.doMock("../lib/logger.js", async () => { + const actual = await vi.importActual( + "../lib/logger.js", + ); + return { + ...actual, + createLogger: () => ({ + debug: debugSpy, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + time: () => () => 0, + timeEnd: () => undefined, + }), + }; + }); + + try { + const freshStorageModule = await import("../lib/storage.js"); + const freshStateModule = await import("../lib/codex-cli/state.js"); + const freshWriterModule = await import("../lib/codex-cli/writer.js"); + const freshSyncModule = await import("../lib/codex-cli/sync.js"); + freshStateModule.clearCodexCliStateCache(); + freshSyncModule.__resetLastCodexCliSyncRunForTests(); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + vi.spyOn(freshStorageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn( + freshWriterModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(freshStorageModule, "getStoragePath").mockReturnValue(targetStoragePath); + + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + const error = new Error(`${code.toLowerCase()} target`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + try { + const preview = await freshSyncModule.previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(preview.status).toBe("noop"); + expect(debugSpy).toHaveBeenCalledWith( + "Exhausted retries reading persisted local selection timestamp", + { + error: `${code.toLowerCase()} target`, + }, + ); + } finally { + statSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + vi.resetModules(); + } + }, + ); + + it("retries a transient persisted-target EBUSY before applying the Codex selection", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const sourceTime = new Date("2026-03-13T00:00:05.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.000Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + + vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); + vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( + 0, + ); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + if (targetStatCalls === 1) { + const error = new Error("busy target") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalStat(...args); + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + accountIdSource: "token", + email: "b@example.com", + refreshToken: "refresh-b", + accessToken: "access-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); + + expect(targetStatCalls).toBe(2); + expect(preview.status).toBe("ready"); + expect(preview.summary.selectionChanged).toBe(true); + } finally { + statSpy.mockRestore(); + } + }); + + it("records a changed manual sync only after the caller commits persistence", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.changed).toBe(true); + expect(result.pendingRun).not.toBeNull(); + expect(result.storage?.accounts).toHaveLength(2); + expect(getLastCodexCliSyncRun()).toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.outcome).toBe("changed"); + expect(lastRun?.sourcePath).toBe(accountsPath); + expect(lastRun?.summary.addedAccountCount).toBe(1); + expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); + }); + + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); + } finally { + loadSpy.mockRestore(); + } + }); + + it("preserves explicit per-family selections when Codex CLI updates the global selection", async () => { + const alternateFamily = MODEL_FAMILIES.find((family) => family !== "codex"); + expect(alternateFamily).toBeDefined(); + if (!alternateFamily) { + return; + } + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_c", + email: "c@example.com", + refreshToken: "refresh-c", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + [alternateFamily]: 2, + }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + forceRefresh: true, + }); + + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + expect(result.storage?.activeIndexByFamily?.[alternateFamily]).toBe(2); + }); + + it("forces a fresh Codex CLI state read on apply when forceRefresh is omitted", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + await previewCodexCliSync(current, { forceRefresh: true }); + + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "access-c", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await applyCodexCliSyncToStorage(current); + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + "acc_c", + ]); + expect(result.storage?.activeIndex).toBe(2); + } finally { + loadSpy.mockRestore(); + } + }); + + it("returns isolated pending runs for concurrent apply attempts", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const [first, second] = await Promise.all([ + applyCodexCliSyncToStorage(current), + applyCodexCliSyncToStorage(current), + ]); + + expect(first.changed).toBe(true); + expect(second.changed).toBe(true); + expect(first.pendingRun).not.toBeNull(); + expect(second.pendingRun).not.toBeNull(); + expect(first.pendingRun?.revision).not.toBe(second.pendingRun?.revision); + expect(first.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(second.storage?.accounts.map((account) => account.accountId)).toEqual([ + "acc_a", + "acc_b", + ]); + expect(getLastCodexCliSyncRun()).toBeNull(); + }); + + it("records a manual sync save failure over a pending changed run", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.pendingRun).not.toBeNull(); + + commitCodexCliSyncRunFailure(result.pendingRun, new Error("save busy")); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.outcome).toBe("error"); + expect(lastRun?.message).toBe("save busy"); + expect(lastRun?.summary.addedAccountCount).toBe(1); + }); + + it("publishes the completion that finishes last even when it started earlier", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + const firstSourceState = { + path: accountsPath, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + isActive: true, + }, + ], + }; + const secondSourceState = { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + ], + }; + + const first = await applyCodexCliSyncToStorage(current, { + sourceState: firstSourceState, + }); + const second = await applyCodexCliSyncToStorage(current, { + sourceState: secondSourceState, + }); + + expect(first.pendingRun).not.toBeNull(); + expect(second.pendingRun).not.toBeNull(); + + commitCodexCliSyncRunFailure(second.pendingRun, new Error("later run failed")); + expect(getLastCodexCliSyncRun()?.outcome).toBe("error"); + + commitPendingCodexCliSyncRun(first.pendingRun); + + expect(getLastCodexCliSyncRun()).toEqual( + expect.objectContaining({ + outcome: "changed", + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: expect.objectContaining({ + addedAccountCount: 1, + }), + }), + ); + }); + + it("ignores a duplicate sync-run publish for the same revision", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current); + expect(result.pendingRun).not.toBeNull(); + + commitPendingCodexCliSyncRun(result.pendingRun); + const committedRun = getLastCodexCliSyncRun(); + + commitCodexCliSyncRunFailure( + result.pendingRun, + new Error("should not overwrite committed run"), + ); + + expect(getLastCodexCliSyncRun()).toEqual(committedRun); + expect(getLastCodexCliSyncRun()?.outcome).toBe("changed"); + }); + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 96406cbb..cb35b147 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -24,8 +24,27 @@ const loadDashboardDisplaySettingsMock = vi.fn(); const saveDashboardDisplaySettingsMock = vi.fn(); const loadQuotaCacheMock = vi.fn(); const saveQuotaCacheMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); +const previewCodexCliSyncMock = vi.fn(); +const applyCodexCliSyncToStorageMock = vi.fn(); +const commitPendingCodexCliSyncRunMock = vi.fn(); +const commitCodexCliSyncRunFailureMock = vi.fn(); +const formatRollbackPathsMock = vi.fn((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, +]); +const getLastCodexCliSyncRunMock = vi.fn(); +const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); +const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); +const getCodexCliConfigPathMock = vi.fn(() => "/mock/codex/config.toml"); +const clearCodexCliStateCacheMock = vi.fn(); +const isCodexCliSyncEnabledMock = vi.fn(() => true); +const loadCodexCliStateMock = vi.fn(); +const getLastLiveAccountSyncSnapshotMock = vi.fn(); const selectMock = vi.fn(); const deleteSavedAccountsMock = vi.fn(); const resetLocalStateMock = vi.fn(); @@ -144,6 +163,28 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); +vi.mock("../lib/codex-cli/sync.js", () => ({ + applyCodexCliSyncToStorage: applyCodexCliSyncToStorageMock, + commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, + commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, + formatRollbackPaths: formatRollbackPathsMock, + getLastCodexCliSyncRun: getLastCodexCliSyncRunMock, + previewCodexCliSync: previewCodexCliSyncMock, +})); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, + getCodexCliAccountsPath: getCodexCliAccountsPathMock, + getCodexCliAuthPath: getCodexCliAuthPathMock, + getCodexCliConfigPath: getCodexCliConfigPathMock, + isCodexCliSyncEnabled: isCodexCliSyncEnabledMock, + loadCodexCliState: loadCodexCliStateMock, +})); + +vi.mock("../lib/live-account-sync.js", () => ({ + getLastLiveAccountSyncSnapshot: getLastLiveAccountSyncSnapshotMock, +})); + vi.mock("../lib/quota-probe.js", () => ({ fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, formatQuotaSnapshotLine: vi.fn(() => "probe-ok"), @@ -177,6 +218,7 @@ vi.mock("../lib/config.js", async () => { }); vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, loadQuotaCache: loadQuotaCacheMock, saveQuotaCache: saveQuotaCacheMock, })); @@ -367,6 +409,7 @@ type SettingsHubMenuItem = { const SETTINGS_HUB_MENU_ORDER = [ "account-list", + "sync-center", "summary-fields", "behavior", "theme", @@ -533,9 +576,37 @@ describe("codex manager cli commands", () => { saveDashboardDisplaySettingsMock.mockReset(); loadQuotaCacheMock.mockReset(); saveQuotaCacheMock.mockReset(); + clearQuotaCacheMock.mockReset(); loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); + previewCodexCliSyncMock.mockReset(); + applyCodexCliSyncToStorageMock.mockReset(); + commitPendingCodexCliSyncRunMock.mockReset(); + commitCodexCliSyncRunFailureMock.mockReset(); + formatRollbackPathsMock.mockReset(); + formatRollbackPathsMock.mockImplementation((targetPath: string) => [ + `${targetPath}.bak`, + `${targetPath}.bak.1`, + `${targetPath}.bak.2`, + `${targetPath}.wal`, + ]); + getLastCodexCliSyncRunMock.mockReset(); + getCodexCliAccountsPathMock.mockReset(); + getCodexCliAuthPathMock.mockReset(); + getCodexCliConfigPathMock.mockReset(); + isCodexCliSyncEnabledMock.mockReset(); + loadCodexCliStateMock.mockReset(); + clearCodexCliStateCacheMock.mockReset(); + getLastLiveAccountSyncSnapshotMock.mockReset(); selectMock.mockReset(); + planOcChatgptSyncMock.mockReset(); + applyOcChatgptSyncMock.mockReset(); + runNamedBackupExportMock.mockReset(); + exportNamedBackupMock.mockReset(); + promptQuestionMock.mockReset(); + detectOcChatgptMultiAuthTargetMock.mockReset(); + normalizeAccountStorageMock.mockReset(); + normalizeAccountStorageMock.mockImplementation((value) => value); deleteSavedAccountsMock.mockReset(); resetLocalStateMock.mockReset(); deleteAccountAtIndexMock.mockReset(); @@ -662,6 +733,51 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + getLastCodexCliSyncRunMock.mockReturnValue(null); + previewCodexCliSyncMock.mockResolvedValue({ + status: "unavailable", + statusDetail: "No Codex CLI sync source was found.", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: [ + "/mock/openai-codex-accounts.json.bak", + "/mock/openai-codex-accounts.json.wal", + ], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValue({ + changed: false, + storage: null, + pendingRun: null, + }); + getCodexCliAccountsPathMock.mockReturnValue("/mock/codex/accounts.json"); + getCodexCliAuthPathMock.mockReturnValue("/mock/codex/auth.json"); + getCodexCliConfigPathMock.mockReturnValue("/mock/codex/config.toml"); + isCodexCliSyncEnabledMock.mockReturnValue(true); + loadCodexCliStateMock.mockResolvedValue(null); + getLastLiveAccountSyncSnapshotMock.mockReturnValue({ + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }); selectMock.mockResolvedValue(undefined); createAuthorizationFlowMock.mockResolvedValue({ pkce: { verifier: "test-verifier" }, @@ -678,6 +794,23 @@ describe("codex manager cli commands", () => { waitForCode: vi.fn(), close: vi.fn(), }); + planOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-none", + detection: { kind: "none", reason: "No oc-chatgpt target found." }, + }); + applyOcChatgptSyncMock.mockResolvedValue({ + kind: "blocked-none", + detection: { kind: "none", reason: "No oc-chatgpt target found." }, + }); + runNamedBackupExportMock.mockResolvedValue({ + kind: "exported", + path: "/mock/backups/demo.json", + }); + promptQuestionMock.mockResolvedValue("demo"); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "none", + reason: "No oc-chatgpt target found.", + }); restoreTTYDescriptors(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); @@ -5112,6 +5245,1002 @@ describe("codex manager cli commands", () => { expect(selectSequence.remaining()).toBe(0); expect(runNamedBackupExportMock).not.toHaveBeenCalled(); }); + + it("honors the disabled sync-center apply hotkey", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + let selectCall = 0; + selectMock.mockImplementation(async (_items, options) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const onInput = ( + options as { onInput?: (raw: string) => unknown } | undefined + )?.onInput; + expect(onInput?.("a")).toBeUndefined(); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("keeps sync-center recoverable when its initial preview load fails", async () => { + setInteractiveTTY(true); + const storage = createSettingsStorage(Date.now()); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock.mockResolvedValue({ + status: "error", + statusDetail: "busy", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); + }); + + it("keeps sync-center recoverable when refresh preview rebuild fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "error", + statusDetail: "busy", + sourcePath: null, + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "refresh" }; + if (selectCall === 3) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Status: error"); + expect(text).toContain("busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("applies sync-center writes with storage backups disabled when configured", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + loadPluginConfigMock.mockReturnValue({ storageBackupEnabled: false }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: false, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 2, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ backupEnabled: false }), + ); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("rebuilds the sync-center preview from reloaded disk storage after apply", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + const syncedStorage = { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }; + const persistedStorage = { + ...syncedStorage, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + }; + + loadAccountsMock.mockImplementation(async () => + saveAccountsMock.mock.calls.length > 0 ? persistedStorage : storage, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: 1, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: syncedStorage, + pendingRun: { + revision: 6, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: true, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(saveAccountsMock).toHaveBeenCalledWith( + syncedStorage, + expect.objectContaining({ backupEnabled: true }), + ); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).toBe(persistedStorage); + expect(previewCodexCliSyncMock.mock.calls[1]?.[0]).not.toBe(syncedStorage); + }); + + it("retries transient sync-center save failures before committing the sync run", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 4, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + const secondSaveStarted = createDeferred(); + const secondSaveFinished = createDeferred(); + saveAccountsMock + .mockRejectedValueOnce(makeErrnoError("busy", "EBUSY")) + .mockImplementationOnce(async () => { + secondSaveStarted.resolve(undefined); + await secondSaveFinished.promise; + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await secondSaveStarted.promise; + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + secondSaveFinished.resolve(undefined); + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("retries transient sync-center apply-time reads before running the sync", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + let failNextApplyRead = false; + loadAccountsMock.mockImplementation(async () => { + if (failNextApplyRead) { + failNextApplyRead = false; + throw makeErrnoError("busy", "EBUSY"); + } + return structuredClone(storage); + }); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 5, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) { + failNextApplyRead = true; + return { type: "apply" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(loadAccountsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("retries transient sync-center apply-time reconcile failures before saving", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const storage = createSettingsStorage(now); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock + .mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }) + .mockResolvedValueOnce({ + status: "noop", + statusDetail: "Target already matches the current one-way sync result.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock + .mockRejectedValueOnce(makeErrnoError("busy", "EBUSY")) + .mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 7, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + + let selectCall = 0; + selectMock.mockImplementation(async () => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledTimes(2); + expect(applyCodexCliSyncToStorageMock).toHaveBeenNthCalledWith( + 1, + storage, + expect.objectContaining({ sourceState }), + ); + expect(applyCodexCliSyncToStorageMock).toHaveBeenNthCalledWith( + 2, + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(commitPendingCodexCliSyncRunMock).toHaveBeenCalledTimes(1); + expect(commitCodexCliSyncRunFailureMock).not.toHaveBeenCalled(); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(2); + }); + + it("surfaces sync-center save failures distinctly from reconcile failures", async () => { + setInteractiveTTY(true); + const now = Date.now(); + const sourceState = { + path: "/mock/codex/accounts.json", + accounts: [ + { + accountId: "acc_codex", + email: "codex@example.com", + accessToken: "access-codex", + refreshToken: "refresh-codex", + }, + ], + }; + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync@example.com", + accountId: "acc_sync", + refreshToken: "refresh-sync", + accessToken: "access-sync", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadCodexCliStateMock.mockResolvedValue(sourceState); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + previewCodexCliSyncMock.mockResolvedValueOnce({ + status: "ready", + statusDetail: "Preview ready.", + sourcePath: "/mock/codex/accounts.json", + sourceAccountCount: null, + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + targetPath: "/mock/openai-codex-accounts.json", + rollbackPaths: ["/mock/openai-codex-accounts.json.bak"], + }, + lastSync: null, + }); + applyCodexCliSyncToStorageMock.mockResolvedValueOnce({ + changed: true, + storage: { + ...storage, + accounts: [ + ...storage.accounts, + { + email: "codex@example.com", + accountId: "acc_codex", + refreshToken: "refresh-codex", + addedAt: now, + lastUsed: now, + }, + ], + }, + pendingRun: { + revision: 3, + run: { + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + }, + }, + }); + getLastCodexCliSyncRunMock.mockReturnValue({ + outcome: "error", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 1, + selectionChanged: false, + }, + message: "busy", + }); + saveAccountsMock.mockRejectedValue(makeErrnoError("busy", "EBUSY")); + + let selectCall = 0; + selectMock.mockImplementation(async (items) => { + selectCall += 1; + if (selectCall === 1) return { type: "sync-center" }; + if (selectCall === 2) return { type: "apply" }; + if (selectCall === 3) { + const text = (items as Array<{ label?: string; hint?: string }>) + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(text).toContain("Failed to save synced storage: busy"); + return { type: "back" }; + } + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(applyCodexCliSyncToStorageMock).toHaveBeenCalledWith( + storage, + expect.objectContaining({ sourceState }), + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(4); + expect(commitPendingCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(commitCodexCliSyncRunFailureMock).toHaveBeenCalledTimes(1); + expect(previewCodexCliSyncMock).toHaveBeenCalledTimes(1); + }); + it("drives current settings panels through representative hotkeys and persists each section", async () => { const now = Date.now(); setupInteractiveSettingsLogin( @@ -5822,31 +6951,6 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); - deleteAccountAtIndexMock.mockResolvedValueOnce({ - storage: { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "first@example.com", - refreshToken: "refresh-first", - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - ], - }, - flagged: { version: 1, accounts: [] }, - removedAccount: { - refreshToken: "refresh-second", - addedAt: now - 1_000, - lastUsed: now - 1_000, - accountIdSource: undefined, - enabled: true, - }, - removedFlaggedCount: 0, - }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index fa51e52b..7a619f01 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "node:fs"; -import { join } from "node:path"; import { tmpdir } from "node:os"; -import { LiveAccountSync } from "../lib/live-account-sync.js"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetLastLiveAccountSyncSnapshotForTests, + getLastLiveAccountSyncSnapshot, + LiveAccountSync, +} from "../lib/live-account-sync.js"; + +const RETRYABLE_REMOVE_CODES = new Set([ + "EBUSY", + "EPERM", + "ENOTEMPTY", + "EACCES", + "ETIMEDOUT", +]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} describe("live-account-sync", () => { let workDir = ""; @@ -11,23 +58,153 @@ describe("live-account-sync", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-26T12:00:00.000Z")); - workDir = join(tmpdir(), `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`); + __resetLastLiveAccountSyncSnapshotForTests(); + workDir = join( + tmpdir(), + `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); storagePath = join(workDir, "openai-codex-accounts.json"); await fs.mkdir(workDir, { recursive: true }); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); }); afterEach(async () => { vi.useRealTimers(); - await fs.rm(workDir, { recursive: true, force: true }); + __resetLastLiveAccountSyncSnapshotForTests(); + await removeWithRetry(workDir, { recursive: true, force: true }); + }); + + it("publishes watcher state for sync-center status surfaces", async () => { + const reload = vi.fn(async () => undefined); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + expect(getLastLiveAccountSyncSnapshot().running).toBe(false); + await sync.syncToPath(storagePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + sync.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + }); + + it("keeps the newest watcher snapshot published when older instances stop later", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-secondary.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const first = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + const second = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + await first.syncToPath(storagePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + await second.syncToPath(secondStoragePath); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + first.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + }), + ); + + second.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: false, + }), + ); + }); + + it("falls back to the still-running older watcher when a newer watcher stops", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-tertiary.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const first = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + const second = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + + await first.syncToPath(storagePath); + await second.syncToPath(secondStoragePath); + + second.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: true, + }), + ); + + first.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: false, + }), + ); }); it("reloads when file changes are detected by polling", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "a" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "a" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 1_000); await fs.utimes(storagePath, bumped, bumped); @@ -44,10 +221,21 @@ describe("live-account-sync", () => { const reload = vi.fn(async () => { throw new Error("reload failed"); }); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "b" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "b" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 2_000); await fs.utimes(storagePath, bumped, bumped); @@ -61,11 +249,22 @@ describe("live-account-sync", () => { it("stops watching cleanly and prevents further reloads", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); sync.stop(); - await fs.writeFile(storagePath, JSON.stringify({ version: 3, activeIndex: 0, accounts: [{ refreshToken: "c" }] }), "utf-8"); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "c" }], + }), + "utf-8", + ); const bumped = new Date(Date.now() + 3_000); await fs.utimes(storagePath, bumped, bumped); @@ -77,11 +276,16 @@ describe("live-account-sync", () => { it("counts poll errors when stat throws non-retryable errors", async () => { const reload = vi.fn(async () => undefined); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); await sync.syncToPath(storagePath); const statSpy = vi.spyOn(fs, "stat"); - statSpy.mockRejectedValueOnce(Object.assign(new Error("disk fault"), { code: "EIO" })); + statSpy.mockRejectedValueOnce( + Object.assign(new Error("disk fault"), { code: "EIO" }), + ); await vi.advanceTimersByTimeAsync(600); @@ -90,25 +294,308 @@ describe("live-account-sync", () => { sync.stop(); }); - it("coalesces overlapping reload attempts into a single in-flight reload", async () => { - let resolveReload: (() => void) | undefined; - const reloadStarted = new Promise((resolve) => { - resolveReload = resolve; + it("runs a follow-up reload when writes land during an in-flight reload", async () => { + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + let reloadCalls = 0; + const reload = vi.fn(async () => { + reloadCalls += 1; + if (reloadCalls === 1) { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + return; + } + secondReloadStarted.resolve(undefined); + }); + const sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, }); - const reload = vi.fn(async () => reloadStarted); - const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50 }); await sync.syncToPath(storagePath); - const runReload = Reflect.get(sync, "runReload") as (reason: "watch" | "poll") => Promise; + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; const invoke = (reason: "watch" | "poll") => - Reflect.apply(runReload as (...args: unknown[]) => unknown, sync as object, [reason]) as Promise; + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; const first = invoke("poll"); + await firstReloadStarted.promise; + + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "during-first-reload-a" }], + }), + "utf-8", + ); + const firstBump = new Date(Date.now() + 1_000); + await fs.utimes(storagePath, firstBump, firstBump); const second = invoke("watch"); - resolveReload?.(); - await Promise.all([first, second]); + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "during-first-reload-b" }], + }), + "utf-8", + ); + const secondBump = new Date(Date.now() + 2_000); + await fs.utimes(storagePath, secondBump, secondBump); + const third = invoke("watch"); + + await vi.advanceTimersByTimeAsync(0); expect(reload).toHaveBeenCalledTimes(1); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + await Promise.all([first, second, third]); + + expect(reload).toHaveBeenCalledTimes(2); + expect(sync.getSnapshot().reloadCount).toBe(2); sync.stop(); }); -}); + it("drops stale reload completions after switching to a new path", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-second.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + const secondReloadFinished = createDeferred(); + const seenPaths: string[] = []; + let reloadCall = 0; + let sync: LiveAccountSync; + + const reload = vi.fn(async () => { + reloadCall += 1; + const currentPath = Reflect.get(sync, "currentPath") as string | null; + seenPaths.push(currentPath ?? ""); + if (reloadCall === 1) { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + return; + } + secondReloadStarted.resolve(undefined); + await secondReloadFinished.promise; + }); + + sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; + const invoke = (reason: "watch" | "poll") => + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; + + const first = invoke("poll"); + await firstReloadStarted.promise; + + await sync.syncToPath(secondStoragePath); + const second = invoke("watch"); + await vi.advanceTimersByTimeAsync(0); + expect(reload).toHaveBeenCalledTimes(1); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 0, + errorCount: 0, + }), + ); + + secondReloadFinished.resolve(undefined); + await Promise.all([first, second]); + + expect(seenPaths).toEqual([storagePath, secondStoragePath]); + expect(reload).toHaveBeenCalledTimes(2); + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + + sync.stop(); + }); + + it("keeps the prior path published when stop aborts a path switch mid-start", async () => { + const secondStoragePath = join( + workDir, + "openai-codex-accounts-aborted-switch.json", + ); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const sync = new LiveAccountSync(async () => undefined, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const originalStat = fs.stat; + const secondStatStarted = createDeferred(); + const releaseSecondStat = createDeferred(); + const statSpy = vi + .spyOn(fs, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === secondStoragePath) { + secondStatStarted.resolve(undefined); + await releaseSecondStat.promise; + } + return originalStat(...args); + }); + + try { + const pendingSwitch = sync.syncToPath(secondStoragePath); + await secondStatStarted.promise; + + sync.stop(); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + + releaseSecondStat.resolve(undefined); + await pendingSwitch; + + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + expect(getLastLiveAccountSyncSnapshot()).toEqual( + expect.objectContaining({ + path: storagePath, + running: false, + }), + ); + } finally { + statSpy.mockRestore(); + } + }); + + it("waits for the prior path reload before counting the next path as synced", async () => { + const secondStoragePath = join(workDir, "openai-codex-accounts-third.json"); + await fs.writeFile( + secondStoragePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const firstReloadStarted = createDeferred(); + const firstReloadFinished = createDeferred(); + const secondReloadStarted = createDeferred(); + const secondReloadFinished = createDeferred(); + const startedReloadPaths: string[] = []; + let sharedReloadPromise: Promise | null = null; + let sync: LiveAccountSync; + + const reload = vi.fn(async () => { + if (sharedReloadPromise) { + return sharedReloadPromise; + } + const currentPath = Reflect.get(sync, "currentPath") as string | null; + startedReloadPaths.push(currentPath ?? ""); + if (startedReloadPaths.length === 1) { + sharedReloadPromise = (async () => { + firstReloadStarted.resolve(undefined); + await firstReloadFinished.promise; + sharedReloadPromise = null; + })(); + return sharedReloadPromise; + } + sharedReloadPromise = (async () => { + secondReloadStarted.resolve(undefined); + await secondReloadFinished.promise; + sharedReloadPromise = null; + })(); + return sharedReloadPromise; + }); + + sync = new LiveAccountSync(reload, { + pollIntervalMs: 500, + debounceMs: 50, + }); + await sync.syncToPath(storagePath); + + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; + const invoke = (reason: "watch" | "poll") => + Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + [reason], + ) as Promise; + + const first = invoke("poll"); + await firstReloadStarted.promise; + + await sync.syncToPath(secondStoragePath); + const second = invoke("watch"); + + await vi.advanceTimersByTimeAsync(0); + expect(startedReloadPaths).toEqual([storagePath]); + expect(sync.getSnapshot().reloadCount).toBe(0); + + firstReloadFinished.resolve(undefined); + await secondReloadStarted.promise; + expect(startedReloadPaths).toEqual([storagePath, secondStoragePath]); + expect(sync.getSnapshot().reloadCount).toBe(0); + + secondReloadFinished.resolve(undefined); + await Promise.all([first, second]); + + expect(sync.getSnapshot()).toEqual( + expect.objectContaining({ + path: secondStoragePath, + running: true, + reloadCount: 1, + errorCount: 0, + }), + ); + + sync.stop(); + }); +}); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 2c56244b..e983221b 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -16,6 +16,45 @@ import { import type { MenuItem } from "../lib/ui/select.js"; type SettingsHubTestApi = { + buildSyncCenterOverview: ( + preview: { + status: "ready" | "noop" | "disabled" | "unavailable" | "error"; + statusDetail: string; + sourcePath: string | null; + targetPath: string; + summary: { + addedAccountCount: number; + updatedAccountCount: number; + destinationOnlyPreservedCount: number; + targetAccountCountAfter: number; + selectionChanged: boolean; + }; + backup: { + enabled: boolean; + rollbackPaths: string[]; + }; + lastSync: { + outcome: "changed" | "noop" | "disabled" | "unavailable" | "error"; + runAt: number; + message?: string; + } | null; + }, + context?: { + accountsPath: string; + authPath: string; + configPath: string; + sourceAccountCount: number | null; + liveSync: { + path: string | null; + running: boolean; + lastKnownMtimeMs: number | null; + lastSyncAt: number | null; + reloadCount: number; + errorCount: number; + }; + syncEnabled: boolean; + }, + ) => Array<{ label: string; hint?: string }>; clampBackendNumber: (settingKey: string, value: number) => number; formatMenuLayoutMode: (mode: "compact-details" | "expanded-rows") => string; cloneDashboardSettings: ( @@ -71,6 +110,7 @@ type SettingsHubTestApi = { promptExperimentalSettings: ( initial: PluginConfig, ) => Promise; + promptSyncCenter: (config: PluginConfig) => Promise; }; type UiRuntimeOptions = ReturnType; @@ -205,6 +245,176 @@ describe("settings-hub utility coverage", () => { ); }); + it("builds sync-center overview text with preservation and rollback details", async () => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: "ready", + statusDetail: "Preview ready", + sourcePath: "/tmp/source/accounts.json", + targetPath: "/tmp/target/openai-codex-accounts.json", + summary: { + addedAccountCount: 1, + updatedAccountCount: 2, + destinationOnlyPreservedCount: 3, + targetAccountCountAfter: 6, + selectionChanged: true, + }, + backup: { + enabled: true, + rollbackPaths: [ + "/tmp/target/openai-codex-accounts.json.bak", + "/tmp/target/openai-codex-accounts.json.wal", + ], + }, + lastSync: { + outcome: "changed", + runAt: Date.parse("2026-03-01T00:00:00.000Z"), + }, + }, + { + accountsPath: "/tmp/source/accounts.json", + authPath: "/tmp/source/auth.json", + configPath: "/tmp/source/config.toml", + sourceAccountCount: 1, + liveSync: { + path: "/tmp/target/openai-codex-accounts.json", + running: true, + lastKnownMtimeMs: Date.parse("2026-03-01T00:01:00.000Z"), + lastSyncAt: Date.parse("2026-03-01T00:02:00.000Z"), + reloadCount: 2, + errorCount: 0, + }, + syncEnabled: true, + }, + ); + + expect(overview[0]?.label).toContain("Status: ready"); + expect(overview[1]?.hint).toContain("/tmp/source/accounts.json"); + expect(overview[2]?.hint).toContain("/tmp/source/auth.json"); + expect(overview[2]?.hint).toContain("/tmp/source/config.toml"); + expect(overview[3]?.label).toContain("Live watcher: running"); + expect(overview[4]?.label).toContain("Preview mode: read-only until apply"); + expect(overview[4]?.hint).toContain("latest preview snapshot"); + expect(overview[4]?.hint).toContain("refresh before apply"); + expect(overview[5]?.label).toContain( + "add 1 | update 2 | preserve 3 | after 6", + ); + expect(overview[6]?.hint).toContain("activeAccountId first"); + expect(overview[7]?.hint).toContain("never deletes"); + expect(overview[8]?.hint).toContain(".bak"); + expect(overview[8]?.hint).toContain(".wal"); + }); + + it.each([ + { + outcome: "disabled" as const, + statusLabel: "Status: disabled", + lastSyncLabel: "Last sync: disabled", + }, + { + outcome: "unavailable" as const, + statusLabel: "Status: unavailable", + lastSyncLabel: "Last sync: source missing", + }, + { + outcome: "error" as const, + statusLabel: "Status: error", + lastSyncLabel: "Last sync: error: save busy", + message: "save busy", + }, + ])( + "formats sync-center overview status text for $outcome last-sync outcomes", + async ({ outcome, statusLabel, lastSyncLabel, message }) => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: outcome, + statusDetail: `Status ${outcome}`, + sourcePath: null, + targetPath: "/tmp/target/openai-codex-accounts.json", + summary: { + addedAccountCount: 0, + updatedAccountCount: 0, + destinationOnlyPreservedCount: 0, + targetAccountCountAfter: 0, + selectionChanged: false, + }, + backup: { + enabled: false, + rollbackPaths: [], + }, + lastSync: { + outcome, + runAt: Date.parse("2026-03-01T00:00:00.000Z"), + message, + }, + }, + { + accountsPath: "/tmp/source/accounts.json", + authPath: "/tmp/source/auth.json", + configPath: "/tmp/source/config.toml", + sourceAccountCount: null, + liveSync: { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }, + syncEnabled: outcome !== "disabled", + }, + ); + + expect(overview[0]?.label).toContain(statusLabel); + expect(overview[0]?.hint).toContain(lastSyncLabel); + }, + ); + + it("matches windows-style source paths when labeling the active sync source", async () => { + const api = await loadSettingsHubTestApi(); + const overview = api.buildSyncCenterOverview( + { + status: "ready", + statusDetail: "Preview ready", + sourcePath: "C:\\Users\\Neil\\.codex\\Accounts.json", + targetPath: "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json", + summary: { + addedAccountCount: 0, + updatedAccountCount: 0, + destinationOnlyPreservedCount: 1, + targetAccountCountAfter: 1, + selectionChanged: false, + }, + backup: { + enabled: true, + rollbackPaths: [ + "C:\\Users\\Neil\\.codex\\openai-codex-accounts.json.bak", + ], + }, + lastSync: null, + }, + { + accountsPath: "c:/users/neil/.codex/accounts.json", + authPath: "c:/users/neil/.codex/auth.json", + configPath: "c:/users/neil/.codex/config.toml", + sourceAccountCount: 1, + liveSync: { + path: null, + running: false, + lastKnownMtimeMs: null, + lastSyncAt: null, + reloadCount: 0, + errorCount: 0, + }, + syncEnabled: true, + }, + ); + + expect(overview[2]?.label).toContain("accounts.json active"); + }); + it("formats layout mode labels", async () => { const api = await loadSettingsHubTestApi(); expect(api.formatMenuLayoutMode("expanded-rows")).toBe("Expanded Rows"); @@ -285,6 +495,128 @@ describe("settings-hub utility coverage", () => { expect(attempts).toBe(3); }); + it("retries sync-center preview loading when loadAccounts hits a retryable lock", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + let loadAttempts = 0; + const loadAccountsSpy = vi + .spyOn(storageModule, "loadAccounts") + .mockImplementation(async () => { + loadAttempts += 1; + if (loadAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + }); + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "/tmp/source/accounts.json", + accounts: [], + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + expect(loadStateSpy).toHaveBeenCalledTimes(1); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + + it("retries sync-center preview loading when loadCodexCliState hits a retryable lock", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + const loadAccountsSpy = vi.spyOn(storageModule, "loadAccounts").mockResolvedValue({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + let loadStateAttempts = 0; + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockImplementation(async () => { + loadStateAttempts += 1; + if (loadStateAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return { + path: "/tmp/source/accounts.json", + accounts: [], + }; + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + expect(loadStateSpy).toHaveBeenCalledTimes(2); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + + it("retries sync-center preview loading when loadCodexCliState returns 429 once", async () => { + const api = await loadSettingsHubTestApi(); + const storageModule = await import("../lib/storage.js"); + const codexCliState = await import("../lib/codex-cli/state.js"); + const loadAccountsSpy = vi.spyOn(storageModule, "loadAccounts").mockResolvedValue({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const retryAfterMs = 1; + let loadStateAttempts = 0; + const loadStateSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockImplementation(async () => { + loadStateAttempts += 1; + if (loadStateAttempts === 1) { + const error = new Error("rate limited") as Error & { + status: number; + retryAfterMs: number; + }; + error.status = 429; + error.retryAfterMs = retryAfterMs; + throw error; + } + return { + path: "/tmp/source/accounts.json", + accounts: [], + }; + }); + + queueSelectResults({ type: "back" }); + + try { + await api.promptSyncCenter({}); + + expect(loadStateSpy).toHaveBeenCalledTimes(2); + expect(loadAccountsSpy).toHaveBeenCalledTimes(2); + } finally { + loadAccountsSpy.mockRestore(); + loadStateSpy.mockRestore(); + } + }); + it("propagates non-retryable filesystem errors immediately", async () => { const api = await loadSettingsHubTestApi(); let attempts = 0; diff --git a/test/storage.test.ts b/test/storage.test.ts index 33bd8c33..140fba46 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -338,9 +338,6 @@ describe("storage", () => { }); it("should export accounts to a file", async () => { - // @ts-expect-error - exportAccounts doesn't exist yet - const { exportAccounts } = await import("../lib/storage.js"); - const storage = { version: 3, activeIndex: 0, @@ -360,8 +357,6 @@ describe("storage", () => { }); it("should fail export if file exists and force is false", async () => { - // @ts-expect-error - const { exportAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "exists"); // @ts-expect-error @@ -399,9 +394,6 @@ describe("storage", () => { }); it("should import accounts from a file and merge", async () => { - // @ts-expect-error - const { importAccounts } = await import("../lib/storage.js"); - const existing = { version: 3, activeIndex: 0, @@ -435,7 +427,6 @@ describe("storage", () => { }); it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { - const { importAccounts } = await import("../lib/storage.js"); const existing = { version: 3, activeIndex: 1, @@ -491,7 +482,6 @@ describe("storage", () => { }); it("should preserve distinct accountId plus email pairs during import", async () => { - const { importAccounts } = await import("../lib/storage.js"); await saveAccounts({ version: 3, activeIndex: 0, @@ -539,7 +529,6 @@ describe("storage", () => { }); it("should preserve duplicate shared accountId entries when imported rows lack email", async () => { - const { importAccounts } = await import("../lib/storage.js"); await saveAccounts({ version: 3, activeIndex: 0, @@ -932,9 +921,6 @@ describe("storage", () => { }); it("should enforce MAX_ACCOUNTS during import", async () => { - // @ts-expect-error - const { importAccounts } = await import("../lib/storage.js"); - const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ accountId: `acct${i}`, refreshToken: `ref${i}`, @@ -956,7 +942,6 @@ describe("storage", () => { }); it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); const isolatedStorageDir = join( testWorkDir, "empty-export-" + Math.random().toString(36).slice(2), @@ -971,7 +956,6 @@ describe("storage", () => { }); it("should fail import when file does not exist", async () => { - const { importAccounts } = await import("../lib/storage.js"); const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); await expect(importAccounts(nonexistentPath)).rejects.toThrow( /Import file not found/, @@ -979,13 +963,11 @@ describe("storage", () => { }); it("should fail import when file contains invalid JSON", async () => { - const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); }); it("should fail import when file contains invalid format", async () => { - const { importAccounts } = await import("../lib/storage.js"); await fs.writeFile(exportPath, JSON.stringify({ invalid: "format" })); await expect(importAccounts(exportPath)).rejects.toThrow( /Invalid account storage format/, From 37bdb349e081d69e8761e7efb522e3cdc91fba94 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:22:01 +0800 Subject: [PATCH 05/76] fix(auth): clean startup recovery prompt branch --- docs/reference/commands.md | 2 + docs/reference/storage-paths.md | 4 + lib/codex-manager.ts | 37 +++--- lib/storage.ts | 28 ++++- test/cli.test.ts | 10 ++ test/codex-manager-cli.test.ts | 181 ++++++++++++++++++++++++++++ test/storage-recovery-paths.test.ts | 6 + test/storage.test.ts | 93 +++++++++++++- 8 files changed, 334 insertions(+), 27 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..f7a9cf0a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -111,6 +112,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index cf0747de..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,6 +109,10 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index f4a4d5b8..0cb5b5ea 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -64,6 +64,7 @@ import { import { assessNamedBackupRestore, getActionableNamedBackupRestores, + getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, listNamedBackups, NAMED_BACKUP_LIST_CONCURRENCY, @@ -328,6 +329,7 @@ function printUsage(): void { " codex auth best [--live] [--json] [--model ]", " codex auth check", " codex auth features", + " codex auth restore-backup", " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", " codex auth forecast [--live] [--json] [--model ]", " codex auth report [--live] [--json] [--model ] [--out ]", @@ -4048,6 +4050,17 @@ async function runAuthLogin(): Promise { console.log("Cancelled."); return 0; } + const modeRequiresDrainedQuotaRefresh = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix"; + if (modeRequiresDrainedQuotaRefresh) { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + } if (menuResult.mode === "check") { await runActionPanel("Quick Check", "Checking local session + live status", async () => { await runHealthCheck({ forceRefresh: false, liveProbe: true }); @@ -4731,18 +4744,7 @@ type BackupRestoreAssessment = Awaited< ReturnType >; -type BackupRestoreManagerResult = "restored" | "dismissed"; - -function getRedactedFilesystemErrorLabel(error: unknown): string { - const code = (error as NodeJS.ErrnoException).code; - if (typeof code === "string" && code.trim().length > 0) { - return code; - } - if (error instanceof Error && error.name && error.name !== "Error") { - return error.name; - } - return "UNKNOWN"; -} +type BackupRestoreManagerResult = "restored" | "dismissed" | "failed"; async function loadBackupRestoreManagerAssessments(): Promise< BackupRestoreAssessment[] @@ -4869,11 +4871,11 @@ async function runBackupRestoreManager( console.warn( `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, ); - return "dismissed"; + return "failed"; } if (!latestAssessment.eligibleForRestore) { console.log(latestAssessment.error ?? "Backup is not eligible for restore."); - return "dismissed"; + return "failed"; } const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; @@ -4891,7 +4893,7 @@ async function runBackupRestoreManager( console.warn( `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, ); - return "dismissed"; + return "failed"; } } @@ -4924,8 +4926,9 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runAuthLogin(); } if (command === "restore-backup") { - await runBackupRestoreManager(startupDisplaySettings); - return 0; + return (await runBackupRestoreManager(startupDisplaySettings)) === "failed" + ? 1 + : 0; } if (command === "list" || command === "status") { await showAccountStatus(); diff --git a/lib/storage.ts b/lib/storage.ts index 8424a888..dbbe11fb 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -176,7 +176,7 @@ function createUnloadedBackupCandidate(): LoadedBackupCandidate { }; } -function getBackupRestoreAssessmentErrorLabel(error: unknown): string { +export function getRedactedFilesystemErrorLabel(error: unknown): string { const code = (error as NodeJS.ErrnoException).code; if (typeof code === "string" && code.trim().length > 0) { return code; @@ -200,10 +200,16 @@ function buildFailedBackupRestoreAssessment( skipped: null, wouldExceedLimit: false, eligibleForRestore: false, - error: getBackupRestoreAssessmentErrorLabel(error), + error: getRedactedFilesystemErrorLabel(error), }; } +function normalizeBackupUpdatedAt(updatedAt: number | null | undefined): number { + return typeof updatedAt === "number" && Number.isFinite(updatedAt) && updatedAt !== 0 + ? updatedAt + : 0; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -1697,7 +1703,8 @@ async function scanNamedBackups(): Promise { return { backups: backups.sort( (left, right) => - (right.backup.updatedAt ?? 0) - (left.backup.updatedAt ?? 0), + normalizeBackupUpdatedAt(right.backup.updatedAt) - + normalizeBackupUpdatedAt(left.backup.updatedAt), ), totalBackups, }; @@ -1748,7 +1755,11 @@ async function listNamedBackupsWithoutLoading(): Promise (right.updatedAt ?? 0) - (left.updatedAt ?? 0)), + backups: backups.sort( + (left, right) => + normalizeBackupUpdatedAt(right.updatedAt) - + normalizeBackupUpdatedAt(left.updatedAt), + ), totalBackups, }; } catch (error) { @@ -1771,7 +1782,10 @@ export async function listNamedBackups(): Promise { function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" { - return code === "EPERM" || code === "EBUSY" || code === "EAGAIN"; + if (code === "EBUSY" || code === "EAGAIN") { + return true; + } + return code === "EPERM" && process.platform === "win32"; } async function retryTransientFilesystemOperation( @@ -1785,7 +1799,9 @@ async function retryTransientFilesystemOperation( if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { throw error; } - await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + await new Promise((resolve) => + setTimeout(resolve, 10 * 2 ** attempt + Math.floor(Math.random() * 10)), + ); } } diff --git a/test/cli.test.ts b/test/cli.test.ts index 269a0eba..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,6 +716,16 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index cb35b147..84186c51 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1042,6 +1042,61 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -7079,6 +7134,132 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before starting quick check", async () => { + const now = Date.now(); + const menuStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + refreshToken: "refresh-alpha", + addedAt: now, + lastUsed: now, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + refreshToken: "refresh-beta", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }; + const quickCheckStorage = { + ...menuStorage, + accounts: [menuStorage.accounts[0]!], + }; + let loadAccountsCalls = 0; + loadAccountsMock.mockImplementation(async () => { + loadAccountsCalls += 1; + return structuredClone( + loadAccountsCalls === 1 ? menuStorage : quickCheckStorage, + ); + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + let currentQuotaCache: { + byAccountId: Record; + byEmail: Record; + } = { + byAccountId: {}, + byEmail: {}, + }; + loadQuotaCacheMock.mockImplementation(async () => + structuredClone(currentQuotaCache), + ); + saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => { + currentQuotaCache = structuredClone(value); + }); + const firstFetchStarted = createDeferred(); + const secondFetchStarted = createDeferred(); + const releaseFirstFetch = createDeferred(); + const releaseSecondFetch = createDeferred(); + let secondFetchObserved = false; + let fetchCallCount = 0; + fetchCodexQuotaSnapshotMock.mockImplementation( + async (input: { accountId: string }) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } else if (fetchCallCount === 2) { + secondFetchStarted.resolve(input.accountId); + await releaseSecondFetch.promise; + } + return { + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }; + }, + ); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "check" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + void secondFetchStarted.promise.then(() => { + secondFetchObserved = true; + }); + + await firstFetchStarted.promise; + await Promise.resolve(); + + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + expect(secondFetchObserved).toBe(false); + + releaseFirstFetch.resolve(); + + const secondAccountId = await secondFetchStarted.promise; + expect(secondAccountId).toBe("acc-alpha"); + + releaseSecondFetch.resolve(); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(Object.keys(currentQuotaCache.byEmail)).toEqual(["alpha@example.com"]); + } finally { + logSpy.mockRestore(); + } + }); + it("waits for an in-flight menu quota refresh before resetting local state", async () => { const now = Date.now(); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 264bf494..08032032 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -735,6 +735,12 @@ describe("storage recovery paths", () => { }), "utf-8", ); + const manualCheckpointMtime = new Date(Date.now() + 5_000); + await fs.utimes( + `${storagePath}.manual-meta-checkpoint`, + manualCheckpointMtime, + manualCheckpointMtime, + ); const metadata = await getBackupMetadata(); const accountSnapshots = metadata.accounts.snapshots; diff --git a/test/storage.test.ts b/test/storage.test.ts index 140fba46..a3d73657 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -949,10 +949,26 @@ describe("storage", () => { const isolatedStoragePath = join(isolatedStorageDir, "accounts.json"); const isolatedExportPath = join(isolatedStorageDir, "export.json"); await fs.mkdir(isolatedStorageDir, { recursive: true }); - setStoragePathDirect(isolatedStoragePath); - await expect(exportAccounts(isolatedExportPath)).rejects.toThrow( - /No accounts to export/, - ); + vi.resetModules(); + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(isolatedStoragePath); + try { + await fs.writeFile( + isolatedStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }), + ); + await expect( + isolatedStorageModule.exportAccounts(isolatedExportPath), + ).rejects.toThrow(/No accounts to export/); + } finally { + isolatedStorageModule.setStoragePathDirect(null); + vi.resetModules(); + } }); it("should fail import when file does not exist", async () => { @@ -1412,6 +1428,24 @@ describe("storage", () => { } }); + it("rethrows unreadable backup directory errors after one attempt on non-Windows platforms", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + expect(readdirSpy).toHaveBeenCalledTimes(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("rethrows unreadable backup directory errors while restoring backups", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; @@ -1631,6 +1665,57 @@ describe("storage", () => { } }); + it("sorts backups with invalid timestamps after finite timestamps", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "valid-backup", + refreshToken: "ref-valid-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const validBackup = await createNamedBackup("valid-backup"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "nan-backup", + refreshToken: "ref-nan-backup", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + const nanBackup = await createNamedBackup("nan-backup"); + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => { + const [path] = args; + const stats = await originalStat(...(args as Parameters)); + if (String(path) === nanBackup.path) { + return { + ...stats, + mtimeMs: Number.NaN, + } as Awaited>; + } + return stats; + }); + + try { + const backups = await listNamedBackups(); + expect(backups.map((backup) => backup.name)).toEqual([ + validBackup.name, + nanBackup.name, + ]); + } finally { + statSpy.mockRestore(); + } + }); + it("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; for (let index = 0; index < 12; index += 1) { From 8a769841cef69f20702818c6a863491da99a9f72 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 00:25:27 +0800 Subject: [PATCH 06/76] feat(settings): productize stable and advanced controls --- docs/reference/settings.md | 32 +++++++++++------- lib/cli.ts | 8 +++++ lib/codex-manager/settings-hub.ts | 25 ++++++++++---- lib/ui/copy.ts | 55 +++++++++++++++++++------------ test/codex-manager-cli.test.ts | 42 +++++++++++++++++++++-- test/documentation.test.ts | 50 +++++++++++++++++++++------- 6 files changed, 159 insertions(+), 53 deletions(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index aaede2c3..dacce7d4 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1,6 +1,6 @@ # Settings Reference -Reference for dashboard and backend settings available from `codex auth login` -> `Settings`. +Reference for the settings surface available from `codex auth login` -> `Settings`. --- @@ -19,7 +19,11 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly. --- -## Account List View +## Everyday Settings + +The top-level settings flow separates everyday dashboard preferences from advanced operator controls. + +### List Appearance Controls account-row display and sorting behavior: @@ -37,7 +41,7 @@ Controls account-row display and sorting behavior: - `menuSortQuickSwitchVisibleRow` - `menuLayoutMode` -## Summary Line +### Details Line Controls detail-line fields and order: @@ -46,7 +50,7 @@ Controls detail-line fields and order: - `limits` - `status` -## Menu Behavior +### Results & Refresh Controls result-screen and fetch behavior: @@ -56,7 +60,7 @@ Controls result-screen and fetch behavior: - `menuShowFetchStatus` - `menuQuotaTtlMs` -## Color Theme +### Colors Controls display style: @@ -64,9 +68,13 @@ Controls display style: - `uiAccentColor` - `menuFocusStyle` -## Sync Center +--- + +## Advanced and Operator Controls + +### Codex CLI Sync -The settings hub includes a preview-first sync center for Codex CLI account sync. +The advanced section includes a preview-first sync center for Codex CLI account sync. See [upgrade notes](../upgrade.md) for sync workflow changes. Before applying sync, it shows: @@ -84,9 +92,7 @@ Validation: - `npm run build` - `npm test` ---- - -## Experimental +### Experimental Experimental currently hosts: @@ -108,7 +114,11 @@ Named backup behavior: - rejects separators, traversal (`..`), `.rotate.`, `.tmp`, and `.wal` suffixes - fails safely on collisions instead of overwriting by default -## Backend Controls +### Advanced Backend Controls + +Expert backend controls stay available without changing the saved settings schema. They are grouped into categories so the default path can stay simpler for day-to-day use. + +## Backend Categories ### Session & Sync diff --git a/lib/cli.ts b/lib/cli.ts index b0a81b35..e52aa00b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -225,6 +225,14 @@ async function promptLoginModeFallback( } return { mode: "reset" }; } + if ( + normalized === "u" || + normalized === "restore" || + normalized === "backup" || + normalized === "restore-backup" + ) { + return { mode: "restore-backup" }; + } if (normalized === "c" || normalized === "check") return { mode: "check" }; if (normalized === "d" || normalized === "deep") { diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 0c1fb28f..5af5a386 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -3294,40 +3294,51 @@ async function promptSettingsHub( }, { label: UI_COPY.settings.accountList, + hint: UI_COPY.settings.accountListHint, value: { type: "account-list" }, color: "green", }, - { - label: UI_COPY.settings.syncCenter, - value: { type: "sync-center" }, - color: "green", - }, { label: UI_COPY.settings.summaryFields, + hint: UI_COPY.settings.summaryFieldsHint, value: { type: "summary-fields" }, color: "green", }, { label: UI_COPY.settings.behavior, + hint: UI_COPY.settings.behaviorHint, value: { type: "behavior" }, color: "green", }, - { label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" }, + { + label: UI_COPY.settings.theme, + hint: UI_COPY.settings.themeHint, + value: { type: "theme" }, + color: "green", + }, { label: "", value: { type: "back" }, separator: true }, { label: UI_COPY.settings.advancedTitle, value: { type: "back" }, kind: "heading", }, + { + label: UI_COPY.settings.syncCenter, + hint: UI_COPY.settings.syncCenterHint, + value: { type: "sync-center" }, + color: "yellow", + }, { label: UI_COPY.settings.experimental, + hint: UI_COPY.settings.experimentalHint, value: { type: "experimental" }, color: "yellow", }, { label: UI_COPY.settings.backend, + hint: UI_COPY.settings.backendHint, value: { type: "backend" }, - color: "green", + color: "yellow", }, { label: "", value: { type: "back" }, separator: true }, { diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 552258b8..bb42b09b 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -62,17 +62,28 @@ export const UI_COPY = { }, settings: { title: "Settings", - subtitle: "Customize menu, behavior, backend, and experiments", + subtitle: + "Start with everyday dashboard settings. Advanced operator controls stay separate.", help: "↑↓ Move | Enter Select | Q Back", - sectionTitle: "Basic", - advancedTitle: "Advanced", + sectionTitle: "Everyday Settings", + advancedTitle: "Advanced & Operator", exitTitle: "Back", - accountList: "Account List View", - syncCenter: "Sync Center", - summaryFields: "Summary Line", - behavior: "Menu Behavior", - theme: "Color Theme", + accountList: "List Appearance", + accountListHint: + "Show badges, sorting, and how much detail each account row shows.", + syncCenter: "Codex CLI Sync", + syncCenterHint: + "Preview and apply one-way sync from Codex CLI account files.", + summaryFields: "Details Line", + summaryFieldsHint: "Choose which details appear under each account row.", + behavior: "Results & Refresh", + behaviorHint: + "Control auto-return timing and background limit refresh behavior.", + theme: "Colors", + themeHint: "Pick the base palette and accent color.", experimental: "Experimental", + experimentalHint: + "Preview sync and backup actions before they become stable.", experimentalTitle: "Experimental", experimentalSubtitle: "Preview sync and backup actions before they become stable", experimentalHelpMenu: "Enter Select | Q Back", @@ -86,35 +97,37 @@ export const UI_COPY = { experimentalRefreshInterval: "Refresh Guard Interval", experimentalDecreaseInterval: "Decrease Refresh Interval", experimentalIncreaseInterval: "Increase Refresh Interval", - backend: "Backend Controls", + backend: "Advanced Backend Controls", + backendHint: "Tune retry, quota, sync, recovery, and timeout internals.", back: "Back", previewHeading: "Live Preview", displayHeading: "Options", resetDefault: "Reset to Default", saveAndBack: "Save and Back", backNoSave: "Back Without Saving", - accountListTitle: "Account List View", - accountListSubtitle: "Choose row details and optional smart sorting", + accountListTitle: "List Appearance", + accountListSubtitle: "Choose badges, sorting, and row layout", accountListHelp: "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", - summaryTitle: "Account Details Row", - summarySubtitle: "Choose and order detail fields", + summaryTitle: "Details Line", + summarySubtitle: "Choose and order the details shown under each account", summaryHelp: "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", - behaviorTitle: "Return Behavior", - behaviorSubtitle: "Control how result screens return", + behaviorTitle: "Results & Refresh", + behaviorSubtitle: "Control auto-return and limit refresh behavior", behaviorHelp: "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", - themeTitle: "Color Theme", - themeSubtitle: "Pick base color and accent", + themeTitle: "Colors", + themeSubtitle: "Pick the base palette and accent color", themeHelp: "Enter Select | 1-2 Base | S Save | Q Back (No Save)", - backendTitle: "Backend Controls", - backendSubtitle: "Tune sync, retry, and limit behavior", + backendTitle: "Advanced Backend Controls", + backendSubtitle: + "Expert settings for sync, retry, quota, and timeout behavior", backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", - syncCenterTitle: "Sync Center", + syncCenterTitle: "Codex CLI Sync", syncCenterSubtitle: - "Inspect source paths, watcher status, and one-way preview semantics before applying sync", + "Inspect source files and preview one-way sync before applying it", syncCenterHelp: "Enter Select | A Apply | R Refresh | Q Back", syncCenterOverviewHeading: "Sync Overview", syncCenterActionsHeading: "Actions", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 84186c51..02d7ab2e 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -409,10 +409,10 @@ type SettingsHubMenuItem = { const SETTINGS_HUB_MENU_ORDER = [ "account-list", - "sync-center", "summary-fields", "behavior", "theme", + "sync-center", "experimental", "backend", ] as const; @@ -5110,7 +5110,7 @@ describe("codex manager cli commands", () => { ); }); - it("shows experimental settings in the settings hub", async () => { + it("shows productized everyday and advanced settings in the hub", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); queueSettingsSelectSequence([{ type: "back" }]); @@ -5120,6 +5120,44 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); + const firstMenuItems = (selectMock.mock.calls[0]?.[0] ?? []) as Array<{ + label?: string; + hint?: string; + }>; + const firstMenuOptions = selectMock.mock.calls[0]?.[1] as + | { + message?: string; + subtitle?: string; + } + | undefined; + expect(firstMenuOptions?.message).toBe("Settings"); + expect(firstMenuOptions?.subtitle).toContain( + "everyday dashboard settings", + ); + const menuText = firstMenuItems + .map((item) => `${item.label ?? ""}\n${item.hint ?? ""}`) + .join("\n"); + expect(menuText).toContain("Everyday Settings"); + expect(menuText).toContain("List Appearance"); + expect(menuText).toContain("Details Line"); + expect(menuText).toContain("Results & Refresh"); + expect(menuText).toContain("Colors"); + expect(menuText).toContain("Advanced & Operator"); + expect(menuText).toContain("Codex CLI Sync"); + expect(menuText).toContain("Experimental"); + expect(menuText).toContain("Advanced Backend Controls"); + expect(menuText).toContain( + "Show badges, sorting, and how much detail each account row shows.", + ); + expect(menuText).toContain( + "Preview and apply one-way sync from Codex CLI account files.", + ); + expect(menuText).toContain( + "Preview sync and backup actions before they become stable.", + ); + expect(menuText).toContain( + "Tune retry, quota, sync, recovery, and timeout internals.", + ); }); it("runs experimental oc sync with mandatory preview before apply", async () => { diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..eb06a930 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -288,15 +288,37 @@ describe("Documentation Integrity", () => { it("locks the current Experimental settings menu labels and help text", () => { expect(UI_COPY.settings.title).toBe("Settings"); expect(UI_COPY.settings.subtitle).toBe( - "Customize menu, behavior, backend, and experiments", + "Start with everyday dashboard settings. Advanced operator controls stay separate.", ); expect(UI_COPY.settings.help).toBe("↑↓ Move | Enter Select | Q Back"); - expect(UI_COPY.settings.accountList).toBe("Account List View"); - expect(UI_COPY.settings.summaryFields).toBe("Summary Line"); - expect(UI_COPY.settings.behavior).toBe("Menu Behavior"); - expect(UI_COPY.settings.theme).toBe("Color Theme"); + expect(UI_COPY.settings.accountList).toBe("List Appearance"); + expect(UI_COPY.settings.accountListHint).toBe( + "Show badges, sorting, and how much detail each account row shows.", + ); + expect(UI_COPY.settings.syncCenter).toBe("Codex CLI Sync"); + expect(UI_COPY.settings.syncCenterHint).toBe( + "Preview and apply one-way sync from Codex CLI account files.", + ); + expect(UI_COPY.settings.summaryFields).toBe("Details Line"); + expect(UI_COPY.settings.summaryFieldsHint).toBe( + "Choose which details appear under each account row.", + ); + expect(UI_COPY.settings.behavior).toBe("Results & Refresh"); + expect(UI_COPY.settings.behaviorHint).toBe( + "Control auto-return timing and background limit refresh behavior.", + ); + expect(UI_COPY.settings.theme).toBe("Colors"); + expect(UI_COPY.settings.themeHint).toBe( + "Pick the base palette and accent color.", + ); expect(UI_COPY.settings.experimental).toBe("Experimental"); - expect(UI_COPY.settings.backend).toBe("Backend Controls"); + expect(UI_COPY.settings.experimentalHint).toBe( + "Preview sync and backup actions before they become stable.", + ); + expect(UI_COPY.settings.backend).toBe("Advanced Backend Controls"); + expect(UI_COPY.settings.backendHint).toBe( + "Tune retry, quota, sync, recovery, and timeout internals.", + ); expect(UI_COPY.settings.accountListHelp).toBe( "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", ); @@ -317,12 +339,16 @@ describe("Documentation Integrity", () => { it("keeps settings reference sections aligned with current menu labels and backend categories", () => { const settingsRef = read("docs/reference/settings.md"); - expect(settingsRef).toContain(`## ${UI_COPY.settings.accountList}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.summaryFields}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.behavior}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.theme}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.experimental}`); - expect(settingsRef).toContain(`## ${UI_COPY.settings.backend}`); + expect(settingsRef).toContain("## Everyday Settings"); + expect(settingsRef).toContain(`### ${UI_COPY.settings.accountList}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.summaryFields}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.behavior}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.theme}`); + expect(settingsRef).toContain("## Advanced and Operator Controls"); + expect(settingsRef).toContain(`### ${UI_COPY.settings.syncCenter}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.experimental}`); + expect(settingsRef).toContain(`### ${UI_COPY.settings.backend}`); + expect(settingsRef).toContain("## Backend Categories"); expect(settingsRef).toContain("### Session & Sync"); expect(settingsRef).toContain("### Rotation & Quota"); expect(settingsRef).toContain("### Refresh & Recovery"); From c52e84fb5c3ef819edef72c34b6fad1faccd53c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 08:31:41 +0800 Subject: [PATCH 07/76] fix(auth): drop recovery fallback from settings slice --- lib/cli.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index e52aa00b..b0a81b35 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -225,14 +225,6 @@ async function promptLoginModeFallback( } return { mode: "reset" }; } - if ( - normalized === "u" || - normalized === "restore" || - normalized === "backup" || - normalized === "restore-backup" - ) { - return { mode: "restore-backup" }; - } if (normalized === "c" || normalized === "check") return { mode: "check" }; if (normalized === "d" || normalized === "deep") { From 8a519282c21a6f75a093224eab0d7d1be13576e5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 00:33:35 +0800 Subject: [PATCH 08/76] docs(auth): align front-door flows with shipped workflows --- README.md | 11 +++++++++-- docs/README.md | 12 ++++++------ docs/features.md | 5 +++++ docs/getting-started.md | 23 +++++++++++++++++++++++ docs/index.md | 10 +++++++++- docs/reference/commands.md | 22 ++++++++++++++++------ docs/reference/settings.md | 16 +++++++++++++--- docs/troubleshooting.md | 33 ++++++++++++++++++++++++++++++++- 8 files changed, 113 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0ede89bd..54fec5cb 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Codex CLI-first multi-account OAuth manager for the official `@openai/codex` CLI - Canonical `codex auth ...` workflow for account login, switching, checks, and diagnostics - Multi-account OAuth pool with health-aware selection and automatic failover - Project-scoped account storage under `~/.codex/multi-auth/projects//...` -- Interactive dashboard for account actions and settings -- Experimental settings tab for staged sync, backup, and refresh-guard controls +- Interactive dashboard for login, restore, switching, sync preview, and settings +- Productized settings split across `Everyday Settings`, `Codex CLI Sync`, `Experimental`, and `Advanced & Operator` - Forecast, report, fix, and doctor commands for operational safety - Flagged account verification and restore flow - Session affinity and live account sync controls @@ -112,6 +112,12 @@ codex auth fix --dry-run codex auth doctor --fix ``` +Interactive dashboard paths: + +- restore named backups: `codex auth login` -> `Restore From Backup` +- preview Codex CLI sync: `codex auth login` -> `Settings` -> `Codex CLI Sync` +- adjust stable dashboard preferences: `codex auth login` -> `Settings` -> `Everyday Settings` + --- ## Command Toolkit @@ -237,6 +243,7 @@ codex auth login - `codex auth` unrecognized: run `where codex`, then follow `docs/troubleshooting.md` for routing fallback commands - Switch succeeds but wrong account appears active: run `codex auth switch `, then restart session - OAuth callback on port `1455` fails: free the port and re-run `codex auth login` +- Interactive login skipped restore and went straight to OAuth: place named backups in `~/.codex/multi-auth/backups/`, then rerun `codex auth login` in a normal TTY - `missing field id_token` / `token_expired` / `refresh_token_reused`: re-login affected account diff --git a/docs/README.md b/docs/README.md index 926e5675..67aea9fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,13 +17,13 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [index.md](index.md) | Daily-use landing page for common `codex auth ...` workflows | -| [getting-started.md](getting-started.md) | Install, first login, and first health check | +| [index.md](index.md) | Daily-use landing page for login, restore, sync, and diagnostics workflows | +| [getting-started.md](getting-started.md) | Install, first login, startup restore prompt, and first health check | | [faq.md](faq.md) | Short answers to common adoption questions | | [architecture.md](architecture.md) | Public system overview of the wrapper, storage, and optional plugin runtime | -| [features.md](features.md) | User-facing capability map | +| [features.md](features.md) | User-facing capability map, including backup restore, sync center, and settings split | | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | -| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | +| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, restore, sync, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | | [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | @@ -40,8 +40,8 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [reference/commands.md](reference/commands.md) | Commands, flags, and hotkeys | -| [reference/settings.md](reference/settings.md) | Dashboard and runtime settings | +| [reference/commands.md](reference/commands.md) | Commands, flags, hotkeys, and interactive entry points | +| [reference/settings.md](reference/settings.md) | Everyday settings, sync center, and advanced operator controls | | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | diff --git a/docs/features.md b/docs/features.md index e88c6342..39df4112 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,6 +9,8 @@ User-facing capability map for `codex-multi-auth`. | Capability | What it gives you | Primary entry | | --- | --- | --- | | Multi-account dashboard login | Add and manage multiple OAuth identities from one terminal flow | `codex auth login` | +| Startup recovery prompt | Offer restore before OAuth when recoverable named backups are found and no active accounts exist | `codex auth login` | +| Backup restore manager | Review named backups, merge with dedupe, and skip invalid or over-limit restores | `codex auth login` -> `Restore From Backup` | | Account dedupe and identity normalization | Avoid duplicate saved account rows | login flow | | Explicit active-account switching | Pick the current account by index instead of relying on hidden state | `codex auth switch ` | | Fast and deep health checks | See whether the current pool is usable before a coding session | `codex auth check` | @@ -32,6 +34,7 @@ User-facing capability map for `codex-multi-auth`. | --- | --- | --- | | Safe repair workflow | Detects and repairs known local storage inconsistencies | `codex auth fix` | | Diagnostics with optional repair | One command to inspect and optionally fix common failures | `codex auth doctor` | +| JSON diagnostics pack | Machine-readable state for support, bug reports, and deeper inspection | `codex auth report --live --json` | | Backup and WAL recovery | Safer persistence when local writes are interrupted or partially applied | storage runtime | --- @@ -53,6 +56,8 @@ User-facing capability map for `codex-multi-auth`. | --- | --- | | Quick switch and search hotkeys | Faster navigation in the dashboard | | Account action hotkeys | Per-account set, refresh, toggle, and delete shortcuts | +| Productized settings split | Keeps `Everyday Settings` separate from `Advanced & Operator` controls | +| Preview-first sync center | Shows one-way Codex CLI sync results and rollback context before apply | | In-dashboard settings hub | Runtime and display tuning without editing files directly | | Browser-first OAuth with manual fallback | Works in normal and constrained terminal environments | diff --git a/docs/getting-started.md b/docs/getting-started.md index 45337911..8caf3fb4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,6 +72,29 @@ codex auth forecast --live --- +## Restore Or Start Fresh + +Use the restore path when you already have named backup files and want to recover account state before creating new OAuth sessions. + +- Automatic path: run `codex auth login`, then confirm the startup restore prompt when it appears +- Manual path: run `codex auth login`, then choose `Restore From Backup` +- Backup location: `~/.codex/multi-auth/backups/.json` + +The restore manager shows each backup name, account count, freshness, and whether the restore would exceed the account limit before it lets you apply anything. + +--- + +## Sync And Settings + +The settings flow is split into two productized sections: + +- `Everyday Settings` for list appearance, details line, results and refresh behavior, and colors +- `Advanced & Operator` for `Codex CLI Sync`, `Experimental`, and backend tuning + +Use `Codex CLI Sync` when you want to preview one-way sync from official Codex CLI account files before applying it. The sync screen shows source and target paths, preview summary, destination-only preservation, and backup rollback paths before apply. + +--- + ## Day-1 Command Pack ```bash diff --git a/docs/index.md b/docs/index.md index fb4074f8..ec09eff4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # codex-multi-auth Docs -Daily-use guide for the `codex auth ...` workflow. +Daily-use guide for the `codex auth ...` workflow, including restore, sync, and diagnostics. --- @@ -12,6 +12,8 @@ codex auth list codex auth check ``` +If login detects recoverable named backups before OAuth, confirm the prompt to open `Restore From Backup` first. + If you are choosing an account for the next session: ```bash @@ -39,6 +41,12 @@ codex auth report --live --json codex auth doctor --fix ``` +Interactive workflows that ship in the dashboard: + +- backup restore: `codex auth login` -> `Restore From Backup` +- sync preview and apply: `codex auth login` -> `Settings` -> `Codex CLI Sync` +- settings split: `codex auth login` -> `Settings` -> `Everyday Settings` or `Advanced & Operator` + --- ## Canonical Policy diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f7a9cf0a..ab142c2b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -20,7 +20,7 @@ Compatibility aliases are supported: | Command | Description | | --- | --- | -| `codex auth login` | Open interactive auth dashboard | +| `codex auth login` | Open interactive auth dashboard, including login, restore, settings, and diagnostics entry points | | `codex auth list` | List saved accounts and active account | | `codex auth status` | Print short runtime/account summary | | `codex auth switch ` | Set active account by index | @@ -91,16 +91,26 @@ Compatibility aliases are supported: Settings screen hotkeys are panel-specific: -- Account List View: `Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)` -- Summary Line: `Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)` -- Menu Behavior: `Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)` -- Color Theme: `Enter Select | 1-2 Base | S Save | Q Back (No Save)` -- Backend Controls: `Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)` +- List Appearance: `Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)` +- Details Line: `Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)` +- Results & Refresh: `Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)` +- Colors: `Enter Select | 1-2 Base | S Save | Q Back (No Save)` +- Advanced Backend Controls: `Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)` --- ## Workflow Packs +Interactive dashboard workflows: + +- Backup restore: `codex auth login` -> `Restore From Backup` +- Startup recovery prompt: interactive `codex auth login` TTY flow only, then confirm restore when recoverable named backups are found before OAuth +- Sync preview and apply: `codex auth login` -> `Settings` -> `Codex CLI Sync` +- Stable settings path: `codex auth login` -> `Settings` -> `Everyday Settings` +- Advanced settings path: `codex auth login` -> `Settings` -> `Advanced & Operator` + +--- + Health and planning: ```bash diff --git a/docs/reference/settings.md b/docs/reference/settings.md index dacce7d4..0c9c802f 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -21,7 +21,7 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly. ## Everyday Settings -The top-level settings flow separates everyday dashboard preferences from advanced operator controls. +The shipped settings menu starts with `Everyday Settings` and keeps the stable dashboard path separate from advanced operator controls. This is the default path for most users. ### List Appearance @@ -72,9 +72,11 @@ Controls display style: ## Advanced and Operator Controls +The second top-level section is `Advanced & Operator`. It holds the sync workflow and backend tuning that are useful when you need to inspect or change lower-level behavior. + ### Codex CLI Sync -The advanced section includes a preview-first sync center for Codex CLI account sync. +`Codex CLI Sync` is a preview-first sync center for Codex CLI account sync. See [upgrade notes](../upgrade.md) for sync workflow changes. Before applying sync, it shows: @@ -86,6 +88,13 @@ Before applying sync, it shows: - destination-only preservation behavior - backup and rollback context (`.bak`, `.bak.1`, `.bak.2`, `.wal`) +Workflow notes: + +- refresh recomputes the read-only preview from Codex CLI source files +- apply writes the preview result into the target path +- sync is one-way, it is not a bidirectional merge +- target-only accounts are preserved rather than deleted + Validation: - `npm run typecheck` @@ -116,7 +125,7 @@ Named backup behavior: ### Advanced Backend Controls -Expert backend controls stay available without changing the saved settings schema. They are grouped into categories so the default path can stay simpler for day-to-day use. +`Advanced Backend Controls` stay available without changing the saved settings schema. They are grouped into categories so the everyday path can stay simpler for day-to-day use. ## Backend Categories @@ -208,6 +217,7 @@ For most environments: - smart sort enabled - auto-fetch limits enabled +- storage backups enabled when you want rollback context for sync and recovery flows - live sync enabled - session affinity enabled - preemptive quota deferral enabled diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b31aa95c..1595eea7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # Troubleshooting -Recovery guide for install, login, switching, worktree storage, and stale local auth state. +Recovery guide for install, login, backup restore, sync preview, worktree storage, and stale local auth state. --- @@ -20,6 +20,8 @@ codex auth login If `codex auth login` starts with no saved accounts and recoverable named backups are present, you will be prompted to restore before OAuth. This prompt only appears in interactive terminals and is skipped after same-session fresh/reset flows. +If you want to inspect backup options yourself instead of taking the prompt immediately, open `codex auth login` and choose `Restore From Backup`. + --- ## Verify Install And Routing @@ -57,6 +59,17 @@ npm i -g codex-multi-auth --- +## Backup Restore Problems + +| Symptom | Likely cause | Action | +| --- | --- | --- | +| You expected a restore prompt but went straight to OAuth | No recoverable named backups were found, the terminal is non-interactive, or the flow is skipping restore after an intentional reset | Put named backup files in `~/.codex/multi-auth/backups/`, then rerun `codex auth login` in an interactive terminal | +| `Restore From Backup` says no backups were found | The named backup directory is empty or the files are elsewhere | Place backup files in `~/.codex/multi-auth/backups/` and retry | +| A backup is listed but cannot be selected | The backup is invalid or would exceed the account limit | Trim current accounts first or choose a different backup | +| Restore succeeded but some rows were skipped | Deduping kept the existing matching account state | Run `codex auth list` and `codex auth check` to review the merged result | + +--- + ## Switching And State Problems | Symptom | Likely cause | Action | @@ -67,6 +80,19 @@ npm i -g codex-multi-auth --- +## Codex CLI Sync Problems + +Use `codex auth login` -> `Settings` -> `Codex CLI Sync` when you want to inspect sync state before applying it. + +| Symptom | Likely cause | Action | +| --- | --- | --- | +| Sync preview looks one-way | This is the shipped behavior | Review the preview, then apply only if the target result is what you want | +| A target-only account would be lost | The sync center preserves destination-only accounts instead of deleting them | Recheck the preview summary before apply | +| You want rollback context before syncing | Backup support is disabled in current settings | Enable storage backups in advanced settings, then refresh the sync preview | +| Active selection does not match expectation | Preview kept the newer local choice or updated from Codex CLI based on selection precedence | Refresh preview and review the selection summary before apply | + +--- + ## Worktrees And Project Storage | Symptom | Likely cause | Action | @@ -89,6 +115,11 @@ codex auth report --live --json codex auth doctor --json ``` +Interactive diagnostics path: + +- `codex auth login` -> `Settings` -> `Codex CLI Sync` for preview-based sync diagnostics +- `codex auth login` -> `Settings` -> `Advanced Backend Controls` for sync, retry, quota, recovery, and timeout tuning + --- ## Reset Options From 587117d1fc727eb6f5e4189cb5281f1232cf36bd Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 00:54:38 +0800 Subject: [PATCH 09/76] feat(sync): add result history and last-result log --- lib/codex-cli/sync.ts | 45 +++++- lib/index.ts | 1 + lib/live-account-sync.ts | 39 +++++ lib/sync-history.ts | 261 +++++++++++++++++++++++++++++++++ test/codex-cli-sync.test.ts | 20 +++ test/live-account-sync.test.ts | 32 ++++ 6 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 lib/sync-history.ts diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index a5eff517..a87a9a6f 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -21,6 +21,12 @@ import { loadCodexCliState, } from "./state.js"; import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; +import { + appendSyncHistoryEntry, + cloneSyncHistoryEntry, + readLatestSyncHistorySync, + readSyncHistory, +} from "../sync-history.js"; const log = createLogger("codex-cli-sync"); const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); @@ -114,6 +120,7 @@ let lastCodexCliSyncRun: CodexCliSyncRun | null = null; let lastCodexCliSyncRunRevision = 0; let nextCodexCliSyncRunRevision = 0; const activePendingCodexCliSyncRunRevisions = new Set(); +let lastCodexCliSyncHistoryLoadAttempted = false; function createEmptySyncSummary(): CodexCliSyncSummary { return { @@ -166,9 +173,25 @@ function publishCodexCliSyncRun( } lastCodexCliSyncRunRevision = revision; lastCodexCliSyncRun = cloneCodexCliSyncRun(run); + void appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: run.runAt, + run: cloneCodexCliSyncRun(run), + }).catch((error) => { + log.debug("Failed to record codex-cli sync history", { + error: error instanceof Error ? error.message : String(error), + }); + }); return true; } +function hydrateLastCodexCliSyncRunFromHistory( + run: CodexCliSyncRun, +): CodexCliSyncRun { + lastCodexCliSyncRun = cloneCodexCliSyncRun(run); + return cloneCodexCliSyncRun(run); +} + function buildSyncRunError( run: CodexCliSyncRun, error: unknown, @@ -208,7 +231,26 @@ async function resolveCodexCliSyncState(options: { } export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { - return lastCodexCliSyncRun ? cloneCodexCliSyncRun(lastCodexCliSyncRun) : null; + if (lastCodexCliSyncRun) { + return cloneCodexCliSyncRun(lastCodexCliSyncRun); + } + if (!lastCodexCliSyncHistoryLoadAttempted) { + lastCodexCliSyncHistoryLoadAttempted = true; + const latest = cloneSyncHistoryEntry(readLatestSyncHistorySync()); + if (latest?.kind === "codex-cli-sync") { + return hydrateLastCodexCliSyncRunFromHistory(latest.run); + } + void readSyncHistory({ kind: "codex-cli-sync", limit: 1 }) + .then((entries) => { + if (lastCodexCliSyncRun) return; + const lastEntry = entries.at(-1); + if (lastEntry?.kind === "codex-cli-sync") { + lastCodexCliSyncRun = cloneCodexCliSyncRun(lastEntry.run); + } + }) + .catch(() => undefined); + } + return null; } export function commitPendingCodexCliSyncRun( @@ -252,6 +294,7 @@ export function __resetLastCodexCliSyncRunForTests(): void { lastCodexCliSyncRunRevision = 0; nextCodexCliSyncRunRevision = 0; activePendingCodexCliSyncRunRevisions.clear(); + lastCodexCliSyncHistoryLoadAttempted = false; } function hasConflictingIdentity( diff --git a/lib/index.ts b/lib/index.ts index 0e259846..a9dc9d26 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -17,6 +17,7 @@ export * from "./table-formatter.js"; export * from "./parallel-probe.js"; export * from "./session-affinity.js"; export * from "./live-account-sync.js"; +export * from "./sync-history.js"; export * from "./refresh-guardian.js"; export * from "./refresh-lease.js"; export * from "./request/failure-policy.js"; diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index b6e9816a..e261ebc5 100644 --- a/lib/live-account-sync.ts +++ b/lib/live-account-sync.ts @@ -1,6 +1,7 @@ import { type FSWatcher, promises as fs, watch as fsWatch } from "node:fs"; import { basename, dirname } from "node:path"; import { createLogger } from "./logger.js"; +import { appendSyncHistoryEntry } from "./sync-history.js"; const log = createLogger("live-account-sync"); @@ -111,6 +112,15 @@ function summarizeWatchPath(path: string | null): string { return basename(path); } +function toHistorySnapshot( + snapshot: LiveAccountSyncSnapshot, +): LiveAccountSyncSnapshot { + return { + ...snapshot, + path: snapshot.path ? summarizeWatchPath(snapshot.path) : null, + }; +} + /** * Watches account storage and triggers a reload callback when file content * changes. Uses fs.watch + polling fallback for Windows reliability. @@ -230,6 +240,29 @@ export class LiveAccountSync { }; } + private async recordHistory( + reason: "watch" | "poll" | "manual", + outcome: "success" | "error", + message?: string, + ): Promise { + const snapshot = toHistorySnapshot(this.getSnapshot()); + try { + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: Date.now(), + reason, + outcome, + path: snapshot.path, + message, + snapshot, + }); + } catch (error) { + log.debug("Failed to record live account sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + private publishSnapshot(): void { const snapshot = this.getSnapshot(); if (snapshot.running) { @@ -312,6 +345,7 @@ export class LiveAccountSync { if (generation !== this.generation || targetPath !== this.currentPath) { return; } + void this.recordHistory(reason, "success"); log.debug("Reloaded account manager from live storage update", { reason, path: summarizeWatchPath(targetPath), @@ -321,6 +355,11 @@ export class LiveAccountSync { return; } this.errorCount += 1; + void this.recordHistory( + reason, + "error", + error instanceof Error ? error.message : String(error), + ); log.warn("Live account sync reload failed", { reason, path: summarizeWatchPath(targetPath), diff --git a/lib/sync-history.ts b/lib/sync-history.ts new file mode 100644 index 00000000..3248e8bf --- /dev/null +++ b/lib/sync-history.ts @@ -0,0 +1,261 @@ +import { promises as fs, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CodexCliSyncRun } from "./codex-cli/sync.js"; +import type { LiveAccountSyncSnapshot } from "./live-account-sync.js"; +import { createLogger } from "./logger.js"; +import { getCodexLogDir } from "./runtime-paths.js"; + +const log = createLogger("sync-history"); + +const HISTORY_FILE_NAME = "sync-history.ndjson"; +const LATEST_FILE_NAME = "sync-history-latest.json"; +const MAX_HISTORY_ENTRIES = 200; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; + +export interface CodexCliSyncHistoryEntry { + kind: "codex-cli-sync"; + recordedAt: number; + run: CodexCliSyncRun; +} + +export interface LiveAccountSyncHistoryEntry { + kind: "live-account-sync"; + recordedAt: number; + reason: "watch" | "poll" | "manual"; + outcome: "success" | "error"; + path: string | null; + message?: string; + snapshot: LiveAccountSyncSnapshot; +} + +export type SyncHistoryEntry = + | CodexCliSyncHistoryEntry + | LiveAccountSyncHistoryEntry; + +interface SyncHistoryPaths { + directory: string; + historyPath: string; + latestPath: string; +} + +let historyDirOverride: string | null = null; +let historyMutex: Promise = Promise.resolve(); +let lastAppendError: string | null = null; +let lastAppendPaths: SyncHistoryPaths | null = null; +const pendingHistoryWrites = new Set>(); + +function getHistoryDirectory(): string { + return historyDirOverride ?? getCodexLogDir(); +} + +export function getSyncHistoryPaths(): SyncHistoryPaths { + const directory = getHistoryDirectory(); + return { + directory, + historyPath: join(directory, HISTORY_FILE_NAME), + latestPath: join(directory, LATEST_FILE_NAME), + }; +} + +function cloneEntry(entry: T): T { + if (!entry) return entry; + return JSON.parse(JSON.stringify(entry)) as T; +} + +function serializeEntry(entry: SyncHistoryEntry): string { + return JSON.stringify(entry); +} + +function withHistoryLock(fn: () => Promise): Promise { + const previous = historyMutex; + let release: () => void = () => {}; + historyMutex = new Promise((resolve) => { + release = resolve; + }); + return previous.then(fn).finally(() => release()); +} + +async function waitForPendingHistoryWrites(): Promise { + if (pendingHistoryWrites.size === 0) return; + await Promise.allSettled(Array.from(pendingHistoryWrites)); +} + +async function ensureHistoryDir(directory: string): Promise { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); +} + +function isSyncHistoryEntry(value: unknown): value is SyncHistoryEntry { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + if ( + candidate.kind !== "codex-cli-sync" && + candidate.kind !== "live-account-sync" + ) { + return false; + } + return typeof candidate.recordedAt === "number"; +} + +function parseEntry(line: string): SyncHistoryEntry | null { + try { + const parsed = JSON.parse(line) as unknown; + return isSyncHistoryEntry(parsed) ? cloneEntry(parsed) : null; + } catch (error) { + log.debug("Failed to parse sync history entry", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { + const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return ""; + } + throw error; + }); + if (!content) { + return; + } + const lines = content.split(/\r?\n/).filter(Boolean); + if (lines.length <= MAX_HISTORY_ENTRIES) { + return; + } + const trimmedContent = `${lines.slice(-MAX_HISTORY_ENTRIES).join("\n")}\n`; + await fs.writeFile(paths.historyPath, trimmedContent, { + encoding: "utf8", + mode: 0o600, + }); +} + +export async function appendSyncHistoryEntry( + entry: SyncHistoryEntry, +): Promise { + const writePromise = withHistoryLock(async () => { + const paths = getSyncHistoryPaths(); + lastAppendPaths = paths; + await ensureHistoryDir(paths.directory); + await fs.appendFile(paths.historyPath, `${serializeEntry(entry)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await trimHistoryFileIfNeeded(paths); + await fs.writeFile(paths.latestPath, `${JSON.stringify(entry, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + lastAppendError = null; + }); + pendingHistoryWrites.add(writePromise); + try { + await writePromise; + } catch (error) { + lastAppendError = error instanceof Error ? error.message : String(error); + log.debug("Failed to append sync history", { + error: lastAppendError, + }); + throw error; + } finally { + pendingHistoryWrites.delete(writePromise); + } +} + +export async function readSyncHistory( + options: { limit?: number; kind?: SyncHistoryKind } = {}, +): Promise { + const { kind, limit } = options; + await waitForPendingHistoryWrites(); + try { + const content = await fs.readFile(getSyncHistoryPaths().historyPath, "utf8"); + const parsed = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => parseEntry(line)) + .filter((entry): entry is SyncHistoryEntry => entry !== null); + const filtered = kind + ? parsed.filter((entry) => entry.kind === kind) + : parsed; + if (typeof limit === "number" && limit > 0) { + return filtered.slice(-limit).map((entry) => cloneEntry(entry)); + } + return filtered.map((entry) => cloneEntry(entry)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.debug("Failed to read sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + return []; + } +} + +export function readLatestSyncHistorySync(): SyncHistoryEntry | null { + try { + const content = readFileSync(getSyncHistoryPaths().latestPath, "utf8"); + const parsed = parseEntry(content); + return parsed ? cloneEntry(parsed) : null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.debug("Failed to read latest sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + return null; + } +} + +export function cloneSyncHistoryEntry( + entry: SyncHistoryEntry | null, +): SyncHistoryEntry | null { + return cloneEntry(entry); +} + +export function configureSyncHistoryForTests(directory: string | null): void { + historyDirOverride = directory ? directory.trim() : null; +} + +export async function __resetSyncHistoryForTests(): Promise { + const paths = getSyncHistoryPaths(); + await waitForPendingHistoryWrites(); + await withHistoryLock(async () => { + for (const target of [paths.historyPath, paths.latestPath]) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(target, { force: true }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !code || + !RETRYABLE_REMOVE_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, 25 * 2 ** attempt), + ); + } + } + } + }); + lastAppendError = null; + lastAppendPaths = null; +} + +export function __getLastSyncHistoryErrorForTests(): string | null { + return lastAppendError; +} + +export function __getLastSyncHistoryPathsForTests(): SyncHistoryPaths | null { + return lastAppendPaths ? { ...lastAppendPaths } : null; +} diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 39ef4c25..c128d8b7 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -17,6 +17,11 @@ import { SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; +import { + __resetSyncHistoryForTests, + configureSyncHistoryForTests, + readSyncHistory, +} from "../lib/sync-history.js"; import * as writerModule from "../lib/codex-cli/writer.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; @@ -68,6 +73,8 @@ describe("codex-cli sync", () => { authPath = join(tempDir, "auth.json"); configPath = join(tempDir, "config.toml"); targetStoragePath = join(tempDir, "openai-codex-accounts.json"); + configureSyncHistoryForTests(join(tempDir, "logs")); + await __resetSyncHistoryForTests(); process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; process.env.CODEX_CLI_AUTH_PATH = authPath; process.env.CODEX_CLI_CONFIG_PATH = configPath; @@ -100,6 +107,8 @@ describe("codex-cli sync", () => { process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = previousEnforceFileStore; } + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); await removeWithRetry(tempDir, { recursive: true, force: true }); }); @@ -1319,6 +1328,17 @@ describe("codex-cli sync", () => { expect(lastRun?.sourcePath).toBe(accountsPath); expect(lastRun?.summary.addedAccountCount).toBe(1); expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const lastHistory = history.at(-1); + expect(lastHistory?.kind).toBe("codex-cli-sync"); + if (lastHistory?.kind === "codex-cli-sync") { + expect(lastHistory.run.outcome).toBe("changed"); + expect(lastHistory.run.summary.addedAccountCount).toBe(1); + } + __resetLastCodexCliSyncRunForTests(); + const persisted = getLastCodexCliSyncRun(); + expect(persisted?.outcome).toBe("changed"); + expect(persisted?.summary.addedAccountCount).toBe(1); }); it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index 7a619f01..28684ee3 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -7,6 +7,7 @@ import { getLastLiveAccountSyncSnapshot, LiveAccountSync, } from "../lib/live-account-sync.js"; +import * as syncHistory from "../lib/sync-history.js"; const RETRYABLE_REMOVE_CODES = new Set([ "EBUSY", @@ -63,6 +64,8 @@ describe("live-account-sync", () => { tmpdir(), `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); + syncHistory.configureSyncHistoryForTests(join(workDir, "logs")); + await syncHistory.__resetSyncHistoryForTests(); storagePath = join(workDir, "openai-codex-accounts.json"); await fs.mkdir(workDir, { recursive: true }); await fs.writeFile( @@ -75,6 +78,8 @@ describe("live-account-sync", () => { afterEach(async () => { vi.useRealTimers(); __resetLastLiveAccountSyncSnapshotForTests(); + await syncHistory.__resetSyncHistoryForTests(); + syncHistory.configureSyncHistoryForTests(null); await removeWithRetry(workDir, { recursive: true, force: true }); }); @@ -221,6 +226,7 @@ describe("live-account-sync", () => { const reload = vi.fn(async () => { throw new Error("reload failed"); }); + const appendSpy = vi.spyOn(syncHistory, "appendSyncHistoryEntry"); const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50, @@ -244,7 +250,25 @@ describe("live-account-sync", () => { const snapshot = sync.getSnapshot(); expect(snapshot.errorCount).toBeGreaterThan(0); expect(snapshot.reloadCount).toBe(0); + expect(appendSpy).toHaveBeenCalled(); + expect(syncHistory.__getLastSyncHistoryErrorForTests()).toBeNull(); + const paths = syncHistory.getSyncHistoryPaths(); + expect(paths.directory).toBe(join(workDir, "logs")); + const lastAppendPaths = syncHistory.__getLastSyncHistoryPathsForTests(); + expect(lastAppendPaths?.directory).toBe(paths.directory); + const history = await syncHistory.readSyncHistory({ + kind: "live-account-sync", + }); + const last = history.at(-1); + expect(last?.kind).toBe("live-account-sync"); + if (last?.kind === "live-account-sync") { + expect(last.outcome).toBe("error"); + expect(["poll", "watch"]).toContain(last.reason); + expect(last.path).toBe("openai-codex-accounts.json"); + expect(last.snapshot.path).toBe("openai-codex-accounts.json"); + } sync.stop(); + appendSpy.mockRestore(); }); it("stops watching cleanly and prevents further reloads", async () => { @@ -361,6 +385,14 @@ describe("live-account-sync", () => { expect(reload).toHaveBeenCalledTimes(2); expect(sync.getSnapshot().reloadCount).toBe(2); + const history = await syncHistory.readSyncHistory({ + kind: "live-account-sync", + }); + const last = history.at(-1); + expect(last?.kind).toBe("live-account-sync"); + if (last?.kind === "live-account-sync") { + expect(last.outcome).toBe("success"); + } sync.stop(); }); From 46553865cf75db605ebe31e98df65f03461fe62d Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:54:17 +0800 Subject: [PATCH 10/76] fix(sync): bound sync history recovery reads --- lib/sync-history.ts | 64 +++++++++++++- test/sync-history.test.ts | 177 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 test/sync-history.test.ts diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 3248e8bf..dcc5ece1 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -112,6 +112,61 @@ function parseEntry(line: string): SyncHistoryEntry | null { } } +async function readHistoryTail( + historyPath: string, + options: { limit: number; kind?: SyncHistoryKind }, +): Promise { + const { kind, limit } = options; + const handle = await fs.open(historyPath, "r"); + try { + const stats = await handle.stat(); + if (stats.size === 0) { + return []; + } + + let position = stats.size; + let remainder = ""; + const chunkSize = 8 * 1024; + const matchesNewestFirst: SyncHistoryEntry[] = []; + + while (position > 0 && matchesNewestFirst.length < limit) { + const start = Math.max(0, position - chunkSize); + const length = position - start; + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + const combined = buffer.toString("utf8", 0, bytesRead) + remainder; + const lines = combined.split("\n"); + remainder = lines.shift() ?? ""; + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) continue; + const entry = parseEntry(line); + if (!entry) continue; + if (kind && entry.kind !== kind) continue; + matchesNewestFirst.push(entry); + if (matchesNewestFirst.length >= limit) { + break; + } + } + + position = start; + } + + const leadingLine = remainder.trim(); + if (matchesNewestFirst.length < limit && leadingLine) { + const entry = parseEntry(leadingLine); + if (entry && (!kind || entry.kind === kind)) { + matchesNewestFirst.push(entry); + } + } + + return matchesNewestFirst.reverse().map((entry) => cloneEntry(entry)); + } finally { + await handle.close(); + } +} + async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { const code = (error as NodeJS.ErrnoException).code; @@ -172,6 +227,12 @@ export async function readSyncHistory( const { kind, limit } = options; await waitForPendingHistoryWrites(); try { + if (typeof limit === "number" && limit > 0) { + return readHistoryTail(getSyncHistoryPaths().historyPath, { + kind, + limit, + }); + } const content = await fs.readFile(getSyncHistoryPaths().historyPath, "utf8"); const parsed = content .split(/\r?\n/) @@ -182,9 +243,6 @@ export async function readSyncHistory( const filtered = kind ? parsed.filter((entry) => entry.kind === kind) : parsed; - if (typeof limit === "number" && limit > 0) { - return filtered.slice(-limit).map((entry) => cloneEntry(entry)); - } return filtered.map((entry) => cloneEntry(entry)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts new file mode 100644 index 00000000..4fcbd14d --- /dev/null +++ b/test/sync-history.test.ts @@ -0,0 +1,177 @@ +import { promises as nodeFs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetLastCodexCliSyncRunForTests, + getLastCodexCliSyncRun, +} from "../lib/codex-cli/sync.js"; +import { + __resetSyncHistoryForTests, + appendSyncHistoryEntry, + configureSyncHistoryForTests, + getSyncHistoryPaths, + readSyncHistory, +} from "../lib/sync-history.js"; + +const RETRYABLE_REMOVE_CODES = new Set([ + "EBUSY", + "EPERM", + "ENOTEMPTY", + "EACCES", + "ETIMEDOUT", +]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await nodeFs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +describe("sync history", () => { + let workDir = ""; + + beforeEach(async () => { + workDir = await nodeFs.mkdtemp(join(tmpdir(), "codex-sync-history-")); + configureSyncHistoryForTests(join(workDir, "logs")); + await __resetSyncHistoryForTests(); + __resetLastCodexCliSyncRunForTests(); + }); + + afterEach(async () => { + __resetLastCodexCliSyncRunForTests(); + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); + await removeWithRetry(workDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("reads the last matching history entry without loading the whole file", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: { + outcome: "noop", + runAt: 1, + sourcePath: "source-1.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + }, + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "openai-codex-accounts.json", + snapshot: { + path: "openai-codex-accounts.json", + running: true, + lastReason: "watch", + lastError: null, + lastSuccessAt: 2, + lastAttemptAt: 2, + reloadCount: 1, + errorCount: 0, + }, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: { + outcome: "changed", + runAt: 3, + sourcePath: "source-2.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 0, + targetAccountCountAfter: 1, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: true, + }, + }, + }); + + const readFileSpy = vi.spyOn(nodeFs, "readFile"); + + const history = await readSyncHistory({ kind: "codex-cli-sync", limit: 1 }); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + kind: "codex-cli-sync", + recordedAt: 3, + run: expect.objectContaining({ + outcome: "changed", + sourcePath: "source-2.json", + }), + }); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + it("recovers the last codex-cli sync run from history when the latest snapshot is missing", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 11, + run: { + outcome: "changed", + runAt: 11, + sourcePath: "source.json", + targetPath: "target.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 0, + targetAccountCountAfter: 1, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: true, + }, + }, + }); + + await nodeFs.rm(getSyncHistoryPaths().latestPath, { force: true }); + __resetLastCodexCliSyncRunForTests(); + + expect(getLastCodexCliSyncRun()).toBeNull(); + + await vi.waitFor(() => { + expect(getLastCodexCliSyncRun()).toMatchObject({ + outcome: "changed", + sourcePath: "source.json", + targetPath: "target.json", + summary: expect.objectContaining({ + addedAccountCount: 1, + }), + }); + }); + }); +}); From 638349e9567c6b65f5c872a5cdc7f902d7ccfe65 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 02:15:46 +0800 Subject: [PATCH 11/76] feat(auth): add backup browser and rotating restore support --- lib/codex-manager.ts | 581 +++++++++++++++++++++++++-------- lib/storage.ts | 125 ++++++- test/codex-manager-cli.test.ts | 74 +++-- test/storage.test.ts | 221 ++++++++++++- 4 files changed, 831 insertions(+), 170 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0cb5b5ea..1f683b33 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -48,6 +48,7 @@ import { summarizeForecast, type ForecastAccountResult, } from "./forecast.js"; +import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { fetchCodexQuotaSnapshot, @@ -67,6 +68,7 @@ import { getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, listNamedBackups, + listRotatingBackups, NAMED_BACKUP_LIST_CONCURRENCY, restoreNamedBackup, findMatchingAccountIndex, @@ -104,6 +106,7 @@ type TokenSuccessWithAccount = TokenSuccess & { accountLabel?: string; }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +const log = createLogger("codex-manager"); let destructiveActionInFlight = false; @@ -150,6 +153,28 @@ function formatRelativeDateShort( return new Date(timestamp).toLocaleDateString(); } +function formatDateTimeLong(timestamp: number | null | undefined): string { + if (timestamp === null || timestamp === undefined) return "unknown"; + return new Date(timestamp).toLocaleString(); +} + +function formatFileSize(sizeBytes: number | null | undefined): string { + if ( + typeof sizeBytes !== "number" || + !Number.isFinite(sizeBytes) || + sizeBytes < 0 + ) { + return "unknown"; + } + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; +} + function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -4101,7 +4126,7 @@ async function runAuthLogin(): Promise { if (pendingQuotaRefresh) { await pendingQuotaRefresh; } - await runBackupRestoreManager(displaySettings); + await runBackupBrowserManager(displaySettings); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -4245,13 +4270,14 @@ async function runAuthLogin(): Promise { 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?`, + } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Open backup browser now?`, ); if (restoreNow) { - const restoreResult = await runBackupRestoreManager( - displaySettings, - recoveryState.allAssessments, - ); + const restoreResult = + await runBackupBrowserManager( + displaySettings, + recoveryState.allAssessments, + ); if (restoreResult !== "restored") { pendingRecoveryState = recoveryState; recoveryPromptAttempted = false; @@ -4733,167 +4759,440 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +type NamedBackupAssessment = Awaited< + ReturnType +>; +type NamedBackupEntry = Awaited>[number]; +type RotatingBackupEntry = Awaited< + ReturnType +>[number]; + +type NamedBackupBrowserEntry = + | { + kind: "named"; + label: string; + backup: NamedBackupEntry; + assessment: NamedBackupAssessment; + assessmentError?: undefined; + } + | { + kind: "named"; + label: string; + backup: NamedBackupEntry; + assessment: null; + assessmentError: string; + }; + +type BackupBrowserEntry = + | NamedBackupBrowserEntry + | { + kind: "rotating"; + label: string; + backup: RotatingBackupEntry; + }; + type BackupMenuAction = | { - type: "restore"; - assessment: BackupRestoreAssessment; + type: "inspect"; + entry: BackupBrowserEntry; } | { type: "back" }; -type BackupRestoreAssessment = Awaited< - ReturnType ->; +type LegacyBackupRestoreSelection = { + type: "restore"; + assessment: NamedBackupAssessment; +}; type BackupRestoreManagerResult = "restored" | "dismissed" | "failed"; -async function loadBackupRestoreManagerAssessments(): Promise< - BackupRestoreAssessment[] -> { - let backups: Awaited>; - 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 []; +function hasNamedBackupAssessment( + entry: BackupBrowserEntry, +): entry is Extract { + return entry.kind === "named" && entry.assessment !== null; +} + +function normalizeBackupAssessmentError(error: unknown): string { + const detail = collapseWhitespace( + error instanceof Error ? error.message : String(error), + ); + return detail.length > 0 + ? detail + : "Unable to assess restore eligibility"; +} + +function buildBackupBrowserHint(entry: BackupBrowserEntry): string { + const backup = entry.backup; + const lastUpdated = formatRelativeDateShort(backup.updatedAt); + const backupType = + entry.kind === "named" ? "named" : `rotating slot ${entry.backup.slot}`; + const parts = [ + backupType, + backup.valid ? "valid" : "invalid", + backup.accountCount !== null + ? `${backup.accountCount} account${backup.accountCount === 1 ? "" : "s"}` + : undefined, + lastUpdated ? `updated ${lastUpdated}` : undefined, + hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit + ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` + : undefined, + entry.kind === "named" && entry.assessment === null + ? "restore assessment unavailable" + : undefined, + entry.kind === "named" && entry.assessment === null + ? entry.assessmentError + : undefined, + backup.loadError, + ].filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); + return parts.join(" | "); +} + +function backupMenuColor( + entry: BackupBrowserEntry, +): MenuItem["color"] { + if (entry.kind === "named" && entry.assessment === null) { + return entry.backup.valid ? "yellow" : "red"; } - if (backups.length === 0) { - return []; + if (hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit) { + return "red"; } + return entry.backup.valid ? "green" : "red"; +} - const currentStorage = await loadAccounts(); - const assessments: BackupRestoreAssessment[] = []; - for ( - let index = 0; - index < backups.length; - index += NAMED_BACKUP_LIST_CONCURRENCY - ) { - const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_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" - }`, +function buildBackupStatusSummary(entry: BackupBrowserEntry): string { + const backup = entry.backup; + if (!backup.valid) { + return stylePromptText("Invalid backup", "danger"); + } + if (entry.kind === "named" && entry.assessment === null) { + return stylePromptText("Restore assessment unavailable", "warning"); + } + if (hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit) { + return stylePromptText("Valid file, restore would exceed limit", "warning"); + } + return stylePromptText("Valid backup", "success"); +} + +async function showBackupBrowserDetails( + entry: BackupBrowserEntry, + displaySettings: DashboardDisplaySettings, +): Promise<"back" | "restore"> { + const backup = entry.backup; + const typeLabel = + entry.kind === "named" + ? "Named backup" + : `Rotating backup (.bak${entry.backup.slot === 0 ? "" : `.${entry.backup.slot}`})`; + const lines = [ + stylePromptText(entry.label, "accent"), + buildBackupStatusSummary(entry), + stylePromptText(backup.path, "muted"), + "", + `${stylePromptText("Type:", "muted")} ${typeLabel}`, + `${stylePromptText("Accounts:", "muted")} ${backup.accountCount ?? "unknown"}`, + `${stylePromptText("Version:", "muted")} ${backup.version ?? "unknown"}`, + `${stylePromptText("Size:", "muted")} ${formatFileSize(backup.sizeBytes)}`, + `${stylePromptText("Created:", "muted")} ${formatDateTimeLong(backup.createdAt)}`, + `${stylePromptText("Updated:", "muted")} ${formatDateTimeLong(backup.updatedAt)}`, + ]; + + if (entry.kind === "named") { + lines.push("", stylePromptText("Restore Assessment", "accent")); + if (entry.assessment === null) { + lines.push( + `${stylePromptText("Eligibility:", "muted")} Unavailable`, + `${stylePromptText("Reason:", "muted")} ${entry.assessmentError}`, + ); + } else { + const assessment = entry.assessment; + lines.push( + `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, + `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, + `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, + `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, + `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.eligibleForRestore ? "Recoverable" : (assessment.error ?? "Unavailable")}`, ); } } - return assessments; + if (backup.schemaErrors.length > 0 || backup.loadError) { + lines.push("", stylePromptText("Validation", "accent")); + for (const schemaError of backup.schemaErrors.slice(0, 3)) { + lines.push(`${stylePromptText("-", "muted")} ${schemaError}`); + } + if (backup.schemaErrors.length > 3) { + lines.push( + `${stylePromptText("-", "muted")} ${backup.schemaErrors.length - 3} more schema error${backup.schemaErrors.length - 3 === 1 ? "" : "s"}`, + ); + } + if (backup.loadError) { + lines.push(`${stylePromptText("-", "muted")} ${backup.loadError}`); + } + } + + if (output.isTTY) { + output.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } + for (const line of lines) { + console.log(line); + } + console.log(""); + if ( + entry.kind !== "named" || + entry.assessment === null || + !entry.assessment.eligibleForRestore || + entry.assessment.wouldExceedLimit + ) { + await waitForMenuReturn(); + return "back"; + } + const action = await select<"restore" | "back">( + [ + { label: "Restore This Backup", value: "restore", color: "green" }, + { label: "Back", value: "back" }, + ], + { + message: "Backup Browser", + subtitle: entry.label, + help: "Enter Select | Q Back", + clearScreen: false, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: getUiRuntimeOptions().theme, + }, + ); + return action === "restore" ? "restore" : "back"; +} + +async function loadBackupBrowserEntries(options: { + startupAssessments?: NamedBackupAssessment[]; +} = {}): Promise<{ + namedEntries: NamedBackupBrowserEntry[]; + rotatingEntries: Extract[]; +}> { + const { startupAssessments } = options; + const rotatingBackups = await listRotatingBackups(); + let currentStorage: Awaited> = null; + try { + currentStorage = await loadAccounts(); + } catch (error) { + log.warn("Failed to load current storage for backup browser", { + error: normalizeBackupAssessmentError(error), + }); + } + let namedEntries: NamedBackupBrowserEntry[]; + if (startupAssessments) { + namedEntries = startupAssessments.map((assessment) => ({ + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + })); + } else { + const namedBackups = await listNamedBackups(); + namedEntries = []; + for ( + let index = 0; + index < namedBackups.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = namedBackups.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + const settledEntries = await Promise.all( + chunk.map(async (backup) => { + try { + const assessment = await assessNamedBackupRestore(backup.name, { + currentStorage, + }); + return { + kind: "named" as const, + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }; + } catch (error) { + const assessmentError = normalizeBackupAssessmentError(error); + log.warn("Failed to assess named backup for backup browser", { + name: backup.name, + error: assessmentError, + }); + return { + kind: "named" as const, + label: backup.name, + backup, + assessment: null, + assessmentError, + }; + } + }), + ); + namedEntries.push(...settledEntries); + } + } + return { + namedEntries, + rotatingEntries: rotatingBackups.map((backup) => ({ + kind: "rotating", + label: backup.label, + backup, + })), + }; } -async function runBackupRestoreManager( +async function runBackupBrowserManager( displaySettings: DashboardDisplaySettings, - assessmentsOverride?: BackupRestoreAssessment[], + startupAssessments?: NamedBackupAssessment[], ): Promise { const backupDir = getNamedBackupsDirectoryPath(); - const assessments = - assessmentsOverride ?? (await loadBackupRestoreManagerAssessments()); - if (assessments.length === 0) { - console.log(`No named backups found. Place backup files in ${backupDir}.`); - return "dismissed"; - } - - const items: MenuItem[] = assessments.map((assessment) => { - const status = - assessment.eligibleForRestore - ? "ready" - : assessment.wouldExceedLimit - ? "limit" - : "invalid"; - const lastUpdated = formatRelativeDateShort(assessment.backup.updatedAt); - const parts = [ - assessment.backup.accountCount !== null - ? `${assessment.backup.accountCount} account${assessment.backup.accountCount === 1 ? "" : "s"}` - : undefined, - lastUpdated ? `updated ${lastUpdated}` : undefined, - assessment.wouldExceedLimit - ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` - : undefined, - assessment.error ?? assessment.backup.loadError, - ].filter( - (value): value is string => - typeof value === "string" && value.trim().length > 0, - ); + const ui = getUiRuntimeOptions(); - return { - label: assessment.backup.name, - hint: parts.length > 0 ? parts.join(" | ") : undefined, - value: { type: "restore", assessment }, - color: - status === "ready" ? "green" : status === "limit" ? "red" : "yellow", - disabled: !assessment.eligibleForRestore, - }; - }); + while (true) { + let namedEntries: NamedBackupBrowserEntry[]; + let rotatingEntries: Extract[]; + try { + ({ namedEntries, rotatingEntries } = await loadBackupBrowserEntries({ + startupAssessments, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + return "dismissed"; + } + if (namedEntries.length === 0 && rotatingEntries.length === 0) { + console.log( + `No backups found. Named backups live in ${backupDir}. Rotating backups live next to ${getStoragePath()}.`, + ); + return "dismissed"; + } - items.push({ label: "Back", value: { type: "back" } }); + const items: MenuItem[] = [ + { label: "Named Backups", value: { type: "back" }, kind: "heading" }, + ]; + if (namedEntries.length === 0) { + items.push({ + label: "No named backups found", + value: { type: "back" }, + disabled: true, + }); + } else { + items.push( + ...namedEntries.map((entry) => ({ + label: entry.label, + hint: buildBackupBrowserHint(entry), + value: { type: "inspect" as const, entry }, + color: backupMenuColor(entry), + })), + ); + } - const ui = getUiRuntimeOptions(); - const selection = await select(items, { - message: "Restore From Backup", - subtitle: backupDir, - help: UI_COPY.mainMenu.helpCompact, - clearScreen: true, - selectedEmphasis: "minimal", - focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - theme: ui.theme, - }); + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ + label: "Rotating Backups", + value: { type: "back" }, + kind: "heading", + }); + if (rotatingEntries.length === 0) { + items.push({ + label: "No rotating backups found", + value: { type: "back" }, + disabled: true, + }); + } else { + items.push( + ...rotatingEntries.map((entry) => ({ + label: entry.label, + hint: buildBackupBrowserHint(entry), + value: { type: "inspect" as const, entry }, + color: backupMenuColor(entry), + })), + ); + } - if (!selection || selection.type === "back") { - return "dismissed"; - } + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ label: "Back", value: { type: "back" } }); - let latestAssessment: BackupRestoreAssessment; - try { - latestAssessment = await assessNamedBackupRestore( - selection.assessment.backup.name, - { currentStorage: await loadAccounts() }, - ); - } catch (error) { - const errorLabel = getRedactedFilesystemErrorLabel(error); - console.warn( - `Failed to re-assess backup "${selection.assessment.backup.name}" before restore (${errorLabel}).`, - ); - return "failed"; - } - if (!latestAssessment.eligibleForRestore) { - console.log(latestAssessment.error ?? "Backup is not eligible for restore."); - return "failed"; - } + const selection = await select(items, { + message: "Backup Browser", + subtitle: `Named: ${backupDir} | Rotating: ${dirname(getStoragePath())}`, + help: "Enter Inspect | Q Back", + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: ui.theme, + }); - const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will merge ${latestAssessment.backup.accountCount ?? 0} account(s) into ${latestAssessment.currentAccountCount} current (${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; - const confirmed = await confirm(confirmMessage); - if (!confirmed) return "dismissed"; + if (!selection || selection.type === "back") { + return "dismissed"; + } - try { - const result = await restoreNamedBackup(latestAssessment.backup.name); - console.log( - `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, - ); - return "restored"; - } catch (error) { - const errorLabel = getRedactedFilesystemErrorLabel(error); - console.warn( - `Failed to restore backup "${latestAssessment.backup.name}" (${errorLabel}).`, - ); - return "failed"; + let entry: BackupBrowserEntry | null = null; + let action: "back" | "restore" = "back"; + const legacySelection = selection as unknown as LegacyBackupRestoreSelection; + if (legacySelection.type === "restore" && legacySelection.assessment) { + entry = { + kind: "named", + label: legacySelection.assessment.backup.name, + backup: legacySelection.assessment.backup, + assessment: legacySelection.assessment, + }; + action = "restore"; + } else if (selection.type === "inspect") { + entry = selection.entry; + action = await showBackupBrowserDetails(entry, displaySettings); + } + + if (action === "restore" && entry?.kind === "named") { + const backupName = entry.backup.name; + let latestAssessment: NamedBackupAssessment; + try { + latestAssessment = await assessNamedBackupRestore(backupName, { + currentStorage: await loadAccounts(), + }); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to re-assess backup "${backupName}" before restore (${errorLabel}).`, + ); + return "failed"; + } + if (!latestAssessment.eligibleForRestore) { + console.log( + latestAssessment.error ?? "Backup is not eligible for restore.", + ); + return "failed"; + } + const confirmed = await confirm(`Restore backup "${backupName}"?`); + if (!confirmed) { + continue; + } + try { + await runActionPanel( + "Restore Backup", + `Restoring ${backupName}`, + async () => { + const result = await restoreNamedBackup(backupName); + console.log( + `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, + ); + }, + displaySettings, + ); + return "restored"; + } catch (restoreError) { + const errorLabel = getRedactedFilesystemErrorLabel(restoreError); + console.warn( + `Failed to restore backup "${backupName}" (${errorLabel}).`, + ); + return "failed"; + } + } } } @@ -4926,7 +5225,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runAuthLogin(); } if (command === "restore-backup") { - return (await runBackupRestoreManager(startupDisplaySettings)) === "failed" + return (await runBackupBrowserManager(startupDisplaySettings)) === "failed" ? 1 : 0; } diff --git a/lib/storage.ts b/lib/storage.ts index dbbe11fb..a523700f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -129,6 +129,12 @@ export interface NamedBackupMetadata { loadError?: string; } +export interface RotatingBackupMetadata + extends Omit { + label: string; + slot: number; +} + export interface BackupRestoreAssessment { backup: NamedBackupMetadata; currentAccountCount: number; @@ -151,6 +157,7 @@ interface LoadedBackupCandidate { storedVersion: unknown; schemaErrors: string[]; error?: string; + errorCode?: string; } interface NamedBackupScanEntry { @@ -187,6 +194,34 @@ export function getRedactedFilesystemErrorLabel(error: unknown): string { return "UNKNOWN"; } +function normalizeBackupLookupName(rawName: string): string { + const trimmed = rawName.trim().replace(/\.(json|bak)$/i, ""); + if (!trimmed) { + throw new StorageError( + `Invalid backup name: ${rawName}`, + "EINVALID", + getNamedBackupRoot(getStoragePath()), + "Named backup restore operations only accept backup names from the backups directory.", + ); + } + const hasPathSeparator = /[\\/]/.test(trimmed); + const hasDrivePrefix = /^[a-zA-Z]:/.test(trimmed); + const segments = trimmed.split(/[\\/]+/).filter(Boolean); + if ( + hasPathSeparator || + hasDrivePrefix || + segments.some((segment) => segment === "." || segment === "..") + ) { + throw new StorageError( + `Invalid backup name: ${rawName}`, + "EINVALID", + getNamedBackupRoot(getStoragePath()), + "Named backup restore operations only accept backup names from the backups directory.", + ); + } + return trimmed; +} + function buildFailedBackupRestoreAssessment( backup: NamedBackupMetadata, currentStorage: AccountStorageV3 | null, @@ -1779,6 +1814,44 @@ export async function listNamedBackups(): Promise { return scanResult.backups.map((entry) => entry.backup); } +export async function listRotatingBackups(): Promise { + let storagePath: string | null = null; + try { + storagePath = getStoragePath(); + const candidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); + const backups: RotatingBackupMetadata[] = []; + + for (const candidatePath of candidates) { + const slot = parseRotatingBackupSlot(storagePath, candidatePath); + if (slot === null) { + continue; + } + + const candidate = await loadBackupCandidate(candidatePath); + if (!candidate.normalized && candidate.errorCode === "ENOENT") { + continue; + } + const metadata = await buildBackupFileMetadata(candidatePath, { + candidate, + }); + backups.push({ + label: formatRotatingBackupLabel(slot), + slot, + ...metadata, + }); + } + + return backups.sort((a, b) => a.slot - b.slot); + } catch (error) { + log.warn("Failed to list rotating backups", { + path: storagePath ?? "", + error: String(error), + }); + return []; + } +} + function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" { @@ -2025,11 +2098,16 @@ async function loadBackupCandidate(path: string): Promise loadAccountsFromPath(path), ); } catch (error) { + const errorCode = + typeof (error as NodeJS.ErrnoException).code === "string" + ? (error as NodeJS.ErrnoException).code + : undefined; return { normalized: null, storedVersion: undefined, schemaErrors: [], error: String(error), + errorCode, }; } } @@ -2097,17 +2175,18 @@ async function findExistingNamedBackupPath( } async function resolveNamedBackupRestorePath(name: string): Promise { - const existingPath = await findExistingNamedBackupPath(name); + const normalizedName = normalizeBackupLookupName(name); + const existingPath = await findExistingNamedBackupPath(normalizedName); if (existingPath) { return existingPath; } - const requested = (name ?? "").trim(); + const requested = normalizedName; const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; try { - return buildNamedBackupPath(name); + return buildNamedBackupPath(normalizedName); } catch (error) { const baseName = requestedWithExtension.toLowerCase().endsWith(".json") ? requestedWithExtension.slice(0, -".json".length) @@ -2339,6 +2418,17 @@ async function buildNamedBackupMetadata( path: string, opts: { candidate?: LoadedBackupCandidate } = {}, ): Promise { + const metadata = await buildBackupFileMetadata(path, opts); + return { + name, + ...metadata, + }; +} + +async function buildBackupFileMetadata( + path: string, + opts: { candidate?: LoadedBackupCandidate } = {}, +): Promise> { const candidate = opts.candidate ?? (await loadBackupCandidate(path)); let stats: { size?: number; @@ -2365,7 +2455,6 @@ async function buildNamedBackupMetadata( const updatedAt = stats?.mtimeMs ?? null; return { - name, path, createdAt, updatedAt, @@ -2378,6 +2467,34 @@ async function buildNamedBackupMetadata( }; } +function parseRotatingBackupSlot( + storagePath: string, + candidatePath: string, +): number | null { + const latestBackupPath = getAccountsBackupPath(storagePath); + if (candidatePath === latestBackupPath) { + return 0; + } + + const slotMatch = candidatePath.match(/\.bak\.(\d+)$/i); + if (!slotMatch) { + return null; + } + + const parsed = Number.parseInt(slotMatch[1] ?? "", 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return null; + } + + return parsed; +} + +function formatRotatingBackupLabel(slot: number): string { + return slot === 0 + ? "Latest rotating backup (.bak)" + : `Rotating backup ${slot} (.bak.${slot})`; +} + /** * Optional per-call overrides for account storage persistence. * When omitted, `saveAccounts` uses the module-level backup policy. diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 02d7ab2e..c54341b8 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -11,6 +11,7 @@ const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); const getActionableNamedBackupRestoresMock = vi.fn(); const listNamedBackupsMock = vi.fn(); +const listRotatingBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); const restoreNamedBackupMock = vi.fn(); @@ -147,6 +148,7 @@ vi.mock("../lib/storage.js", async () => { getStoragePath: getStoragePathMock, getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, listNamedBackups: listNamedBackupsMock, + listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, restoreNamedBackup: restoreNamedBackupMock, @@ -636,6 +638,7 @@ describe("codex manager cli commands", () => { accounts: [], }); listNamedBackupsMock.mockReset(); + listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); @@ -647,6 +650,7 @@ describe("codex manager cli commands", () => { totalBackups: 0, }); listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ backup: { name: "named-backup", @@ -3110,7 +3114,7 @@ describe("codex manager cli commands", () => { expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledWith( - "Found 2 recoverable backups out of 2 total (2 backups) in /mock/backups. Restore now?", + "Found 2 recoverable backups out of 2 total (2 backups) in /mock/backups. Open backup browser now?", ); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); @@ -3159,7 +3163,7 @@ describe("codex manager cli commands", () => { ); }); - it("shows all startup-scanned backups in the restore manager before re-prompting", async () => { + it("shows all startup-scanned backups in the backup browser before re-prompting", async () => { setInteractiveTTY(true); const now = Date.now(); let storageState = { @@ -3230,22 +3234,28 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledTimes(2); - const restoreManagerCall = selectMock.mock.calls.find( - ([, options]) => options?.message === "Restore From Backup", + const backupBrowserCall = selectMock.mock.calls.find( + ([, options]) => options?.message === "Backup Browser", ); - expect(restoreManagerCall).toBeDefined(); - expect(restoreManagerCall?.[1]).toMatchObject({ - message: "Restore From Backup", + expect(backupBrowserCall).toBeDefined(); + expect(backupBrowserCall?.[1]).toMatchObject({ + message: "Backup Browser", }); - expect(restoreManagerCall?.[0]).toEqual( + expect(backupBrowserCall?.[0]).toEqual( expect.arrayContaining([ + expect.objectContaining({ + label: "Named Backups", + kind: "heading", + }), expect.objectContaining({ label: "startup-backup", - disabled: false, + value: expect.objectContaining({ type: "inspect" }), }), expect.objectContaining({ label: "stale-backup", - disabled: true, + color: "red", + hint: expect.stringContaining("Backup is empty or invalid"), + value: expect.objectContaining({ type: "inspect" }), }), ]), ); @@ -4529,7 +4539,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalled(); expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( "named-backup", expect.objectContaining({ @@ -4579,7 +4589,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalled(); expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( "named-backup", expect.objectContaining({ currentStorage: null }), @@ -4640,7 +4650,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(listNamedBackupsMock).toHaveBeenCalled(); expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( "named-backup", expect.objectContaining({ @@ -4797,10 +4807,26 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); + confirmMock.mockResolvedValueOnce(true); selectMock.mockImplementationOnce(async (items) => { const labels = items.map((item) => item.label); + const brokenItem = items.find((item) => item.label === "broken-backup"); expect(labels).toContain("healthy-backup"); - expect(labels).not.toContain("broken-backup"); + expect(labels).toContain("broken-backup"); + expect(brokenItem).toMatchObject({ + label: "broken-backup", + color: "yellow", + hint: expect.stringContaining("restore assessment unavailable"), + value: expect.objectContaining({ + type: "inspect", + entry: expect.objectContaining({ + kind: "named", + label: "broken-backup", + assessment: null, + assessmentError: "backup directory busy", + }), + }), + }); return { type: "restore", assessment: healthyAssessment }; }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -4810,12 +4836,13 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(3); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(confirmMock).toHaveBeenCalledOnce(); expect(restoreNamedBackupMock).toHaveBeenCalledWith("healthy-backup"); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Skipped backup assessment for "broken-backup": backup directory busy', - ), - ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); } finally { warnSpy.mockRestore(); } @@ -4977,9 +5004,7 @@ describe("codex manager cli commands", () => { }), }), ); - expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("into 3 current (4 after dedupe)"), - ); + expect(confirmMock).toHaveBeenCalledWith('Restore backup "named-backup"?'); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); @@ -5105,7 +5130,10 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain( + const epochItem = backupItems?.find( + (item) => item.label === "epoch-backup", + ); + expect(epochItem?.hint).toContain( `updated ${new Date(0).toLocaleDateString()}`, ); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index a3d73657..4754fbb4 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -23,6 +23,7 @@ import { getStoragePath, importAccounts, listNamedBackups, + listRotatingBackups, loadAccounts, loadFlaggedAccounts, normalizeAccountStorage, @@ -1351,10 +1352,10 @@ describe("storage", () => { "rejects backup names that escape the backups directory: %s", async (input) => { await expect(assessNamedBackupRestore(input)).rejects.toThrow( - /must not contain path separators/i, + /invalid backup name/i, ); await expect(restoreNamedBackup(input)).rejects.toThrow( - /must not contain path separators/i, + /invalid backup name/i, ); }, ); @@ -4023,6 +4024,222 @@ describe("storage", () => { expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); }); + + it("lists rotating backups with stable labels and invalid metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile(`${testStoragePath}.bak.1`, "{broken-bak", "utf-8"); + + const rotatingBackups = await listRotatingBackups(); + const namedBackups = await listNamedBackups(); + + expect(namedBackups).toEqual([]); + expect(rotatingBackups).toHaveLength(2); + expect(rotatingBackups[0]).toMatchObject({ + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }); + expect(rotatingBackups[1]).toMatchObject({ + slot: 1, + label: "Rotating backup 1 (.bak.1)", + valid: false, + }); + expect(rotatingBackups[1]?.loadError).toBeTruthy(); + }); + + it("skips rotating backups that disappear during load", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + `${testStoragePath}.bak.1`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-missing", + refreshToken: "ref-rotating-missing", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + "utf-8", + ); + + const originalReadFile = fs.readFile.bind(fs) as typeof fs.readFile; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation((async ( + ...args: Parameters + ) => { + const [filePath] = args; + if (String(filePath).endsWith(".bak.1")) { + throw Object.assign( + new Error("ENOENT: no such file or directory"), + { code: "ENOENT" }, + ); + } + return originalReadFile(...args); + }) as typeof fs.readFile); + + try { + await expect(listRotatingBackups()).resolves.toMatchObject([ + { + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }, + ]); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("keeps rotating backups visible when a non-ENOENT error mentions an ENOENT path segment", async () => { + const storageDir = join(testWorkDir, "ENOENT-project"); + await fs.mkdir(storageDir, { recursive: true }); + testStoragePath = join(storageDir, "openai-codex-accounts.json"); + setStoragePathDirect(testStoragePath); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + `${testStoragePath}.bak.1`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-locked", + refreshToken: "ref-rotating-locked", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + "utf-8", + ); + + const lockedBackupPath = `${testStoragePath}.bak.1`; + const originalReadFile = fs.readFile.bind(fs) as typeof fs.readFile; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation((async ( + ...args: Parameters + ) => { + const [filePath] = args; + if (String(filePath) === lockedBackupPath) { + throw Object.assign( + new Error( + `EPERM: operation not permitted, open '${lockedBackupPath}'`, + ), + { code: "EPERM" }, + ); + } + return originalReadFile(...args); + }) as typeof fs.readFile); + + try { + const rotatingBackups = await listRotatingBackups(); + expect(rotatingBackups).toHaveLength(2); + expect(rotatingBackups[0]).toMatchObject({ + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }); + expect(rotatingBackups[1]).toMatchObject({ + slot: 1, + label: "Rotating backup 1 (.bak.1)", + valid: false, + }); + expect(rotatingBackups[1]?.loadError).toContain("EPERM"); + } finally { + readFileSpy.mockRestore(); + } + }); }); describe("clearAccounts edge cases", () => { From 7a867a43411d32a1cf74552a8f47874cd8839c6f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 05:57:48 +0800 Subject: [PATCH 12/76] feat(auth): add restore preview --- lib/codex-manager.ts | 222 ++++++++++++-- lib/storage.ts | 436 ++++++++++++++++++++++++++-- test/codex-manager-cli.test.ts | 270 ++++++++++++++--- test/storage-recovery-paths.test.ts | 1 + test/storage.test.ts | 160 +++++++++- 5 files changed, 1010 insertions(+), 79 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 1f683b33..77c8691e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4798,6 +4798,8 @@ type BackupMenuAction = } | { type: "back" }; +type BackupDetailAction = "back" | "preview-restore" | "restore"; + type LegacyBackupRestoreSelection = { type: "restore"; assessment: NamedBackupAssessment; @@ -4875,10 +4877,120 @@ function buildBackupStatusSummary(entry: BackupBrowserEntry): string { return stylePromptText("Valid backup", "success"); } +function formatPreviewAccountLabel( + email: string | undefined, + accountId: string | undefined, + index: number | null | undefined, +): string { + const label = email?.trim() || accountId?.trim() || "none"; + if (index === null || index === undefined) { + return label; + } + return `${label} (#${index + 1})`; +} + +function formatActiveAccountOutcome( + assessment: NamedBackupAssessment, +): string { + const nextLabel = formatPreviewAccountLabel( + assessment.nextActiveEmail, + assessment.nextActiveAccountId, + assessment.nextActiveIndex, + ); + const currentLabel = formatPreviewAccountLabel( + assessment.currentActiveEmail, + assessment.currentActiveAccountId, + assessment.currentActiveIndex, + ); + const outcome = assessment.activeAccountOutcome ?? "unchanged"; + if (outcome === "blocked") { + return "Blocked until restore becomes eligible"; + } + if (outcome === "cleared") { + return `Would clear active account (currently ${currentLabel})`; + } + if (outcome === "changed") { + return `${nextLabel} (currently ${currentLabel})`; + } + const changeNote = assessment.activeAccountChanged ? " (would change)" : ""; + return `${nextLabel}${changeNote}`; +} + +function buildRestoreAssessmentLines( + assessment: NamedBackupAssessment, +): string[] { + const backupAccountLabel = (() => { + if ( + assessment.backupAccountCount === null || + assessment.backupAccountCount === undefined + ) { + return "unknown"; + } + if ( + assessment.dedupedBackupAccountCount !== null && + assessment.dedupedBackupAccountCount !== undefined && + assessment.dedupedBackupAccountCount !== assessment.backupAccountCount + ) { + return `${assessment.backupAccountCount} (deduped ${assessment.dedupedBackupAccountCount})`; + } + return `${assessment.backupAccountCount}`; + })(); + + const conflictSummary = (() => { + const replacements = assessment.replacedExistingCount ?? 0; + const withExisting = assessment.conflictsWithExisting ?? 0; + const withinBackup = assessment.conflictsWithinBackup ?? 0; + const parts: string[] = []; + if (replacements > 0) { + parts.push( + `${replacements} replace current${replacements === 1 ? "" : " accounts"}`, + ); + } + if (withExisting > 0) { + parts.push( + `${withExisting} duplicate${withExisting === 1 ? "" : "s"} against current`, + ); + } + if (withinBackup > 0) { + parts.push( + `${withinBackup} duplicate${withinBackup === 1 ? "" : "s"} inside backup`, + ); + } + return parts.length > 0 ? parts.join(" | ") : "No conflicts detected"; + })(); + + return [ + `${stylePromptText("Backup accounts:", "muted")} ${backupAccountLabel}`, + `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, + `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, + `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, + `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, + `${stylePromptText("Conflicts:", "muted")} ${conflictSummary}`, + `${stylePromptText("Current active:", "muted")} ${formatPreviewAccountLabel( + assessment.currentActiveEmail, + assessment.currentActiveAccountId, + assessment.currentActiveIndex, + )}`, + `${stylePromptText("Active after restore:", "muted")} ${formatActiveAccountOutcome(assessment)}`, + `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.eligibleForRestore ? "Recoverable" : (assessment.error ?? "Unavailable")}`, + ]; +} + +function canRestoreFromAssessment( + assessment: NamedBackupAssessment, +): boolean { + return ( + !assessment.wouldExceedLimit && + assessment.eligibleForRestore && + ((assessment.imported ?? 0) > 0 || + (assessment.replacedExistingCount ?? 0) > 0) + ); +} + async function showBackupBrowserDetails( entry: BackupBrowserEntry, displaySettings: DashboardDisplaySettings, -): Promise<"back" | "restore"> { +): Promise { const backup = entry.backup; const typeLabel = entry.kind === "named" @@ -4905,14 +5017,7 @@ async function showBackupBrowserDetails( `${stylePromptText("Reason:", "muted")} ${entry.assessmentError}`, ); } else { - const assessment = entry.assessment; - lines.push( - `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, - `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, - `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, - `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, - `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.eligibleForRestore ? "Recoverable" : (assessment.error ?? "Unavailable")}`, - ); + lines.push(...buildRestoreAssessmentLines(entry.assessment)); } } @@ -4938,31 +5043,96 @@ async function showBackupBrowserDetails( console.log(line); } console.log(""); - if ( - entry.kind !== "named" || - entry.assessment === null || - !entry.assessment.eligibleForRestore || - entry.assessment.wouldExceedLimit - ) { + if (entry.kind !== "named") { + await waitForMenuReturn(); + return "back"; + } + if (entry.assessment === null || !canRestoreFromAssessment(entry.assessment)) { await waitForMenuReturn(); return "back"; } - const action = await select<"restore" | "back">( + const action = await select( [ - { label: "Restore This Backup", value: "restore", color: "green" }, { label: "Back", value: "back" }, + { label: "Preview Restore", value: "preview-restore", color: "green" }, ], { - message: "Backup Browser", + message: "Backup Actions", subtitle: entry.label, - help: "Enter Select | Q Back", + help: "Enter Preview | Q Back", clearScreen: false, selectedEmphasis: "minimal", focusStyle: displaySettings.menuFocusStyle ?? "row-invert", theme: getUiRuntimeOptions().theme, }, ); - return action === "restore" ? "restore" : "back"; + return action ?? "back"; +} + +async function runBackupRestorePreview( + entry: Extract, + displaySettings: DashboardDisplaySettings, +): Promise { + const backupName = entry.backup.name; + let assessment: NamedBackupAssessment; + try { + assessment = await assessNamedBackupRestore(backupName, { + currentStorage: await loadAccounts(), + }); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn( + `Failed to re-assess backup "${backupName}" before restore (${errorLabel}).`, + ); + return "dismissed"; + } + + if (output.isTTY) { + output.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } + console.log(stylePromptText(`Restore Preview: ${entry.label}`, "accent")); + for (const line of buildRestoreAssessmentLines(assessment)) { + console.log(line); + } + console.log(""); + + if (!canRestoreFromAssessment(assessment)) { + await waitForMenuReturn(); + return "dismissed"; + } + + const replacementNote = + (assessment.replacedExistingCount ?? 0) > 0 + ? ` Replacing ${assessment.replacedExistingCount} current account${assessment.replacedExistingCount === 1 ? "" : "s"}.` + : ""; + const activeNote = assessment.activeAccountChanged + ? " Active account would change." + : ""; + const confirmed = await confirm( + `Restore ${entry.label}? Import ${assessment.imported ?? 0} new account${assessment.imported === 1 ? "" : "s"} for ${assessment.mergedAccountCount ?? "?"} total.${replacementNote}${activeNote}`, + ); + if (!confirmed) { + return "dismissed"; + } + + try { + await runActionPanel( + "Restore Backup", + `Restoring ${entry.label}`, + async () => { + const result = await restoreNamedBackup(backupName); + console.log( + `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, + ); + }, + displaySettings, + ); + return "restored"; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + console.warn(`Failed to restore backup "${backupName}" (${errorLabel}).`); + return "dismissed"; + } } async function loadBackupBrowserEntries(options: { @@ -5133,7 +5303,7 @@ async function runBackupBrowserManager( } let entry: BackupBrowserEntry | null = null; - let action: "back" | "restore" = "back"; + let action: BackupDetailAction = "back"; const legacySelection = selection as unknown as LegacyBackupRestoreSelection; if (legacySelection.type === "restore" && legacySelection.assessment) { entry = { @@ -5148,6 +5318,14 @@ async function runBackupBrowserManager( action = await showBackupBrowserDetails(entry, displaySettings); } + if (action === "preview-restore" && entry?.kind === "named") { + const previewResult = await runBackupRestorePreview(entry, displaySettings); + if (previewResult === "restored") { + return "restored"; + } + continue; + } + if (action === "restore" && entry?.kind === "named") { const backupName = entry.backup.name; let latestAssessment: NamedBackupAssessment; @@ -5162,7 +5340,7 @@ async function runBackupBrowserManager( ); return "failed"; } - if (!latestAssessment.eligibleForRestore) { + if (!canRestoreFromAssessment(latestAssessment)) { console.log( latestAssessment.error ?? "Backup is not eligible for restore.", ); diff --git a/lib/storage.ts b/lib/storage.ts index a523700f..132289a4 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -135,14 +135,67 @@ export interface RotatingBackupMetadata slot: number; } +export interface BackupRestoreAccountPreview { + index: number; + email?: string; + accountId?: string; +} + +export interface BackupRestoreConflictDetail { + backupIndex: number; + backupEmail?: string; + backupAccountId?: string; + currentIndex: number; + currentEmail?: string; + currentAccountId?: string; + reasons: Array<"accountId" | "refreshToken" | "email">; + resolution: "backup-kept" | "current-kept"; +} + +export interface BackupRestoreActiveAccountPreview { + current: BackupRestoreAccountPreview | null; + next: BackupRestoreAccountPreview | null; + outcome: "unchanged" | "changed" | "cleared" | "blocked"; + changed: boolean; +} + +export interface BackupRestoreConflictPreview { + conflict: BackupRestoreConflictDetail; + backup: BackupRestoreAccountPreview | null; + current: BackupRestoreAccountPreview | null; +} + +export interface NamedBackupRestorePreview { + conflicts: BackupRestoreConflictPreview[]; + activeAccount: BackupRestoreActiveAccountPreview; +} + export interface BackupRestoreAssessment { backup: NamedBackupMetadata; + backupAccountCount?: number | null; + dedupedBackupAccountCount?: number | null; + conflictsWithinBackup?: number | null; + conflictsWithExisting?: number | null; + overlappingAccountConflicts?: BackupRestoreConflictDetail[] | null; + replacedExistingCount?: number | null; + keptExistingCount?: number | null; + keptBackupCount?: number | null; currentAccountCount: number; mergedAccountCount: number | null; imported: number | null; skipped: number | null; wouldExceedLimit: boolean; eligibleForRestore: boolean; + nextActiveIndex?: number | null; + nextActiveEmail?: string; + nextActiveAccountId?: string; + currentActiveIndex?: number | null; + currentActiveEmail?: string; + currentActiveAccountId?: string; + activeAccountOutcome?: "unchanged" | "changed" | "cleared" | "blocked"; + activeAccountChanged?: boolean; + activeAccountPreview?: BackupRestoreActiveAccountPreview; + namedBackupRestorePreview?: NamedBackupRestorePreview | null; error?: string; } @@ -222,19 +275,122 @@ function normalizeBackupLookupName(rawName: string): string { return trimmed; } +function clampActiveIndexForPreview( + currentIndex: number, + accounts: AccountLike[], +): number | null { + if (accounts.length === 0) return null; + if (!Number.isFinite(currentIndex)) return null; + return Math.max(0, Math.min(currentIndex, accounts.length - 1)); +} + +function toBackupRestoreAccountPreview( + account: AccountLike | null | undefined, + index: number | null, +): BackupRestoreAccountPreview | null { + if (!account || index === null) return null; + return { + index, + email: account.email, + accountId: account.accountId, + }; +} + +function getAccountConflictReasons( + left: AccountLike, + right: AccountLike, +): BackupRestoreConflictDetail["reasons"] { + const reasons = new Set(); + if (left.accountId && right.accountId && left.accountId === right.accountId) { + reasons.add("accountId"); + } + if ( + left.refreshToken && + right.refreshToken && + left.refreshToken === right.refreshToken + ) { + reasons.add("refreshToken"); + } + const leftEmail = normalizeEmailKey(left.email); + if (leftEmail && leftEmail === normalizeEmailKey(right.email)) { + reasons.add("email"); + } + return [...reasons]; +} + +function assessmentHasRestorableChanges( + assessment: Pick< + BackupRestoreAssessment, + "imported" | "replacedExistingCount" + >, +): boolean { + return ( + (assessment.imported ?? 0) > 0 || + (assessment.replacedExistingCount ?? 0) > 0 + ); +} + function buildFailedBackupRestoreAssessment( backup: NamedBackupMetadata, currentStorage: AccountStorageV3 | null, error: unknown, ): BackupRestoreAssessment { + const currentAccounts = currentStorage?.accounts ?? []; + const currentActiveIndex = clampActiveIndexForPreview( + currentStorage?.activeIndex ?? 0, + currentAccounts, + ); return { backup, - currentAccountCount: currentStorage?.accounts.length ?? 0, + backupAccountCount: backup.accountCount, + dedupedBackupAccountCount: backup.accountCount, + conflictsWithinBackup: null, + conflictsWithExisting: null, + overlappingAccountConflicts: null, + replacedExistingCount: null, + keptExistingCount: null, + keptBackupCount: null, + currentAccountCount: currentAccounts.length, mergedAccountCount: null, imported: null, skipped: null, wouldExceedLimit: false, eligibleForRestore: false, + nextActiveIndex: null, + currentActiveIndex, + currentActiveEmail: + currentActiveIndex === null + ? undefined + : currentAccounts[currentActiveIndex]?.email, + currentActiveAccountId: + currentActiveIndex === null + ? undefined + : currentAccounts[currentActiveIndex]?.accountId, + activeAccountOutcome: "blocked", + activeAccountChanged: false, + activeAccountPreview: { + current: toBackupRestoreAccountPreview( + currentActiveIndex === null ? null : currentAccounts[currentActiveIndex], + currentActiveIndex, + ), + next: null, + outcome: "blocked", + changed: false, + }, + namedBackupRestorePreview: { + conflicts: [], + activeAccount: { + current: toBackupRestoreAccountPreview( + currentActiveIndex === null + ? null + : currentAccounts[currentActiveIndex], + currentActiveIndex, + ), + next: null, + outcome: "blocked", + changed: false, + }, + }, error: getRedactedFilesystemErrorLabel(error), }; } @@ -1927,8 +2083,7 @@ export async function getActionableNamedBackupRestores( if ( assessment.eligibleForRestore && !assessment.wouldExceedLimit && - assessment.imported !== null && - assessment.imported > 0 + assessmentHasRestorableChanges(assessment) ) { actionable.push(assessment); } @@ -2013,52 +2168,295 @@ export async function assessNamedBackupRestore( options.currentStorage !== undefined ? options.currentStorage : await loadAccounts(); - return assessNamedBackupRestoreCandidate(backup, candidate, currentStorage); + let previewAccounts: AccountLike[] = []; + try { + const rawContent = await fs.readFile(backupPath, "utf-8"); + const parsed = JSON.parse(rawContent) as unknown; + if (isRecord(parsed) && Array.isArray(parsed.accounts)) { + previewAccounts = (parsed.accounts as unknown[]).filter( + (account): account is AccountLike => + isRecord(account) && + typeof account.refreshToken === "string" && + account.refreshToken.trim().length > 0, + ); + } + } catch (error) { + if (!candidate.normalized) { + log.debug("Failed to parse backup for restore preview", { + path: backupPath, + error: String(error), + }); + } + } + return assessNamedBackupRestoreCandidate( + backup, + candidate, + currentStorage, + previewAccounts, + ); } function assessNamedBackupRestoreCandidate( backup: NamedBackupMetadata, candidate: LoadedBackupCandidate, currentStorage: AccountStorageV3 | null, + rawBackupAccounts: AccountLike[] = [], ): BackupRestoreAssessment { const currentAccounts = currentStorage?.accounts ?? []; + const currentActiveIndex = clampActiveIndexForPreview( + currentStorage?.activeIndex ?? 0, + currentAccounts, + ); + const backupAccounts = + rawBackupAccounts.length > 0 + ? rawBackupAccounts + : (candidate.normalized?.accounts ?? []); + const backupAccountCount = backupAccounts.length; - if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { + if (!candidate.normalized || backupAccountCount <= 0) { return { backup, + backupAccountCount, + dedupedBackupAccountCount: null, + conflictsWithinBackup: null, + conflictsWithExisting: null, + overlappingAccountConflicts: null, + replacedExistingCount: null, + keptExistingCount: null, + keptBackupCount: null, currentAccountCount: currentAccounts.length, mergedAccountCount: null, imported: null, skipped: null, wouldExceedLimit: false, eligibleForRestore: false, + nextActiveIndex: null, + currentActiveIndex, + currentActiveEmail: + currentActiveIndex === null + ? undefined + : currentAccounts[currentActiveIndex]?.email, + currentActiveAccountId: + currentActiveIndex === null + ? undefined + : currentAccounts[currentActiveIndex]?.accountId, + activeAccountOutcome: "blocked", + activeAccountChanged: false, + activeAccountPreview: { + current: toBackupRestoreAccountPreview( + currentActiveIndex === null ? null : currentAccounts[currentActiveIndex], + currentActiveIndex, + ), + next: null, + outcome: "blocked", + changed: false, + }, + namedBackupRestorePreview: { + conflicts: [], + activeAccount: { + current: toBackupRestoreAccountPreview( + currentActiveIndex === null + ? null + : currentAccounts[currentActiveIndex], + currentActiveIndex, + ), + next: null, + outcome: "blocked", + changed: false, + }, + }, error: backup.loadError ?? "Backup is empty or invalid", }; } - const mergedAccounts = deduplicateAccounts([ - ...currentAccounts, - ...candidate.normalized.accounts, - ]); - const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const dedupedBackupAccounts = deduplicateAccountsByEmail( + deduplicateAccounts(backupAccounts), + ); + const dedupedBackupAccountCount = dedupedBackupAccounts.length; + const conflictsWithinBackup = Math.max( + 0, + backupAccountCount - dedupedBackupAccountCount, + ); + + type TaggedAccount = AccountLike & { + __source: "current" | "backup"; + __index: number; + }; + + const taggedCurrent: TaggedAccount[] = currentAccounts.map((account, index) => ({ + ...account, + __source: "current", + __index: index, + })); + const taggedBackup: TaggedAccount[] = backupAccounts.map((account, index) => ({ + ...account, + __source: "backup", + __index: index, + })); + + const mergedTagged = deduplicateAccountsByEmail( + deduplicateAccounts([...taggedCurrent, ...taggedBackup]), + ); + const mergedAccounts = mergedTagged.map( + ({ __source: _source, __index: _index, ...account }) => account, + ); + const mergedAccountCount = mergedAccounts.length; + const keptBackupCount = mergedTagged.filter( + (account) => account.__source === "backup", + ).length; + const keptExistingCount = mergedTagged.length - keptBackupCount; + const replacedExistingCount = Math.max( + 0, + currentAccounts.length - keptExistingCount, + ); + const conflictsWithExisting = Math.max( + 0, + dedupedBackupAccountCount - keptBackupCount, + ); + const wouldExceedLimit = mergedAccountCount > ACCOUNT_LIMITS.MAX_ACCOUNTS; const imported = wouldExceedLimit ? null - : mergedAccounts.length - currentAccounts.length; + : Math.max(0, keptBackupCount - replacedExistingCount); const skipped = wouldExceedLimit ? null - : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + : Math.max( + 0, + backupAccountCount - + (imported ?? 0) - + replacedExistingCount, + ); + + const currentActiveAccount = + currentActiveIndex === null ? null : currentAccounts[currentActiveIndex]; + const nextActiveIndex = wouldExceedLimit + ? null + : clampActiveIndexForPreview( + currentStorage?.activeIndex ?? 0, + mergedAccounts, + ); + const nextActiveAccount = + nextActiveIndex === null ? null : mergedAccounts[nextActiveIndex]; + + const keptBackupIndices = new Set( + mergedTagged + .filter((account) => account.__source === "backup") + .map((account) => account.__index), + ); + const dedupedBackupIndexByAccount = new Map( + dedupedBackupAccounts.map((account) => [ + account, + backupAccounts.indexOf(account), + ]), + ); + const overlappingAccountConflicts = dedupedBackupAccounts.flatMap( + (account) => { + const backupIndex = dedupedBackupIndexByAccount.get(account); + if (backupIndex === undefined || backupIndex < 0) return []; + const resolution: BackupRestoreConflictDetail["resolution"] = + keptBackupIndices.has(backupIndex) ? "backup-kept" : "current-kept"; + return currentAccounts.flatMap((currentAccount, currentIndex) => { + const reasons = getAccountConflictReasons(account, currentAccount); + if (reasons.length === 0) return []; + return [ + { + backupIndex, + backupEmail: account.email, + backupAccountId: account.accountId, + currentIndex, + currentEmail: currentAccount.email, + currentAccountId: currentAccount.accountId, + reasons, + resolution, + }, + ]; + }); + }, + ); + const conflictPreviews: BackupRestoreConflictPreview[] = + overlappingAccountConflicts.map((conflict) => ({ + conflict, + backup: toBackupRestoreAccountPreview( + backupAccounts[conflict.backupIndex], + conflict.backupIndex, + ), + current: toBackupRestoreAccountPreview( + currentAccounts[conflict.currentIndex], + conflict.currentIndex, + ), + })); + + let activeAccountOutcome: BackupRestoreAssessment["activeAccountOutcome"] = + wouldExceedLimit ? "blocked" : "unchanged"; + let activeAccountChanged = false; + if (!wouldExceedLimit) { + if (currentActiveAccount && !nextActiveAccount) { + activeAccountOutcome = "cleared"; + activeAccountChanged = true; + } else if (nextActiveAccount && !currentActiveAccount) { + activeAccountOutcome = "changed"; + activeAccountChanged = true; + } else if (nextActiveAccount && currentActiveAccount) { + const sameAccount = + nextActiveAccount.refreshToken === currentActiveAccount.refreshToken || + (nextActiveAccount.accountId && + nextActiveAccount.accountId === currentActiveAccount.accountId) || + (normalizeEmailKey(nextActiveAccount.email) && + normalizeEmailKey(nextActiveAccount.email) === + normalizeEmailKey(currentActiveAccount.email)); + activeAccountOutcome = sameAccount ? "unchanged" : "changed"; + activeAccountChanged = !sameAccount; + } + } + + const activeAccountPreview: BackupRestoreActiveAccountPreview = { + current: toBackupRestoreAccountPreview( + currentActiveAccount, + currentActiveIndex, + ), + next: toBackupRestoreAccountPreview(nextActiveAccount, nextActiveIndex), + outcome: activeAccountOutcome ?? "blocked", + changed: activeAccountChanged, + }; + const hasRestorableChanges = + !wouldExceedLimit && + ((imported ?? 0) > 0 || replacedExistingCount > 0); + const error = + wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : hasRestorableChanges + ? undefined + : "Backup would not change any accounts"; return { backup, + backupAccountCount, + dedupedBackupAccountCount, + conflictsWithinBackup, + conflictsWithExisting, + overlappingAccountConflicts, + replacedExistingCount, + keptExistingCount, + keptBackupCount, currentAccountCount: currentAccounts.length, - mergedAccountCount: mergedAccounts.length, + mergedAccountCount, imported, skipped, wouldExceedLimit, - eligibleForRestore: !wouldExceedLimit, - error: wouldExceedLimit - ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` - : undefined, + eligibleForRestore: hasRestorableChanges, + nextActiveIndex, + nextActiveEmail: nextActiveAccount?.email, + nextActiveAccountId: nextActiveAccount?.accountId, + currentActiveIndex, + currentActiveEmail: currentActiveAccount?.email, + currentActiveAccountId: currentActiveAccount?.accountId, + activeAccountOutcome: activeAccountOutcome ?? "blocked", + activeAccountChanged, + activeAccountPreview, + namedBackupRestorePreview: { + conflicts: conflictPreviews, + activeAccount: activeAccountPreview, + }, + error, }; } @@ -2066,6 +2464,10 @@ export async function restoreNamedBackup( name: string, ): Promise<{ imported: number; total: number; skipped: number }> { const backupPath = await resolveNamedBackupRestorePath(name); + const assessment = await assessNamedBackupRestore(name); + if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) { + throw new Error(assessment.error ?? "Backup is not eligible for restore"); + } return importAccounts(backupPath); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c54341b8..d95aeddb 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1101,6 +1101,166 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("runs restore preview before applying a replace-only named backup", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "current@example.com", + accountId: "current-account", + refreshToken: "refresh-current", + accessToken: "access-current", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + backupAccountCount: 1, + dedupedBackupAccountCount: 1, + conflictsWithinBackup: 0, + conflictsWithExisting: 0, + replacedExistingCount: 1, + keptExistingCount: 0, + keptBackupCount: 1, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "current@example.com", + nextActiveAccountId: "current-account", + currentActiveIndex: 0, + currentActiveEmail: "current@example.com", + currentActiveAccountId: "current-account", + activeAccountOutcome: "unchanged", + activeAccountChanged: false, + activeAccountPreview: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + namedBackupRestorePreview: { + conflicts: [ + { + conflict: { + backupIndex: 0, + currentIndex: 0, + reasons: ["accountId", "email"], + resolution: "backup-kept", + }, + backup: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + }, + ], + activeAccount: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + }, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(assessment) + .mockResolvedValueOnce(assessment); + selectMock + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("preview-restore"); + confirmMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Backup Browser", + }); + expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ + message: "Backup Actions", + subtitle: "named-backup", + }); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -4924,7 +5084,7 @@ describe("codex manager cli commands", () => { ); }); - it("reassesses a backup before confirmation so the merge summary stays current", async () => { + it("uses the restore preview flow and confirms against the refreshed assessment", async () => { setInteractiveTTY(true); const now = Date.now(); loadAccountsMock.mockResolvedValue({ @@ -4977,10 +5137,31 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); + selectMock + .mockImplementationOnce(async (items) => { + expect( + (items as Array<{ label?: string }>).some( + (item) => item.label === "named-backup", + ), + ).toBe(true); + return { + type: "inspect", + entry: { + kind: "named", + label: "named-backup", + backup: initialAssessment.backup, + assessment: initialAssessment, + }, + }; + }) + .mockImplementationOnce(async (items) => { + expect( + (items as Array<{ label?: string }>).some( + (item) => item.label === "Preview Restore", + ), + ).toBe(true); + return "preview-restore"; + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -5004,11 +5185,13 @@ describe("codex manager cli commands", () => { }), }), ); - expect(confirmMock).toHaveBeenCalledWith('Restore backup "named-backup"?'); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 1 new account for 4 total.", + ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); - it("returns to the login menu when backup reassessment fails before confirmation", async () => { + it("returns to the login menu when preview reassessment fails before confirmation", async () => { setInteractiveTTY(true); const now = Date.now(); loadAccountsMock.mockResolvedValue({ @@ -5056,42 +5239,53 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) .mockResolvedValueOnce({ mode: "cancel" }); - selectMock.mockResolvedValueOnce({ - type: "restore", - assessment: initialAssessment, - }); + selectMock + .mockImplementationOnce(async () => ({ + type: "inspect", + entry: { + kind: "named", + label: "named-backup", + backup: initialAssessment.backup, + assessment: initialAssessment, + }, + })) + .mockImplementationOnce(async () => "preview-restore"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(confirmMock).not.toHaveBeenCalled(); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Failed to re-assess backup "named-backup" before restore (EBUSY).', - ), - ); + ); + expect(confirmMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to re-assess backup "named-backup" before restore (EBUSY).', + ), + ); + } finally { + warnSpy.mockRestore(); + } }); it("shows epoch backup timestamps in restore hints", async () => { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 08032032..17704555 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -719,6 +719,7 @@ describe("storage recovery paths", () => { ); await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8"); + await new Promise((resolve) => setTimeout(resolve, 20)); await fs.writeFile( `${storagePath}.manual-meta-checkpoint`, JSON.stringify({ diff --git a/test/storage.test.ts b/test/storage.test.ts index 4754fbb4..f56ca8c8 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1210,6 +1210,92 @@ describe("storage", () => { expect(assessment.imported).toBe(1); expect(assessment.skipped).toBe(0); expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.currentActiveIndex).toBeNull(); + expect(assessment.nextActiveIndex).toBe(0); + expect(assessment.activeAccountOutcome).toBe("changed"); + expect(assessment.activeAccountChanged).toBe(true); + expect(assessment.namedBackupRestorePreview?.activeAccount).toMatchObject({ + outcome: "changed", + changed: true, + current: null, + next: expect.objectContaining({ + index: 0, + accountId: "backup-account", + }), + }); + }); + + it("allows replace-only named backup restores and exposes preview conflict details", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "Replace Only.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + email: "replace@example.com", + accountId: "replace-account", + refreshToken: "refresh-replaced", + addedAt: 20, + lastUsed: 20, + }, + ], + }), + "utf-8", + ); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + email: "replace@example.com", + accountId: "replace-account", + refreshToken: "refresh-current", + addedAt: 10, + lastUsed: 10, + }, + ], + }); + + const assessment = await assessNamedBackupRestore("Replace Only"); + + expect(assessment.backupAccountCount).toBe(1); + expect(assessment.dedupedBackupAccountCount).toBe(1); + expect(assessment.imported).toBe(0); + expect(assessment.skipped).toBe(0); + expect(assessment.replacedExistingCount).toBe(1); + expect(assessment.eligibleForRestore).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + expect(assessment.activeAccountOutcome).toBe("unchanged"); + expect(assessment.activeAccountChanged).toBe(false); + expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([ + expect.objectContaining({ + conflict: expect.objectContaining({ + backupIndex: 0, + currentIndex: 0, + resolution: "backup-kept", + reasons: expect.arrayContaining(["accountId", "email"]), + }), + }), + ]); + + const restoreResult = await restoreNamedBackup("Replace Only"); + expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 }); + + const restored = await loadAccounts(); + expect(restored?.accounts).toEqual([ + expect.objectContaining({ + accountId: "replace-account", + refreshToken: "refresh-replaced", + }), + ]); }); it("restores manually named backups that already exist inside the backups directory", async () => { @@ -1316,7 +1402,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/Import file not found/); + ).rejects.toThrow(/ENOENT: no such file or directory/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1344,10 +1430,80 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/Invalid JSON in import file/); + ).rejects.toThrow(/is not valid JSON/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); + it("reassesses named restores at mutation time when the current pool grows past the limit", async () => { + const backupPath = join( + dirname(testStoragePath), + "backups", + "limit-race.json", + ); + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + email: "from-backup@example.com", + accountId: "from-backup", + refreshToken: "refresh-from-backup", + addedAt: 100, + lastUsed: 100, + }, + ], + }), + "utf-8", + ); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_value, index) => ({ + email: `current-${index}@example.com`, + accountId: `current-${index}`, + refreshToken: `refresh-current-${index}`, + addedAt: index, + lastUsed: index, + }), + ), + }); + + const initialAssessment = await assessNamedBackupRestore("limit-race"); + expect(initialAssessment.eligibleForRestore).toBe(true); + expect(initialAssessment.wouldExceedLimit).toBe(false); + expect(initialAssessment.imported).toBe(1); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from({ length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, (_value, index) => ({ + email: `expanded-${index}@example.com`, + accountId: `expanded-${index}`, + refreshToken: `refresh-expanded-${index}`, + addedAt: index + 1_000, + lastUsed: index + 1_000, + })), + }); + + await expect(restoreNamedBackup("limit-race")).rejects.toThrow( + `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, + ); + + const persisted = await loadAccounts(); + expect(persisted?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + persisted?.accounts.some( + (account) => account.accountId === "from-backup", + ), + ).toBe(false); + }); + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( "rejects backup names that escape the backups directory: %s", async (input) => { From a9a0fb9abfddbca125fd83bf6ddcd0a48dc99075 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 06:02:34 +0800 Subject: [PATCH 13/76] feat(auth): add restore preview --- lib/storage.ts | 39 +++++++++++++---------------- test/storage-recovery-paths.test.ts | 2 +- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 132289a4..57fc9eae 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -209,6 +209,7 @@ interface LoadedBackupCandidate { normalized: AccountStorageV3 | null; storedVersion: unknown; schemaErrors: string[]; + rawAccounts?: AccountLike[]; error?: string; errorCode?: string; } @@ -233,6 +234,7 @@ function createUnloadedBackupCandidate(): LoadedBackupCandidate { normalized: null, storedVersion: null, schemaErrors: [], + rawAccounts: [], }; } @@ -2168,31 +2170,11 @@ export async function assessNamedBackupRestore( options.currentStorage !== undefined ? options.currentStorage : await loadAccounts(); - let previewAccounts: AccountLike[] = []; - try { - const rawContent = await fs.readFile(backupPath, "utf-8"); - const parsed = JSON.parse(rawContent) as unknown; - if (isRecord(parsed) && Array.isArray(parsed.accounts)) { - previewAccounts = (parsed.accounts as unknown[]).filter( - (account): account is AccountLike => - isRecord(account) && - typeof account.refreshToken === "string" && - account.refreshToken.trim().length > 0, - ); - } - } catch (error) { - if (!candidate.normalized) { - log.debug("Failed to parse backup for restore preview", { - path: backupPath, - error: String(error), - }); - } - } return assessNamedBackupRestoreCandidate( backup, candidate, currentStorage, - previewAccounts, + candidate.rawAccounts ?? [], ); } @@ -2488,10 +2470,22 @@ async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; schemaErrors: string[]; + rawAccounts: AccountLike[]; }> { const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as unknown; - return parseAndNormalizeStorage(data); + return { + ...parseAndNormalizeStorage(data), + rawAccounts: + isRecord(data) && Array.isArray(data.accounts) + ? (data.accounts as unknown[]).filter( + (account): account is AccountLike => + isRecord(account) && + typeof account.refreshToken === "string" && + account.refreshToken.trim().length > 0, + ) + : [], + }; } async function loadBackupCandidate(path: string): Promise { @@ -2508,6 +2502,7 @@ async function loadBackupCandidate(path: string): Promise normalized: null, storedVersion: undefined, schemaErrors: [], + rawAccounts: [], error: String(error), errorCode, }; diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 17704555..b7f11b35 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -747,7 +747,7 @@ describe("storage recovery paths", () => { const accountSnapshots = metadata.accounts.snapshots; const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache")); expect(cacheEntries).toHaveLength(0); - expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.manual-meta-checkpoint`); + expect(metadata.accounts.latestValidPath).not.toBe(`${storagePath}.cache`); const discovered = accountSnapshots.find((snapshot) => snapshot.path.endsWith("manual-meta-checkpoint")); expect(discovered?.kind).toBe("accounts-discovered-backup"); expect(discovered?.valid).toBe(true); From e80f021abd20eb82b85a94a48c6d9acfb8871a98 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 02:02:13 +0800 Subject: [PATCH 14/76] fix(restore): reuse previewed backup state --- lib/codex-manager.ts | 31 +++-- lib/storage.ts | 209 +++++++++++++++------------- test/codex-manager-cli.test.ts | 125 +++++++++++------ test/storage-recovery-paths.test.ts | 12 +- test/storage.test.ts | 96 ++++++++++++- 5 files changed, 325 insertions(+), 148 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 77c8691e..2e575311 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4943,7 +4943,7 @@ function buildRestoreAssessmentLines( const parts: string[] = []; if (replacements > 0) { parts.push( - `${replacements} replace current${replacements === 1 ? "" : " accounts"}`, + `${replacements} current account${replacements === 1 ? "" : "s"} replaced`, ); } if (withExisting > 0) { @@ -4987,6 +4987,21 @@ function canRestoreFromAssessment( ); } +function formatNamedBackupRestoreResult( + result: { imported: number; skipped: number; total: number }, + assessment: NamedBackupAssessment, +): string { + const replacedCount = assessment.replacedExistingCount ?? 0; + if ( + result.imported === 0 && + replacedCount > 0 && + result.skipped === replacedCount + ) { + return `Replaced ${replacedCount} current account${replacedCount === 1 ? "" : "s"}. Total accounts: ${result.total}.`; + } + return `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`; +} + async function showBackupBrowserDetails( entry: BackupBrowserEntry, displaySettings: DashboardDisplaySettings, @@ -5053,8 +5068,8 @@ async function showBackupBrowserDetails( } const action = await select( [ - { label: "Back", value: "back" }, { label: "Preview Restore", value: "preview-restore", color: "green" }, + { label: "Back", value: "back" }, ], { message: "Backup Actions", @@ -5120,10 +5135,8 @@ async function runBackupRestorePreview( "Restore Backup", `Restoring ${entry.label}`, async () => { - const result = await restoreNamedBackup(backupName); - console.log( - `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, - ); + const result = await restoreNamedBackup(backupName, { assessment }); + console.log(formatNamedBackupRestoreResult(result, assessment)); }, displaySettings, ); @@ -5355,9 +5368,11 @@ async function runBackupBrowserManager( "Restore Backup", `Restoring ${backupName}`, async () => { - const result = await restoreNamedBackup(backupName); + const result = await restoreNamedBackup(backupName, { + assessment: latestAssessment, + }); console.log( - `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, + formatNamedBackupRestoreResult(result, latestAssessment), ); }, displaySettings, diff --git a/lib/storage.ts b/lib/storage.ts index 57fc9eae..dc5402d2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -320,18 +320,6 @@ function getAccountConflictReasons( return [...reasons]; } -function assessmentHasRestorableChanges( - assessment: Pick< - BackupRestoreAssessment, - "imported" | "replacedExistingCount" - >, -): boolean { - return ( - (assessment.imported ?? 0) > 0 || - (assessment.replacedExistingCount ?? 0) > 0 - ); -} - function buildFailedBackupRestoreAssessment( backup: NamedBackupMetadata, currentStorage: AccountStorageV3 | null, @@ -2082,11 +2070,7 @@ export async function getActionableNamedBackupRestores( const actionable: BackupRestoreAssessment[] = []; const allAssessments: BackupRestoreAssessment[] = []; const maybePushActionable = (assessment: BackupRestoreAssessment): void => { - if ( - assessment.eligibleForRestore && - !assessment.wouldExceedLimit && - assessmentHasRestorableChanges(assessment) - ) { + if (assessment.eligibleForRestore) { actionable.push(assessment); } }; @@ -2444,13 +2428,22 @@ function assessNamedBackupRestoreCandidate( export async function restoreNamedBackup( name: string, + options: { assessment?: BackupRestoreAssessment } = {}, ): Promise<{ imported: number; total: number; skipped: number }> { const backupPath = await resolveNamedBackupRestorePath(name); - const assessment = await assessNamedBackupRestore(name); - if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) { - throw new Error(assessment.error ?? "Backup is not eligible for restore"); + const candidate = await loadImportableBackupCandidate(backupPath); + if (options.assessment !== undefined) { + const assessment = assessNamedBackupRestoreCandidate( + await buildNamedBackupMetadata(name, backupPath, { candidate }), + candidate, + await loadAccounts(), + candidate.rawAccounts, + ); + if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) { + throw new Error(assessment.error ?? "Backup is not eligible for restore"); + } } - return importAccounts(backupPath); + return importNormalizedAccounts(candidate.normalized, backupPath); } function parseAndNormalizeStorage(data: unknown): { @@ -2466,6 +2459,17 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +function extractRawAccounts(data: unknown): AccountLike[] { + return isRecord(data) && Array.isArray(data.accounts) + ? (data.accounts as unknown[]).filter( + (account): account is AccountLike => + isRecord(account) && + typeof account.refreshToken === "string" && + account.refreshToken.trim().length > 0, + ) + : []; +} + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -2476,18 +2480,98 @@ async function loadAccountsFromPath(path: string): Promise<{ const data = JSON.parse(content) as unknown; return { ...parseAndNormalizeStorage(data), - rawAccounts: - isRecord(data) && Array.isArray(data.accounts) - ? (data.accounts as unknown[]).filter( - (account): account is AccountLike => - isRecord(account) && - typeof account.refreshToken === "string" && - account.refreshToken.trim().length > 0, - ) - : [], + rawAccounts: extractRawAccounts(data), }; } +type ImportableBackupCandidate = LoadedBackupCandidate & { + normalized: AccountStorageV3; + rawAccounts: AccountLike[]; +}; + +async function loadImportableBackupCandidate( + path: string, +): Promise { + const resolvedPath = resolvePath(path); + + if (!existsSync(resolvedPath)) { + throw new Error(`Import file not found: ${resolvedPath}`); + } + + return retryTransientFilesystemOperation(async () => { + const content = await fs.readFile(resolvedPath, "utf-8"); + + let imported: unknown; + try { + imported = JSON.parse(content); + } catch { + throw new Error(`Invalid JSON in import file: ${resolvedPath}`); + } + + const { normalized, storedVersion, schemaErrors } = + parseAndNormalizeStorage(imported); + if (!normalized) { + throw new Error("Invalid account storage format"); + } + + return { + normalized, + storedVersion, + schemaErrors, + rawAccounts: extractRawAccounts(imported), + }; + }); +} + +async function importNormalizedAccounts( + normalized: AccountStorageV3, + sourcePath: string, +): Promise<{ imported: number; total: number; skipped: number }> { + const { + imported: importedCount, + total, + skipped: skippedCount, + } = await withAccountStorageTransaction(async (existing, persist) => { + const existingAccounts = existing?.accounts ?? []; + const existingActiveIndex = existing?.activeIndex ?? 0; + + const merged = [...existingAccounts, ...normalized.accounts]; + + if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + const deduped = deduplicateAccounts(merged); + if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + ); + } + } + + const deduplicatedAccounts = deduplicateAccounts(merged); + + const newStorage: AccountStorageV3 = { + version: 3, + accounts: deduplicatedAccounts, + activeIndex: existingActiveIndex, + activeIndexByFamily: existing?.activeIndexByFamily, + }; + + await persist(newStorage); + + const imported = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + return { imported, total: deduplicatedAccounts.length, skipped }; + }); + + log.info("Imported accounts", { + path: sourcePath, + imported: importedCount, + skipped: skippedCount, + total, + }); + + return { imported: importedCount, total, skipped: skippedCount }; +} + async function loadBackupCandidate(path: string): Promise { try { return await retryTransientFilesystemOperation(() => @@ -3521,67 +3605,6 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); - - // Check file exists with friendly error - if (!existsSync(resolvedPath)) { - throw new Error(`Import file not found: ${resolvedPath}`); - } - - const content = await fs.readFile(resolvedPath, "utf-8"); - - let imported: unknown; - try { - imported = JSON.parse(content); - } catch { - throw new Error(`Invalid JSON in import file: ${resolvedPath}`); - } - - const normalized = normalizeAccountStorage(imported); - if (!normalized) { - throw new Error("Invalid account storage format"); - } - - const { - imported: importedCount, - total, - skipped: skippedCount, - } = await withAccountStorageTransaction(async (existing, persist) => { - const existingAccounts = existing?.accounts ?? []; - const existingActiveIndex = existing?.activeIndex ?? 0; - - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } - } - - const deduplicatedAccounts = deduplicateAccounts(merged); - - const newStorage: AccountStorageV3 = { - version: 3, - accounts: deduplicatedAccounts, - activeIndex: existingActiveIndex, - activeIndexByFamily: existing?.activeIndexByFamily, - }; - - await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; - }); - - log.info("Imported accounts", { - path: resolvedPath, - imported: importedCount, - skipped: skippedCount, - total, - }); - - return { imported: importedCount, total, skipped: skippedCount }; + const candidate = await loadImportableBackupCandidate(resolvedPath); + return importNormalizedAccounts(candidate.normalized, resolvedPath); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d95aeddb..84f400d2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1043,7 +1043,10 @@ describe("codex manager cli commands", () => { "named-backup", expect.objectContaining({ currentStorage: null }), ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); }); it("returns a non-zero exit code when the direct restore-backup command fails", async () => { @@ -1098,7 +1101,10 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(1); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); }); it("runs restore preview before applying a replace-only named backup", async () => { @@ -1223,42 +1229,58 @@ describe("codex manager cli commands", () => { }) .mockResolvedValueOnce("preview-restore"); confirmMock.mockResolvedValueOnce(true); + restoreNamedBackupMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); - expect(exitCode).toBe(0); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalledTimes(2); - expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ - message: "Backup Browser", - }); - expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ - message: "Backup Actions", - subtitle: "named-backup", - }); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Backup Browser", + }); + expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ + message: "Backup Actions", + subtitle: "named-backup", + }); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(confirmMock).toHaveBeenCalledWith( - "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", - ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + ); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); + expect(logSpy).toHaveBeenCalledWith( + "Replaced 1 current account. Total accounts: 1.", + ); + } finally { + logSpy.mockRestore(); + } }); it("restores healthy flagged accounts into active storage", async () => { @@ -3212,7 +3234,10 @@ describe("codex manager cli commands", () => { expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledTimes(2); expect(selectMock).toHaveBeenCalled(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); @@ -3601,7 +3626,10 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledTimes(3); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "startup-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); expect(warnSpy).toHaveBeenCalledWith( 'Failed to restore backup "startup-backup" (EBUSY).', ); @@ -4709,7 +4737,10 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); }); it("offers backup restore from the login menu when no accounts are saved", async () => { @@ -4754,7 +4785,10 @@ describe("codex manager cli commands", () => { "named-backup", expect.objectContaining({ currentStorage: null }), ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); }); it("does not restore a named backup when confirmation is declined", async () => { @@ -4881,7 +4915,10 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); expect(warnSpy).toHaveBeenCalledWith( 'Failed to restore backup "named-backup" (UNKNOWN).', ); @@ -5000,7 +5037,10 @@ describe("codex manager cli commands", () => { expect(selectMock).toHaveBeenCalledTimes(1); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("healthy-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "healthy-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -5188,7 +5228,10 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledWith( "Restore named-backup? Import 1 new account for 4 total.", ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ assessment: expect.anything() }), + ); }); it("returns to the login menu when preview reassessment fails before confirmation", async () => { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index b7f11b35..0c6e414c 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -719,7 +719,8 @@ describe("storage recovery paths", () => { ); await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8"); - await new Promise((resolve) => setTimeout(resolve, 20)); + const cacheMtime = new Date(Date.now() - 5_000); + await fs.utimes(`${storagePath}.cache`, cacheMtime, cacheMtime); await fs.writeFile( `${storagePath}.manual-meta-checkpoint`, JSON.stringify({ @@ -747,7 +748,9 @@ describe("storage recovery paths", () => { const accountSnapshots = metadata.accounts.snapshots; const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache")); expect(cacheEntries).toHaveLength(0); - expect(metadata.accounts.latestValidPath).not.toBe(`${storagePath}.cache`); + expect(metadata.accounts.latestValidPath).toBe( + `${storagePath}.manual-meta-checkpoint`, + ); const discovered = accountSnapshots.find((snapshot) => snapshot.path.endsWith("manual-meta-checkpoint")); expect(discovered?.kind).toBe("accounts-discovered-backup"); expect(discovered?.valid).toBe(true); @@ -768,7 +771,6 @@ describe("storage recovery paths", () => { }), "utf-8", ); - await new Promise((resolve) => setTimeout(resolve, 20)); await fs.writeFile( newerManualPath, JSON.stringify({ @@ -778,6 +780,10 @@ describe("storage recovery paths", () => { }), "utf-8", ); + const olderManualMtime = new Date(Date.now() - 5_000); + const newerManualMtime = new Date(Date.now() + 5_000); + await fs.utimes(olderManualPath, olderManualMtime, olderManualMtime); + await fs.utimes(newerManualPath, newerManualMtime, newerManualMtime); const metadata = await getBackupMetadata(); expect(metadata.accounts.latestValidPath).toBe(newerManualPath); diff --git a/test/storage.test.ts b/test/storage.test.ts index f56ca8c8..7e9f3d39 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1298,6 +1298,46 @@ describe("storage", () => { ]); }); + it("reuses a prevalidated assessment when restoring a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backup = await createNamedBackup("prevalidated-restore"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("prevalidated-restore"); + const originalReadFile = fs.readFile.bind(fs); + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => + originalReadFile(...(args as Parameters)), + ); + + try { + const restoreResult = await restoreNamedBackup("prevalidated-restore", { + assessment, + }); + + expect(restoreResult.total).toBe(1); + const backupReads = readFileSpy.mock.calls.filter( + ([path]) => String(path) === backup.path, + ); + expect(backupReads).toHaveLength(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("restores manually named backups that already exist inside the backups directory", async () => { const backupPath = join( dirname(testStoragePath), @@ -1402,7 +1442,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/ENOENT: no such file or directory/); + ).rejects.toThrow(/Import file not found/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1430,7 +1470,7 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/is not valid JSON/); + ).rejects.toThrow(/Invalid JSON in import file/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1491,7 +1531,9 @@ describe("storage", () => { })), }); - await expect(restoreNamedBackup("limit-race")).rejects.toThrow( + await expect( + restoreNamedBackup("limit-race", { assessment: initialAssessment }), + ).rejects.toThrow( `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, ); @@ -1705,6 +1747,54 @@ describe("storage", () => { } }); + it("retries transient backup read errors while restoring backups", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-restore-read", + refreshToken: "ref-retry-restore-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("retry-restore-read"); + await clearAccounts(); + const backupPath = join( + dirname(testStoragePath), + "backups", + "retry-restore-read.json", + ); + const originalReadFile = fs.readFile.bind(fs); + let backupReads = 0; + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === backupPath) { + backupReads += 1; + if (backupReads === 1 && busyFailures === 0) { + busyFailures += 1; + const error = new Error("backup read busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await restoreNamedBackup("retry-restore-read"); + expect(result.total).toBe(1); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("throws file-not-found when a manually named backup disappears after assessment", async () => { const backupPath = join( dirname(testStoragePath), From 687ca0329165555794ba4a063638437aaacc79d4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:39:26 +0800 Subject: [PATCH 15/76] fix(restore): honor previewed assessments --- lib/storage.ts | 22 +++++++++++++++------- test/storage.test.ts | 10 +++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index dc5402d2..9f59d6ec 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2432,18 +2432,20 @@ export async function restoreNamedBackup( ): Promise<{ imported: number; total: number; skipped: number }> { const backupPath = await resolveNamedBackupRestorePath(name); const candidate = await loadImportableBackupCandidate(backupPath); - if (options.assessment !== undefined) { - const assessment = assessNamedBackupRestoreCandidate( + const assessment = + options.assessment ?? + assessNamedBackupRestoreCandidate( await buildNamedBackupMetadata(name, backupPath, { candidate }), candidate, await loadAccounts(), candidate.rawAccounts, ); - if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) { - throw new Error(assessment.error ?? "Backup is not eligible for restore"); - } + if (!assessment.eligibleForRestore) { + throw new Error(assessment.error ?? "Backup is not eligible for restore"); } - return importNormalizedAccounts(candidate.normalized, backupPath); + return importNormalizedAccounts(candidate.normalized, backupPath, { + replacedExistingCount: assessment.replacedExistingCount ?? 0, + }); } function parseAndNormalizeStorage(data: unknown): { @@ -2526,6 +2528,7 @@ async function loadImportableBackupCandidate( async function importNormalizedAccounts( normalized: AccountStorageV3, sourcePath: string, + options: { replacedExistingCount?: number } = {}, ): Promise<{ imported: number; total: number; skipped: number }> { const { imported: importedCount, @@ -2558,7 +2561,12 @@ async function importNormalizedAccounts( await persist(newStorage); const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; + const skipped = Math.max( + 0, + normalized.accounts.length - + imported - + Math.max(0, options.replacedExistingCount ?? 0), + ); return { imported, total: deduplicatedAccounts.length, skipped }; }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7e9f3d39..bd6ff744 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1286,8 +1286,11 @@ describe("storage", () => { }), ]); - const restoreResult = await restoreNamedBackup("Replace Only"); - expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 }); + const restoreResult = await restoreNamedBackup("Replace Only", { + assessment, + }); + expect(restoreResult).toEqual({ imported: 0, skipped: 0, total: 1 }); + expect(restoreResult.skipped).toBe(assessment.skipped); const restored = await loadAccounts(); expect(restored?.accounts).toEqual([ @@ -1534,7 +1537,7 @@ describe("storage", () => { await expect( restoreNamedBackup("limit-race", { assessment: initialAssessment }), ).rejects.toThrow( - `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, + `maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, ); const persisted = await loadAccounts(); @@ -1790,6 +1793,7 @@ describe("storage", () => { const result = await restoreNamedBackup("retry-restore-read"); expect(result.total).toBe(1); expect(busyFailures).toBe(1); + expect(backupReads).toBe(2); } finally { readFileSpy.mockRestore(); } From f2c3a5b8339538a5a65f72551e89987b97d3082a Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:42:34 +0800 Subject: [PATCH 16/76] test(restore): tighten preview restore coverage --- lib/codex-manager.ts | 9 +++++---- test/codex-manager-cli.test.ts | 20 ++++++++++---------- test/storage-recovery-paths.test.ts | 5 +++-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 2e575311..f06ca897 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4798,7 +4798,8 @@ type BackupMenuAction = } | { type: "back" }; -type BackupDetailAction = "back" | "preview-restore" | "restore"; +type BackupDetailAction = "back" | "preview-restore"; +type BackupBrowserManagerAction = BackupDetailAction | "legacy-restore"; type LegacyBackupRestoreSelection = { type: "restore"; @@ -5316,7 +5317,7 @@ async function runBackupBrowserManager( } let entry: BackupBrowserEntry | null = null; - let action: BackupDetailAction = "back"; + let action: BackupBrowserManagerAction = "back"; const legacySelection = selection as unknown as LegacyBackupRestoreSelection; if (legacySelection.type === "restore" && legacySelection.assessment) { entry = { @@ -5325,7 +5326,7 @@ async function runBackupBrowserManager( backup: legacySelection.assessment.backup, assessment: legacySelection.assessment, }; - action = "restore"; + action = "legacy-restore"; } else if (selection.type === "inspect") { entry = selection.entry; action = await showBackupBrowserDetails(entry, displaySettings); @@ -5339,7 +5340,7 @@ async function runBackupBrowserManager( continue; } - if (action === "restore" && entry?.kind === "named") { + if (action === "legacy-restore" && entry?.kind === "named") { const backupName = entry.backup.name; let latestAssessment: NamedBackupAssessment; try { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 84f400d2..d44ebbd9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1045,7 +1045,7 @@ describe("codex manager cli commands", () => { ); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); }); @@ -1103,7 +1103,7 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledOnce(); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); }); @@ -1273,7 +1273,7 @@ describe("codex manager cli commands", () => { ); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); expect(logSpy).toHaveBeenCalledWith( "Replaced 1 current account. Total accounts: 1.", @@ -3236,7 +3236,7 @@ describe("codex manager cli commands", () => { expect(selectMock).toHaveBeenCalled(); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); @@ -3628,7 +3628,7 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledTimes(3); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "startup-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); expect(warnSpy).toHaveBeenCalledWith( 'Failed to restore backup "startup-backup" (EBUSY).', @@ -4739,7 +4739,7 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledOnce(); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); }); @@ -4787,7 +4787,7 @@ describe("codex manager cli commands", () => { ); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); }); @@ -4917,7 +4917,7 @@ describe("codex manager cli commands", () => { expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment }), ); expect(warnSpy).toHaveBeenCalledWith( 'Failed to restore backup "named-backup" (UNKNOWN).', @@ -5039,7 +5039,7 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledOnce(); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "healthy-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment: healthyAssessment }), ); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); @@ -5230,7 +5230,7 @@ describe("codex manager cli commands", () => { ); expect(restoreNamedBackupMock).toHaveBeenCalledWith( "named-backup", - expect.objectContaining({ assessment: expect.anything() }), + expect.objectContaining({ assessment: refreshedAssessment }), ); }); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 0c6e414c..d93356b3 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -780,8 +780,9 @@ describe("storage recovery paths", () => { }), "utf-8", ); - const olderManualMtime = new Date(Date.now() - 5_000); - const newerManualMtime = new Date(Date.now() + 5_000); + const baseMtimeMs = Date.now() - 60_000; + const olderManualMtime = new Date(baseMtimeMs); + const newerManualMtime = new Date(baseMtimeMs + 10_000); await fs.utimes(olderManualPath, olderManualMtime, olderManualMtime); await fs.utimes(newerManualPath, newerManualMtime, newerManualMtime); From c61f8bf94e2eb95317e69da4620eb056af3b95f1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:04:49 +0800 Subject: [PATCH 17/76] fix(auth): bind restore preview to live storage state --- lib/codex-manager.ts | 6 +- lib/storage.ts | 47 ++++-- test/codex-manager-cli.test.ts | 2 +- test/storage.test.ts | 286 +++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 21 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index f06ca897..c5632736 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4993,11 +4993,7 @@ function formatNamedBackupRestoreResult( assessment: NamedBackupAssessment, ): string { const replacedCount = assessment.replacedExistingCount ?? 0; - if ( - result.imported === 0 && - replacedCount > 0 && - result.skipped === replacedCount - ) { + if (result.imported === 0 && replacedCount > 0 && result.skipped === 0) { return `Replaced ${replacedCount} current account${replacedCount === 1 ? "" : "s"}. Total accounts: ${result.total}.`; } return `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`; diff --git a/lib/storage.ts b/lib/storage.ts index 9f59d6ec..20c9a9b6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1131,6 +1131,10 @@ export function getFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); } +function getFlaggedAccountsPathForStoragePath(storagePath: string): string { + return join(dirname(storagePath), FLAGGED_ACCOUNTS_FILE_NAME); +} + function getLegacyFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME); } @@ -2432,14 +2436,12 @@ export async function restoreNamedBackup( ): Promise<{ imported: number; total: number; skipped: number }> { const backupPath = await resolveNamedBackupRestorePath(name); const candidate = await loadImportableBackupCandidate(backupPath); - const assessment = - options.assessment ?? - assessNamedBackupRestoreCandidate( - await buildNamedBackupMetadata(name, backupPath, { candidate }), - candidate, - await loadAccounts(), - candidate.rawAccounts, - ); + const assessment = assessNamedBackupRestoreCandidate( + await buildNamedBackupMetadata(name, backupPath, { candidate }), + candidate, + await loadAccounts(), + candidate.rawAccounts, + ); if (!assessment.eligibleForRestore) { throw new Error(assessment.error ?? "Backup is not eligible for restore"); } @@ -2990,13 +2992,14 @@ function formatRotatingBackupLabel(slot: number): string { */ export interface SaveAccountsOptions { backupEnabled?: boolean; + pathOverride?: string; } async function saveAccountsUnlocked( storage: AccountStorageV3, options: SaveAccountsOptions = {}, ): Promise { - const path = getStoragePath(); + const path = options.pathOverride ?? getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; @@ -3139,7 +3142,9 @@ export async function withAccountStorageTransaction( }; const current = state.snapshot; const persist = async (storage: AccountStorageV3): Promise => { - await saveAccountsUnlocked(storage); + await saveAccountsUnlocked(storage, { + pathOverride: state.storagePath, + }); state.snapshot = storage; }; return transactionSnapshotContext.run(state, () => @@ -3159,6 +3164,7 @@ export async function withAccountAndFlaggedStorageTransaction( ): Promise { return withStorageLock(async () => { const storagePath = getStoragePath(); + const flaggedStoragePath = getFlaggedAccountsPathForStoragePath(storagePath); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, @@ -3171,13 +3177,19 @@ export async function withAccountAndFlaggedStorageTransaction( ): Promise => { const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); const nextAccounts = cloneAccountStorageForPersistence(accountStorage); - await saveAccountsUnlocked(nextAccounts); + await saveAccountsUnlocked(nextAccounts, { + pathOverride: state.storagePath, + }); try { - await saveFlaggedAccountsUnlocked(flaggedStorage); + await saveFlaggedAccountsUnlocked(flaggedStorage, { + pathOverride: flaggedStoragePath, + }); state.snapshot = nextAccounts; } catch (error) { try { - await saveAccountsUnlocked(previousAccounts); + await saveAccountsUnlocked(previousAccounts, { + pathOverride: state.storagePath, + }); state.snapshot = previousAccounts; } catch (rollbackError) { const combinedError = new AggregateError( @@ -3445,8 +3457,9 @@ export async function loadFlaggedAccounts(): Promise { async function saveFlaggedAccountsUnlocked( storage: FlaggedAccountStorageV1, + options: { pathOverride?: string } = {}, ): Promise { - const path = getFlaggedAccountsPath(); + const path = options.pathOverride ?? getFlaggedAccountsPath(); const markerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; @@ -3515,6 +3528,7 @@ export async function clearFlaggedAccounts(): Promise { const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); let hadError = false; + let primaryCleared = true; for (const candidate of [path, ...backupPaths]) { try { await unlinkWithRetry(candidate); @@ -3522,6 +3536,9 @@ export async function clearFlaggedAccounts(): Promise { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { hadError = true; + if (candidate === path) { + primaryCleared = false; + } log.error("Failed to clear flagged account storage", { path: candidate, error: String(error), @@ -3529,7 +3546,7 @@ export async function clearFlaggedAccounts(): Promise { } } } - if (!hadError) { + if (primaryCleared) { try { await unlinkWithRetry(markerPath); } catch (error) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d44ebbd9..1c335566 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1231,7 +1231,7 @@ describe("codex manager cli commands", () => { confirmMock.mockResolvedValueOnce(true); restoreNamedBackupMock.mockResolvedValueOnce({ imported: 0, - skipped: 1, + skipped: 0, total: 1, }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/test/storage.test.ts b/test/storage.test.ts index bd6ff744..d6bee8d5 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -618,6 +618,76 @@ describe("storage", () => { ).toEqual(new Set(["acct-a", "acct-b"])); }); + it("keeps account transactions bound to the original storage path", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary-original", + refreshToken: "ref-primary-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const alternateStoragePath = join(testWorkDir, "alternate-accounts.json"); + setStoragePathDirect(alternateStoragePath); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "alternate-original", + refreshToken: "ref-alternate-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + setStoragePathDirect(testStoragePath); + + await withAccountStorageTransaction(async (current, persist) => { + if (!current) { + throw new Error("expected existing account storage"); + } + setStoragePathDirect(alternateStoragePath); + try { + await persist({ + ...current, + accounts: [ + ...current.accounts, + { + accountId: "primary-added", + refreshToken: "ref-primary-added", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + } finally { + setStoragePathDirect(testStoragePath); + } + }); + + const primaryLoaded = await loadAccounts(); + expect(primaryLoaded?.accounts.map((account) => account.accountId)).toEqual([ + "primary-original", + "primary-added", + ]); + + setStoragePathDirect(alternateStoragePath); + try { + const alternateLoaded = await loadAccounts(); + expect( + alternateLoaded?.accounts.map((account) => account.accountId), + ).toEqual(["alternate-original"]); + } finally { + setStoragePathDirect(testStoragePath); + } + }); + it("rolls back account storage when flagged persistence keeps failing inside the combined transaction", async () => { const now = Date.now(); await saveAccounts({ @@ -859,6 +929,129 @@ describe("storage", () => { } }); + it("keeps combined transactions bound to the original account and flagged storage paths", async () => { + const now = Date.now(); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "primary-original", + email: "primary@example.com", + refreshToken: "refresh-primary-original", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "flagged-original", + email: "flagged@example.com", + refreshToken: "refresh-flagged-original", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + + const alternateStoragePath = join( + testWorkDir, + "alternate-combined", + "alternate-combined.json", + ); + setStoragePathDirect(alternateStoragePath); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "alternate-original", + email: "alternate@example.com", + refreshToken: "refresh-alternate-original", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "alternate-flagged", + email: "alternate-flagged@example.com", + refreshToken: "refresh-alternate-flagged", + addedAt: now - 500, + lastUsed: now - 500, + flaggedAt: now - 500, + }, + ], + }); + setStoragePathDirect(testStoragePath); + + await withAccountAndFlaggedStorageTransaction(async (current, persist) => { + if (!current) { + throw new Error("expected existing account storage"); + } + setStoragePathDirect(alternateStoragePath); + try { + await persist( + { + ...current, + accounts: [ + ...current.accounts, + { + accountId: "primary-added", + email: "primary-added@example.com", + refreshToken: "refresh-primary-added", + addedAt: now, + lastUsed: now, + }, + ], + }, + { + version: 1, + accounts: [], + }, + ); + } finally { + setStoragePathDirect(testStoragePath); + } + }); + + const primaryLoaded = await loadAccounts(); + expect(primaryLoaded?.accounts.map((account) => account.accountId)).toEqual([ + "primary-original", + "primary-added", + ]); + await expect(loadFlaggedAccounts()).resolves.toMatchObject({ + accounts: [], + }); + + setStoragePathDirect(alternateStoragePath); + try { + const alternateLoaded = await loadAccounts(); + expect( + alternateLoaded?.accounts.map((account) => account.accountId), + ).toEqual(["alternate-original"]); + await expect(loadFlaggedAccounts()).resolves.toMatchObject({ + accounts: [ + expect.objectContaining({ + accountId: "alternate-flagged", + refreshToken: "refresh-alternate-flagged", + }), + ], + }); + } finally { + setStoragePathDirect(testStoragePath); + } + }); + it("retries transient flagged storage rename and succeeds", async () => { const now = Date.now(); await saveFlaggedAccounts({ @@ -1477,6 +1670,40 @@ describe("storage", () => { expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); + it("reassesses named backup contents when a previously previewed backup becomes empty", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "preview-account", + refreshToken: "refresh-preview-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const backup = await createNamedBackup("emptied-after-assessment"); + await clearAccounts(); + const assessment = await assessNamedBackupRestore("emptied-after-assessment"); + expect(assessment.eligibleForRestore).toBe(true); + + await fs.writeFile( + backup.path, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [], + }), + "utf-8", + ); + + await expect( + restoreNamedBackup("emptied-after-assessment", { assessment }), + ).rejects.toThrow(/Backup is empty or invalid/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); + }); + it("reassesses named restores at mutation time when the current pool grows past the limit", async () => { const backupPath = join( dirname(testStoragePath), @@ -2925,6 +3152,65 @@ describe("storage", () => { } }, ); + + it("removes the flagged reset marker when only backup cleanup fails", async () => { + const flaggedPath = getFlaggedAccountsPath(); + const markerPath = `${flaggedPath}.reset-intent`; + const backupPath = `${flaggedPath}.bak`; + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile( + flaggedPath, + JSON.stringify({ + version: 1, + accounts: [ + { + accountId: "flagged-primary", + refreshToken: "refresh-flagged-primary", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }), + ); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 1, + accounts: [ + { + accountId: "flagged-backup", + refreshToken: "refresh-flagged-backup", + addedAt: 2, + lastUsed: 2, + flaggedAt: 2, + }, + ], + }), + ); + + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === backupPath) { + const error = new Error("backup busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + } finally { + unlinkSpy.mockRestore(); + } + + expect(existsSync(flaggedPath)).toBe(false); + expect(existsSync(backupPath)).toBe(true); + expect(existsSync(markerPath)).toBe(false); + }); }); describe("setStoragePath", () => { From ddca70d1a0c1a7f77c47cf65c1cb7b1b13c107d0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:23:17 +0800 Subject: [PATCH 18/76] feat(auth): add backup restore manager --- docs/reference/public-api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. From 137bed5268b50512c6657ef31951cef4c227b23d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:41:55 +0800 Subject: [PATCH 19/76] feat(auth): prompt for recovery on startup --- docs/reference/commands.md | 2 -- docs/reference/public-api.md | 6 ------ docs/reference/storage-paths.md | 4 ---- test/cli.test.ts | 10 ---------- 4 files changed, 22 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ab142c2b..131cb47a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,7 +26,6 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | -| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -122,7 +121,6 @@ codex auth report --live --json Repair and recovery: ```bash -codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a76eb85a..865189ff 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,12 +37,6 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. -Current additive compatibility note: - -- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. -- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. -- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. - ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 8ea743ec..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,10 +109,6 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. -Direct entrypoint: - -- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. - --- ## oc-chatgpt Target Paths diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,16 +716,6 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); - - mockRl.question.mockResolvedValueOnce("backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); - - mockRl.question.mockResolvedValueOnce("restore-backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { From 5103a9415d4d8d64aaffcf0457c7f1ba6274568d Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:22:01 +0800 Subject: [PATCH 20/76] fix(auth): clean startup recovery prompt branch --- docs/reference/commands.md | 2 ++ docs/reference/storage-paths.md | 4 +++ test/cli.test.ts | 10 ++++++ test/codex-manager-cli.test.ts | 55 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 131cb47a..ab142c2b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -121,6 +122,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index cf0747de..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,6 +109,10 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths diff --git a/test/cli.test.ts b/test/cli.test.ts index 269a0eba..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,6 +716,16 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1c335566..04074ef7 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1283,6 +1283,61 @@ describe("codex manager cli commands", () => { } }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ From 1ba1f68a1c9ad6b10a2680217fbe190eb6c37b3a Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 06:20:50 +0800 Subject: [PATCH 21/76] feat(auth): snapshot before destructive actions --- lib/destructive-actions.ts | 4 + lib/storage.ts | 95 ++++++++ test/destructive-actions.test.ts | 27 +++ test/storage.test.ts | 359 +++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 66a8571e..c0392fdd 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -10,6 +10,7 @@ import { loadFlaggedAccounts, saveAccounts, saveFlaggedAccounts, + snapshotAccountStorage, } from "./storage.js"; export const DESTRUCTIVE_ACTION_COPY = { @@ -111,6 +112,7 @@ export async function deleteAccountAtIndex(options: { const target = options.storage.accounts.at(options.index); if (!target) return null; const flagged = await loadFlaggedAccounts(); + await snapshotAccountStorage({ reason: "delete-account" }); const nextStorage: AccountStorageV3 = { ...options.storage, accounts: options.storage.accounts.map((account) => ({ ...account })), @@ -172,6 +174,7 @@ export async function deleteAccountAtIndex(options: { * Removes the accounts WAL and backups via the underlying storage helper. */ export async function deleteSavedAccounts(): Promise { + await snapshotAccountStorage({ reason: "delete-saved-accounts" }); return { accountsCleared: await clearAccounts(), flaggedCleared: false, @@ -184,6 +187,7 @@ export async function deleteSavedAccounts(): Promise { * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. */ export async function resetLocalState(): Promise { + await snapshotAccountStorage({ reason: "reset-local-state" }); const accountsCleared = await clearAccounts(); const flaggedCleared = await clearFlaggedAccounts(); const quotaCacheCleared = await clearQuotaCache(); diff --git a/lib/storage.ts b/lib/storage.ts index 20c9a9b6..5feab12d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -205,6 +205,22 @@ export interface ActionableNamedBackupRecoveries { totalBackups: number; } +export type AccountSnapshotReason = + | "delete-account" + | "delete-saved-accounts" + | "reset-local-state" + | "import-accounts"; + +export type AccountSnapshotFailurePolicy = "warn" | "error"; + +export interface AccountSnapshotOptions { + reason: AccountSnapshotReason; + now?: number; + force?: boolean; + failurePolicy?: AccountSnapshotFailurePolicy; + createBackup?: typeof createNamedBackup; +} + interface LoadedBackupCandidate { normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -2143,6 +2159,83 @@ export async function createNamedBackup( ); } +function formatTimestampForSnapshot(timestamp: number): string { + const date = new Date(timestamp); + const pad = (value: number): string => value.toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); + return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}_${milliseconds}`; +} + +function buildAccountSnapshotName( + reason: AccountSnapshotReason, + timestamp: number, +): string { + return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`; +} + +function extractPathTail(pathValue: string): string { + const segments = pathValue.split(/[\\/]+/).filter(Boolean); + return segments.at(-1) ?? pathValue; +} + +function redactFilesystemDetails(value: string): string { + return value.replace( + /(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)+/g, + (pathValue) => extractPathTail(pathValue), + ); +} + +function formatSnapshotErrorForLog(error: unknown): string { + const code = + typeof (error as NodeJS.ErrnoException | undefined)?.code === "string" + ? (error as NodeJS.ErrnoException).code + : undefined; + const rawMessage = + error instanceof Error ? error.message : String(error ?? "unknown error"); + const redactedMessage = redactFilesystemDetails(rawMessage); + if (code && !redactedMessage.includes(code)) { + return `${code}: ${redactedMessage}`; + } + return redactedMessage; +} + +export async function snapshotAccountStorage( + options: AccountSnapshotOptions, +): Promise { + const { + reason, + now = Date.now(), + force = true, + failurePolicy = "warn", + createBackup = createNamedBackup, + } = options; + const currentStorage = await loadAccounts(); + if (!currentStorage || currentStorage.accounts.length === 0) { + return null; + } + + const backupName = buildAccountSnapshotName(reason, now); + try { + return await createBackup(backupName, { force }); + } catch (error) { + if (failurePolicy === "error") { + throw error; + } + log.warn("Failed to create account storage snapshot", { + reason, + backupName, + error: formatSnapshotErrorForLog(error), + }); + return null; + } +} + export async function assessNamedBackupRestore( name: string, options: { currentStorage?: AccountStorageV3 | null } = {}, @@ -3630,6 +3723,8 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); + const candidate = await loadImportableBackupCandidate(resolvedPath); + await snapshotAccountStorage({ reason: "import-accounts" }); return importNormalizedAccounts(candidate.normalized, resolvedPath); } diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 50083d9d..48795daa 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -7,6 +7,7 @@ const clearCodexCliStateCacheMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); +const snapshotAccountStorageMock = vi.fn(); vi.mock("../lib/codex-cli/state.js", () => ({ clearCodexCliStateCache: clearCodexCliStateCacheMock, @@ -26,6 +27,7 @@ vi.mock("../lib/storage.js", () => ({ loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, + snapshotAccountStorage: snapshotAccountStorageMock, })); describe("destructive actions", () => { @@ -38,6 +40,7 @@ describe("destructive actions", () => { loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); saveAccountsMock.mockResolvedValue(undefined); saveFlaggedAccountsMock.mockResolvedValue(undefined); + snapshotAccountStorageMock.mockResolvedValue(null); }); it("returns delete-only results without pretending kept data was cleared", async () => { @@ -50,7 +53,15 @@ describe("destructive actions", () => { flaggedCleared: false, quotaCacheCleared: false, }); + expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ + reason: "delete-saved-accounts", + }); expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect( + snapshotAccountStorageMock.mock.invocationCallOrder[0], + ).toBeLessThan( + clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); expect(clearQuotaCacheMock).not.toHaveBeenCalled(); expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); @@ -68,7 +79,15 @@ describe("destructive actions", () => { flaggedCleared: false, quotaCacheCleared: true, }); + expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ + reason: "reset-local-state", + }); expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect( + snapshotAccountStorageMock.mock.invocationCallOrder[0], + ).toBeLessThan( + clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1); @@ -120,6 +139,14 @@ describe("destructive actions", () => { const deleted = await deleteAccountAtIndex({ storage, index: 0 }); expect(deleted).not.toBeNull(); + expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ + reason: "delete-account", + }); + expect( + snapshotAccountStorageMock.mock.invocationCallOrder[0], + ).toBeLessThan( + saveAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-active", "refresh-other", diff --git a/test/storage.test.ts b/test/storage.test.ts index d6bee8d5..28c4828d 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; +import { + deleteSavedAccounts, + resetLocalState, +} from "../lib/destructive-actions.js"; import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; @@ -19,6 +23,7 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + getNamedBackupsDirectoryPath, NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, @@ -30,6 +35,7 @@ import { restoreNamedBackup, resolveAccountSelectionIndex, saveFlaggedAccounts, + snapshotAccountStorage, StorageError, saveAccounts, setStoragePath, @@ -2319,6 +2325,359 @@ describe("storage", () => { }); }); + describe("account storage snapshots", () => { + const testWorkDir = join( + tmpdir(), + `codex-snapshot-${Math.random().toString(36).slice(2)}`, + ); + let testStoragePath = ""; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "openai-codex-accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + vi.restoreAllMocks(); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); + }); + + it("creates deterministic named snapshots with millisecond precision", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const fixedNow = Date.UTC(2024, 0, 2, 3, 4, 5, 123); + const snapshot = await snapshotAccountStorage({ + reason: "delete-saved-accounts", + now: fixedNow, + }); + + expect(snapshot?.name).toBe( + "accounts-delete-saved-accounts-snapshot-2024-01-02_03-04-05_123", + ); + expect(snapshot?.path && existsSync(snapshot.path)).toBe(true); + }); + + it("keeps snapshots unique for two destructive actions in the same second", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const first = await snapshotAccountStorage({ + reason: "reset-local-state", + now: Date.UTC(2024, 0, 2, 3, 4, 5, 7), + }); + const second = await snapshotAccountStorage({ + reason: "reset-local-state", + now: Date.UTC(2024, 0, 2, 3, 4, 5, 8), + }); + + expect(first?.name).not.toBe(second?.name); + const backups = await listNamedBackups(); + expect( + backups.filter((backup) => + backup.name.startsWith("accounts-reset-local-state-snapshot-"), + ), + ).toHaveLength(2); + }); + + it("skips snapshot when no accounts exist", async () => { + const snapshot = await snapshotAccountStorage({ + reason: "delete-saved-accounts", + }); + + expect(snapshot).toBeNull(); + expect(existsSync(getNamedBackupsDirectoryPath())).toBe(false); + }); + + it("returns null on warn policy when snapshot creation fails", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const failingBackup = vi + .fn() + .mockRejectedValue(new Error("snapshot failed")); + + await expect( + snapshotAccountStorage({ + reason: "reset-local-state", + createBackup: failingBackup, + }), + ).resolves.toBeNull(); + }); + + it("redacts filesystem paths from snapshot warning logs", async () => { + const warnMock = vi.fn(); + vi.resetModules(); + vi.doMock("../lib/logger.js", () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: warnMock, + error: vi.fn(), + })), + logWarn: vi.fn(), + })); + + try { + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(testStoragePath); + await isolatedStorageModule.saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const leakingPath = join( + testWorkDir, + "backups", + "accounts-reset-local-state-snapshot-2024-01-02_03-04-05_123.json", + ); + const failingBackup = vi.fn().mockRejectedValue( + Object.assign( + new Error( + `EPERM: operation not permitted, open '${leakingPath}'`, + ), + { code: "EPERM" }, + ), + ); + + await expect( + isolatedStorageModule.snapshotAccountStorage({ + reason: "reset-local-state", + createBackup: failingBackup, + }), + ).resolves.toBeNull(); + + const payload = warnMock.mock.calls[0]?.[1] as + | { error?: string; backupName?: string } + | undefined; + expect(payload?.backupName).toContain( + "accounts-reset-local-state-snapshot-", + ); + expect(payload?.error).toContain( + "accounts-reset-local-state-snapshot-2024-01-02_03-04-05_123.json", + ); + expect(payload?.error).not.toContain(testWorkDir); + } finally { + vi.doUnmock("../lib/logger.js"); + vi.resetModules(); + setStoragePathDirect(testStoragePath); + } + }); + + it("propagates snapshot failure when policy is error", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const failingBackup = vi + .fn() + .mockRejectedValue(new Error("snapshot failed")); + + await expect( + snapshotAccountStorage({ + reason: "reset-local-state", + failurePolicy: "error", + createBackup: failingBackup, + }), + ).rejects.toThrow(/snapshot failed/); + }); + + it("creates a named snapshot before deleteSavedAccounts", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await deleteSavedAccounts(); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-delete-saved-accounts-snapshot-"), + ), + ).toBe(true); + expect(await loadAccounts()).toMatchObject({ + accounts: [], + restoreReason: "intentional-reset", + }); + }); + + it("creates a named snapshot before resetLocalState", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await resetLocalState(); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-reset-local-state-snapshot-"), + ), + ).toBe(true); + expect(await loadAccounts()).toMatchObject({ + accounts: [], + restoreReason: "intentional-reset", + }); + }); + + it("creates a snapshot before importing into an existing account pool", async () => { + const importPath = join(testWorkDir, "import.json"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + importPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "imported", + refreshToken: "ref-imported", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + await importAccounts(importPath); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-import-accounts-snapshot-"), + ), + ).toBe(true); + expect((await loadAccounts())?.accounts).toHaveLength(2); + }); + + it("keeps the pre-import snapshot when the import later exceeds the limit", async () => { + const importPath = join(testWorkDir, "over-limit-import.json"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, + (_value, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + await fs.writeFile( + importPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "overflow", + refreshToken: "ref-overflow", + addedAt: 999, + lastUsed: 999, + }, + ], + }), + "utf-8", + ); + + await expect(importAccounts(importPath)).rejects.toThrow( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, + ); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-import-accounts-snapshot-"), + ), + ).toBe(true); + expect((await loadAccounts())?.accounts).toHaveLength( + ACCOUNT_LIMITS.MAX_ACCOUNTS, + ); + }); + }); + describe("filename migration (TDD)", () => { it("should migrate from old filename to new filename", async () => { // This test is tricky because it depends on the internal state of getStoragePath() From 29659e0fcc74284390e0ceb43626cd04c9504367 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:33:11 +0800 Subject: [PATCH 22/76] fix(storage): snapshot exact pre-mutation state --- lib/destructive-actions.ts | 114 ++++++++++++++----------------- lib/storage.ts | 71 +++++++++++++++++-- test/destructive-actions.test.ts | 42 ++++++++++++ test/storage.test.ts | 43 ++++++++++++ 4 files changed, 200 insertions(+), 70 deletions(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index c0392fdd..c8984e6c 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -7,10 +7,10 @@ import { clearAccounts, clearFlaggedAccounts, type FlaggedAccountStorageV1, + getStoragePath, loadFlaggedAccounts, - saveAccounts, - saveFlaggedAccounts, snapshotAccountStorage, + withAccountAndFlaggedStorageTransaction, } from "./storage.js"; export const DESTRUCTIVE_ACTION_COPY = { @@ -99,74 +99,62 @@ export interface DestructiveActionResult { quotaCacheCleared: boolean; } -function asError(error: unknown, fallbackMessage: string): Error { - return error instanceof Error - ? error - : new Error(`${fallbackMessage}: ${String(error)}`); -} - export async function deleteAccountAtIndex(options: { storage: AccountStorageV3; index: number; }): Promise { - const target = options.storage.accounts.at(options.index); - if (!target) return null; - const flagged = await loadFlaggedAccounts(); - await snapshotAccountStorage({ reason: "delete-account" }); - const nextStorage: AccountStorageV3 = { - ...options.storage, - accounts: options.storage.accounts.map((account) => ({ ...account })), - activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, - }; - const previousStorage: AccountStorageV3 = { - ...options.storage, - accounts: options.storage.accounts.map((account) => ({ ...account })), - activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, - }; + const requestedTarget = options.storage.accounts.at(options.index); + if (!requestedTarget) return null; - nextStorage.accounts.splice(options.index, 1); - rebaseActiveIndicesAfterDelete(nextStorage, options.index); - clampActiveIndices(nextStorage); - await saveAccounts(nextStorage); - - const remainingFlagged = flagged.accounts.filter( - (account) => account.refreshToken !== target.refreshToken, - ); - const removedFlaggedCount = flagged.accounts.length - remainingFlagged.length; - let updatedFlagged = flagged; - if (removedFlaggedCount > 0) { - updatedFlagged = { ...flagged, accounts: remainingFlagged }; - try { - await saveFlaggedAccounts(updatedFlagged); - } catch (error) { - const originalError = asError( - error, - "Failed to save flagged account storage after deleting an account", - ); - try { - await saveAccounts(previousStorage); - } catch (rollbackError) { - throw new AggregateError( - [ - originalError, - asError( - rollbackError, - "Failed to roll back account storage after flagged save failure", - ), - ], - "Deleting the account partially failed and rollback also failed.", - ); - } - throw originalError; + return withAccountAndFlaggedStorageTransaction(async (current, persist) => { + const sourceStorage = current ?? options.storage; + const targetIndex = sourceStorage.accounts.findIndex( + (account) => account.refreshToken === requestedTarget.refreshToken, + ); + if (targetIndex < 0) { + return null; + } + const target = sourceStorage.accounts[targetIndex]; + if (!target) { + return null; } - } - return { - storage: nextStorage, - flagged: updatedFlagged, - removedAccount: target, - removedFlaggedCount, - }; + const flagged = await loadFlaggedAccounts(); + await snapshotAccountStorage({ + reason: "delete-account", + storage: sourceStorage, + storagePath: getStoragePath(), + }); + + const nextStorage: AccountStorageV3 = { + ...sourceStorage, + accounts: sourceStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(sourceStorage.activeIndexByFamily ?? {}) }, + }; + + nextStorage.accounts.splice(targetIndex, 1); + rebaseActiveIndicesAfterDelete(nextStorage, targetIndex); + clampActiveIndices(nextStorage); + + const remainingFlagged = flagged.accounts.filter( + (account) => account.refreshToken !== target.refreshToken, + ); + const removedFlaggedCount = + flagged.accounts.length - remainingFlagged.length; + const updatedFlagged = + removedFlaggedCount > 0 + ? { ...flagged, accounts: remainingFlagged } + : flagged; + + await persist(nextStorage, updatedFlagged); + + return { + storage: nextStorage, + flagged: updatedFlagged, + removedAccount: target, + removedFlaggedCount, + }; + }); } /** diff --git a/lib/storage.ts b/lib/storage.ts index 5feab12d..54dac9e9 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -219,6 +219,8 @@ export interface AccountSnapshotOptions { force?: boolean; failurePolicy?: AccountSnapshotFailurePolicy; createBackup?: typeof createNamedBackup; + storage?: AccountStorageV3 | null; + storagePath?: string; } interface LoadedBackupCandidate { @@ -2148,8 +2150,15 @@ export async function getActionableNamedBackupRestores( export async function createNamedBackup( name: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + storage?: AccountStorageV3; + storagePath?: string; + } = {}, ): Promise { + if (options.storage) { + return writeNamedBackupFromStorage(name, options.storage, options); + } const backupPath = await exportNamedBackup(name, options); const candidate = await loadBackupCandidate(backupPath); return buildNamedBackupMetadata( @@ -2159,6 +2168,37 @@ export async function createNamedBackup( ); } +async function writeNamedBackupFromStorage( + name: string, + storage: AccountStorageV3, + options: { + force?: boolean; + storagePath?: string; + } = {}, +): Promise { + const storagePath = options.storagePath ?? getStoragePath(); + const backupPath = resolveNamedBackupPath(name, storagePath); + if (!options.force && existsSync(backupPath)) { + throw new Error(`File already exists: ${backupPath}`); + } + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify( + { + version: storage.version, + accounts: storage.accounts, + activeIndex: storage.activeIndex, + activeIndexByFamily: storage.activeIndexByFamily, + }, + null, + 2, + ), + { encoding: "utf-8", mode: 0o600 }, + ); + return buildNamedBackupMetadata(name, backupPath); +} + function formatTimestampForSnapshot(timestamp: number): string { const date = new Date(timestamp); const pad = (value: number): string => value.toString().padStart(2, "0"); @@ -2214,15 +2254,21 @@ export async function snapshotAccountStorage( force = true, failurePolicy = "warn", createBackup = createNamedBackup, + storagePath, } = options; - const currentStorage = await loadAccounts(); + const currentStorage = + options.storage !== undefined ? options.storage : await loadAccounts(); if (!currentStorage || currentStorage.accounts.length === 0) { return null; } const backupName = buildAccountSnapshotName(reason, now); try { - return await createBackup(backupName, { force }); + return await createBackup(backupName, { + force, + storage: currentStorage, + storagePath, + }); } catch (error) { if (failurePolicy === "error") { throw error; @@ -2623,13 +2669,24 @@ async function loadImportableBackupCandidate( async function importNormalizedAccounts( normalized: AccountStorageV3, sourcePath: string, - options: { replacedExistingCount?: number } = {}, + options: { + replacedExistingCount?: number; + snapshotReason?: AccountSnapshotReason; + } = {}, ): Promise<{ imported: number; total: number; skipped: number }> { + const { snapshotReason } = options; const { imported: importedCount, total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { + if (snapshotReason) { + await snapshotAccountStorage({ + reason: snapshotReason, + storage: existing, + storagePath: getStoragePath(), + }); + } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; @@ -3723,8 +3780,8 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); - const candidate = await loadImportableBackupCandidate(resolvedPath); - await snapshotAccountStorage({ reason: "import-accounts" }); - return importNormalizedAccounts(candidate.normalized, resolvedPath); + return importNormalizedAccounts(candidate.normalized, resolvedPath, { + snapshotReason: "import-accounts", + }); } diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 48795daa..4aa6de0c 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -8,6 +8,9 @@ const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const snapshotAccountStorageMock = vi.fn(); +const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +let transactionCurrentStorage: unknown = null; vi.mock("../lib/codex-cli/state.js", () => ({ clearCodexCliStateCache: clearCodexCliStateCacheMock, @@ -24,10 +27,13 @@ vi.mock("../lib/quota-cache.js", () => ({ vi.mock("../lib/storage.js", () => ({ clearAccounts: clearAccountsMock, clearFlaggedAccounts: clearFlaggedAccountsMock, + getStoragePath: getStoragePathMock, loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, snapshotAccountStorage: snapshotAccountStorageMock, + withAccountAndFlaggedStorageTransaction: + withAccountAndFlaggedStorageTransactionMock, })); describe("destructive actions", () => { @@ -41,6 +47,34 @@ describe("destructive actions", () => { saveAccountsMock.mockResolvedValue(undefined); saveFlaggedAccountsMock.mockResolvedValue(undefined); snapshotAccountStorageMock.mockResolvedValue(null); + transactionCurrentStorage = null; + withAccountAndFlaggedStorageTransactionMock.mockImplementation( + async (handler) => { + const previousSnapshot = structuredClone(transactionCurrentStorage); + return handler( + transactionCurrentStorage, + async (accountStorage: unknown, flaggedStorage: unknown) => { + await saveAccountsMock(accountStorage); + try { + await saveFlaggedAccountsMock(flaggedStorage); + transactionCurrentStorage = structuredClone(accountStorage); + } catch (error) { + try { + await saveAccountsMock(previousSnapshot); + transactionCurrentStorage = + structuredClone(previousSnapshot); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "Deleting the account partially failed and rollback also failed.", + ); + } + throw error; + } + }, + ); + }, + ); }); it("returns delete-only results without pretending kept data was cleared", async () => { @@ -135,12 +169,16 @@ describe("destructive actions", () => { }, ], }; + transactionCurrentStorage = structuredClone(storage); const deleted = await deleteAccountAtIndex({ storage, index: 0 }); expect(deleted).not.toBeNull(); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ reason: "delete-account", + storage, + storagePath: "/mock/openai-codex-accounts.json", }); expect( snapshotAccountStorageMock.mock.invocationCallOrder[0], @@ -204,10 +242,12 @@ describe("destructive actions", () => { }, ], }; + transactionCurrentStorage = structuredClone(storage); const deleted = await deleteAccountAtIndex({ storage, index: 1 }); expect(deleted).not.toBeNull(); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); expect(deleted?.flagged.accounts).toEqual([ expect.objectContaining({ refreshToken: "refresh-newer" }), ]); @@ -255,6 +295,7 @@ describe("destructive actions", () => { }, ], }; + transactionCurrentStorage = structuredClone(storage); await expect(deleteAccountAtIndex({ storage, index: 1 })).rejects.toBe( flaggedSaveError, @@ -307,6 +348,7 @@ describe("destructive actions", () => { }, ], }; + transactionCurrentStorage = structuredClone(storage); try { await deleteAccountAtIndex({ storage, index: 1 }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 28c4828d..a8374da4 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2531,6 +2531,49 @@ describe("storage", () => { ).rejects.toThrow(/snapshot failed/); }); + it("writes the provided storage snapshot instead of re-reading live storage", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "live", + refreshToken: "ref-live", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const snapshot = await snapshotAccountStorage({ + reason: "import-accounts", + storage: { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "provided", + refreshToken: "ref-provided", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + storagePath: testStoragePath, + }); + + expect(snapshot?.path && existsSync(snapshot.path)).toBe(true); + const snapshotContent = JSON.parse( + await fs.readFile(snapshot!.path, "utf-8"), + ) as { accounts: Array<{ accountId?: string }> }; + expect(snapshotContent.accounts).toEqual([ + expect.objectContaining({ accountId: "provided" }), + ]); + expect((await loadAccounts())?.accounts).toEqual([ + expect.objectContaining({ accountId: "live" }), + ]); + }); + it("creates a named snapshot before deleteSavedAccounts", async () => { await saveAccounts({ version: 3, From 4ad013384ff7775c0c819414d79f7656d31e587e Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 02:18:54 +0800 Subject: [PATCH 23/76] fix(storage): lock destructive snapshots with clears --- lib/destructive-actions.ts | 8 +- lib/storage.ts | 104 +++++++++++++---------- test/destructive-actions.test.ts | 32 +++---- test/storage.test.ts | 140 +++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 68 deletions(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index c8984e6c..f1aac008 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -4,12 +4,12 @@ import { clearQuotaCache } from "./quota-cache.js"; import { type AccountMetadataV3, type AccountStorageV3, - clearAccounts, clearFlaggedAccounts, type FlaggedAccountStorageV1, getStoragePath, loadFlaggedAccounts, snapshotAccountStorage, + snapshotAndClearAccounts, withAccountAndFlaggedStorageTransaction, } from "./storage.js"; @@ -162,9 +162,8 @@ export async function deleteAccountAtIndex(options: { * Removes the accounts WAL and backups via the underlying storage helper. */ export async function deleteSavedAccounts(): Promise { - await snapshotAccountStorage({ reason: "delete-saved-accounts" }); return { - accountsCleared: await clearAccounts(), + accountsCleared: await snapshotAndClearAccounts("delete-saved-accounts"), flaggedCleared: false, quotaCacheCleared: false, }; @@ -175,8 +174,7 @@ export async function deleteSavedAccounts(): Promise { * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. */ export async function resetLocalState(): Promise { - await snapshotAccountStorage({ reason: "reset-local-state" }); - const accountsCleared = await clearAccounts(); + const accountsCleared = await snapshotAndClearAccounts("reset-local-state"); const flaggedCleared = await clearFlaggedAccounts(); const quotaCacheCleared = await clearQuotaCache(); clearCodexCliStateCache(); diff --git a/lib/storage.ts b/lib/storage.ts index 54dac9e9..f334a6c7 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3382,54 +3382,74 @@ export async function saveAccounts( }); } +async function clearAccountsUnlocked(storagePath: string): Promise { + const resetMarkerPath = getIntentionalResetMarkerPath(storagePath); + const walPath = getAccountsWalPath(storagePath); + const backupPaths = + await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); + const legacyPaths = Array.from( + new Set( + [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ), + ), + ); + await fs.writeFile( + resetMarkerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + { encoding: "utf-8", mode: 0o600 }, + ); + let hadError = false; + const clearPath = async (targetPath: string): Promise => { + try { + await unlinkWithRetry(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + hadError = true; + log.error("Failed to clear account storage artifact", { + path: targetPath, + error: String(error), + }); + } + } + }; + + try { + const artifacts = Array.from( + new Set([storagePath, walPath, ...backupPaths, ...legacyPaths]), + ); + await Promise.all(artifacts.map(clearPath)); + } catch { + // Individual path cleanup is already best-effort with per-artifact logging. + } + + return !hadError; +} + +export async function snapshotAndClearAccounts( + reason: AccountSnapshotReason, +): Promise { + return withStorageLock(async () => { + const storagePath = getStoragePath(); + const currentStorage = await loadAccountsInternal(saveAccountsUnlocked); + await snapshotAccountStorage({ + reason, + storage: currentStorage, + storagePath, + }); + return clearAccountsUnlocked(storagePath); + }); +} + /** * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ export async function clearAccounts(): Promise { return withStorageLock(async () => { - const path = getStoragePath(); - const resetMarkerPath = getIntentionalResetMarkerPath(path); - const walPath = getAccountsWalPath(path); - const backupPaths = - await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - const legacyPaths = Array.from( - new Set( - [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( - (candidate): candidate is string => - typeof candidate === "string" && candidate.length > 0, - ), - ), - ); - await fs.writeFile( - resetMarkerPath, - JSON.stringify({ version: 1, createdAt: Date.now() }), - { encoding: "utf-8", mode: 0o600 }, - ); - let hadError = false; - const clearPath = async (targetPath: string): Promise => { - try { - await unlinkWithRetry(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - hadError = true; - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; - - try { - const artifacts = Array.from(new Set([path, walPath, ...backupPaths, ...legacyPaths])); - await Promise.all(artifacts.map(clearPath)); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } - - return !hadError; + return clearAccountsUnlocked(getStoragePath()); }); } diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 4aa6de0c..a7dec6d3 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const clearAccountsMock = vi.fn(); const clearFlaggedAccountsMock = vi.fn(); const clearQuotaCacheMock = vi.fn(); const clearCodexCliStateCacheMock = vi.fn(); @@ -8,6 +7,7 @@ const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const snapshotAccountStorageMock = vi.fn(); +const snapshotAndClearAccountsMock = vi.fn(); const withAccountAndFlaggedStorageTransactionMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); let transactionCurrentStorage: unknown = null; @@ -25,13 +25,13 @@ vi.mock("../lib/quota-cache.js", () => ({ })); vi.mock("../lib/storage.js", () => ({ - clearAccounts: clearAccountsMock, clearFlaggedAccounts: clearFlaggedAccountsMock, getStoragePath: getStoragePathMock, loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, snapshotAccountStorage: snapshotAccountStorageMock, + snapshotAndClearAccounts: snapshotAndClearAccountsMock, withAccountAndFlaggedStorageTransaction: withAccountAndFlaggedStorageTransactionMock, })); @@ -40,13 +40,13 @@ describe("destructive actions", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - clearAccountsMock.mockResolvedValue(true); clearFlaggedAccountsMock.mockResolvedValue(true); clearQuotaCacheMock.mockResolvedValue(true); loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); saveAccountsMock.mockResolvedValue(undefined); saveFlaggedAccountsMock.mockResolvedValue(undefined); snapshotAccountStorageMock.mockResolvedValue(null); + snapshotAndClearAccountsMock.mockResolvedValue(true); transactionCurrentStorage = null; withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { @@ -87,14 +87,8 @@ describe("destructive actions", () => { flaggedCleared: false, quotaCacheCleared: false, }); - expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ - reason: "delete-saved-accounts", - }); - expect(clearAccountsMock).toHaveBeenCalledTimes(1); - expect( - snapshotAccountStorageMock.mock.invocationCallOrder[0], - ).toBeLessThan( - clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + expect(snapshotAndClearAccountsMock).toHaveBeenCalledWith( + "delete-saved-accounts", ); expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); expect(clearQuotaCacheMock).not.toHaveBeenCalled(); @@ -102,7 +96,7 @@ describe("destructive actions", () => { }); it("returns reset results and clears Codex CLI state", async () => { - clearAccountsMock.mockResolvedValueOnce(true); + snapshotAndClearAccountsMock.mockResolvedValueOnce(true); clearFlaggedAccountsMock.mockResolvedValueOnce(false); clearQuotaCacheMock.mockResolvedValueOnce(true); @@ -113,14 +107,8 @@ describe("destructive actions", () => { flaggedCleared: false, quotaCacheCleared: true, }); - expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ - reason: "reset-local-state", - }); - expect(clearAccountsMock).toHaveBeenCalledTimes(1); - expect( - snapshotAccountStorageMock.mock.invocationCallOrder[0], - ).toBeLessThan( - clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + expect(snapshotAndClearAccountsMock).toHaveBeenCalledWith( + "reset-local-state", ); expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); @@ -136,7 +124,9 @@ describe("destructive actions", () => { const { resetLocalState } = await import("../lib/destructive-actions.js"); await expect(resetLocalState()).rejects.toBe(resetError); - expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(snapshotAndClearAccountsMock).toHaveBeenCalledWith( + "reset-local-state", + ); expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); expect(clearQuotaCacheMock).not.toHaveBeenCalled(); expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); diff --git a/test/storage.test.ts b/test/storage.test.ts index a8374da4..9ff58f76 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2574,6 +2574,71 @@ describe("storage", () => { ]); }); + it("passes the first loaded storage snapshot to createBackup when no storage is provided", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "before-reread", + refreshToken: "ref-before-reread", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + let observedStorage: + | { + accounts: Array<{ accountId?: string }>; + } + | undefined; + + const snapshot = await snapshotAccountStorage({ + reason: "reset-local-state", + createBackup: async (name, options) => { + observedStorage = options.storage as { + accounts: Array<{ accountId?: string }>; + }; + await fs.writeFile( + testStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "after-reread", + refreshToken: "ref-after-reread", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + return { + name, + path: join(getNamedBackupsDirectoryPath(), `${name}.json`), + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: options.storage?.accounts.length ?? null, + schemaErrors: [], + valid: true, + }; + }, + }); + + expect(snapshot?.accountCount).toBe(1); + expect(observedStorage?.accounts).toEqual([ + expect.objectContaining({ accountId: "before-reread" }), + ]); + expect((await loadAccounts())?.accounts).toEqual([ + expect.objectContaining({ accountId: "after-reread" }), + ]); + }); + it("creates a named snapshot before deleteSavedAccounts", async () => { await saveAccounts({ version: 3, @@ -2602,6 +2667,81 @@ describe("storage", () => { }); }); + it("clears the pre-delete snapshot before releasing the lock to newer saves", async () => { + const originalWriteFile = fs.writeFile.bind(fs); + let releaseSnapshotWrite: (() => void) | undefined; + const snapshotWriteBlocked = new Promise((resolve) => { + releaseSnapshotWrite = resolve; + }); + let snapshotWriteStarted = false; + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if ( + !snapshotWriteStarted && + String(path).includes("accounts-delete-saved-accounts-snapshot-") + ) { + snapshotWriteStarted = true; + await snapshotWriteBlocked; + } + return originalWriteFile(path, data as never, options as never); + }); + + try { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "delete-original", + refreshToken: "ref-delete-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const deletePromise = deleteSavedAccounts(); + await vi.waitFor(() => { + expect(snapshotWriteStarted).toBe(true); + }); + + const concurrentSave = saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "saved-after-delete", + refreshToken: "ref-saved-after-delete", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + releaseSnapshotWrite?.(); + + await expect(deletePromise).resolves.toMatchObject({ + accountsCleared: true, + }); + await expect(concurrentSave).resolves.toBeUndefined(); + + expect((await loadAccounts())?.accounts).toEqual([ + expect.objectContaining({ accountId: "saved-after-delete" }), + ]); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-delete-saved-accounts-snapshot-"), + ), + ).toBe(true); + } finally { + releaseSnapshotWrite?.(); + writeFileSpy.mockRestore(); + } + }); + it("creates a named snapshot before resetLocalState", async () => { await saveAccounts({ version: 3, From a048cb2078c2c441c67d2c52e6554f99959ebd9d Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:50:51 +0800 Subject: [PATCH 24/76] fix(storage): harden auto snapshot destructive flows --- lib/destructive-actions.ts | 8 ++-- lib/storage.ts | 35 +++++++++------- test/destructive-actions.test.ts | 69 +++++++++++++++++++++++++++----- test/storage.test.ts | 61 +++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 30 deletions(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index f1aac008..8f5f6050 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -5,6 +5,7 @@ import { type AccountMetadataV3, type AccountStorageV3, clearFlaggedAccounts, + findMatchingAccountIndex, type FlaggedAccountStorageV1, getStoragePath, loadFlaggedAccounts, @@ -108,10 +109,11 @@ export async function deleteAccountAtIndex(options: { return withAccountAndFlaggedStorageTransaction(async (current, persist) => { const sourceStorage = current ?? options.storage; - const targetIndex = sourceStorage.accounts.findIndex( - (account) => account.refreshToken === requestedTarget.refreshToken, + const targetIndex = findMatchingAccountIndex( + sourceStorage.accounts, + requestedTarget, ); - if (targetIndex < 0) { + if (targetIndex === undefined || targetIndex < 0) { return null; } const target = sourceStorage.accounts[targetIndex]; diff --git a/lib/storage.ts b/lib/storage.ts index f334a6c7..dc668a76 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2181,20 +2181,24 @@ async function writeNamedBackupFromStorage( if (!options.force && existsSync(backupPath)) { throw new Error(`File already exists: ${backupPath}`); } - await fs.mkdir(dirname(backupPath), { recursive: true }); - await fs.writeFile( - backupPath, - JSON.stringify( - { - version: storage.version, - accounts: storage.accounts, - activeIndex: storage.activeIndex, - activeIndexByFamily: storage.activeIndexByFamily, - }, - null, - 2, + await retryTransientFilesystemOperation(() => + fs.mkdir(dirname(backupPath), { recursive: true }), + ); + await retryTransientFilesystemOperation(() => + fs.writeFile( + backupPath, + JSON.stringify( + { + version: storage.version, + accounts: storage.accounts, + activeIndex: storage.activeIndex, + activeIndexByFamily: storage.activeIndexByFamily, + }, + null, + 2, + ), + { encoding: "utf-8", mode: 0o600 }, ), - { encoding: "utf-8", mode: 0o600 }, ); return buildNamedBackupMetadata(name, backupPath); } @@ -3395,6 +3399,7 @@ async function clearAccountsUnlocked(storagePath: string): Promise { ), ), ); + await fs.mkdir(dirname(resetMarkerPath), { recursive: true }); await fs.writeFile( resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), @@ -3409,8 +3414,8 @@ async function clearAccountsUnlocked(storagePath: string): Promise { if (code !== "ENOENT") { hadError = true; log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), + path: extractPathTail(targetPath), + error: formatSnapshotErrorForLog(error), }); } } diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index a7dec6d3..8f1d4a4f 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -24,17 +24,21 @@ vi.mock("../lib/quota-cache.js", () => ({ clearQuotaCache: clearQuotaCacheMock, })); -vi.mock("../lib/storage.js", () => ({ - clearFlaggedAccounts: clearFlaggedAccountsMock, - getStoragePath: getStoragePathMock, - loadFlaggedAccounts: loadFlaggedAccountsMock, - saveAccounts: saveAccountsMock, - saveFlaggedAccounts: saveFlaggedAccountsMock, - snapshotAccountStorage: snapshotAccountStorageMock, - snapshotAndClearAccounts: snapshotAndClearAccountsMock, - withAccountAndFlaggedStorageTransaction: - withAccountAndFlaggedStorageTransactionMock, -})); +vi.mock("../lib/storage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + clearFlaggedAccounts: clearFlaggedAccountsMock, + getStoragePath: getStoragePathMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, + snapshotAccountStorage: snapshotAccountStorageMock, + snapshotAndClearAccounts: snapshotAndClearAccountsMock, + withAccountAndFlaggedStorageTransaction: + withAccountAndFlaggedStorageTransactionMock, + }; +}); describe("destructive actions", () => { beforeEach(() => { @@ -192,6 +196,49 @@ describe("destructive actions", () => { ); }); + it("matches the selected delete target by full identity when refresh tokens collide", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "keep@example.com", + accountId: "keep-account", + refreshToken: "shared-refresh-token", + addedAt: 1, + lastUsed: 1, + }, + { + email: "remove@example.com", + accountId: "remove-account", + refreshToken: "shared-refresh-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + transactionCurrentStorage = structuredClone(storage); + + const deleted = await deleteAccountAtIndex({ storage, index: 1 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.removedAccount).toMatchObject({ + email: "remove@example.com", + accountId: "remove-account", + }); + expect(deleted?.storage.accounts).toEqual([ + expect.objectContaining({ + email: "keep@example.com", + accountId: "keep-account", + }), + ]); + }); + it("reloads flagged storage at delete time so newer flagged entries are preserved", async () => { loadFlaggedAccountsMock.mockResolvedValue({ version: 1, diff --git a/test/storage.test.ts b/test/storage.test.ts index 9ff58f76..8a088036 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2704,7 +2704,7 @@ describe("storage", () => { const deletePromise = deleteSavedAccounts(); await vi.waitFor(() => { expect(snapshotWriteStarted).toBe(true); - }); + }, { timeout: 5_000 }); const concurrentSave = saveAccounts({ version: 3, @@ -2742,6 +2742,53 @@ describe("storage", () => { } }); + it("retries transient EPERM while writing the pre-delete snapshot", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "delete-original", + refreshToken: "ref-delete-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const originalWriteFile = fs.writeFile.bind(fs); + let snapshotWriteAttempts = 0; + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if ( + String(path).includes("accounts-delete-saved-accounts-snapshot-") + ) { + snapshotWriteAttempts += 1; + if (snapshotWriteAttempts === 1) { + const error = new Error("snapshot locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + } + return originalWriteFile(path, data as never, options as never); + }); + + try { + await expect(deleteSavedAccounts()).resolves.toMatchObject({ + accountsCleared: true, + }); + expect(snapshotWriteAttempts).toBe(2); + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-delete-saved-accounts-snapshot-"), + ), + ).toBe(true); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("creates a named snapshot before resetLocalState", async () => { await saveAccounts({ version: 3, @@ -3571,6 +3618,18 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + it("creates the reset marker parent directory when the storage path is brand new", async () => { + const nestedStoragePath = join( + testWorkDir, + "brand-new", + "nested", + "accounts.json", + ); + setStoragePathDirect(nestedStoragePath); + + await expect(clearAccounts()).resolves.toBe(true); + }); + it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( "retries transient %s when clearing saved account artifacts", async (code) => { From 34a3fd30eb6454e19480c9a372a80723e456a0c9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 18:45:59 +0800 Subject: [PATCH 25/76] fix(storage): require snapshots before destructive auth changes --- lib/destructive-actions.ts | 1 + lib/storage.ts | 7 +- test/destructive-actions.test.ts | 32 ++++++ test/storage.test.ts | 181 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 8f5f6050..2e268992 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -124,6 +124,7 @@ export async function deleteAccountAtIndex(options: { const flagged = await loadFlaggedAccounts(); await snapshotAccountStorage({ reason: "delete-account", + failurePolicy: "error", storage: sourceStorage, storagePath: getStoragePath(), }); diff --git a/lib/storage.ts b/lib/storage.ts index dc668a76..583eca45 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2590,6 +2590,8 @@ export async function restoreNamedBackup( } return importNormalizedAccounts(candidate.normalized, backupPath, { replacedExistingCount: assessment.replacedExistingCount ?? 0, + snapshotReason: "import-accounts", + snapshotFailurePolicy: "error", }); } @@ -2676,9 +2678,10 @@ async function importNormalizedAccounts( options: { replacedExistingCount?: number; snapshotReason?: AccountSnapshotReason; + snapshotFailurePolicy?: AccountSnapshotFailurePolicy; } = {}, ): Promise<{ imported: number; total: number; skipped: number }> { - const { snapshotReason } = options; + const { snapshotReason, snapshotFailurePolicy = "warn" } = options; const { imported: importedCount, total, @@ -2687,6 +2690,7 @@ async function importNormalizedAccounts( if (snapshotReason) { await snapshotAccountStorage({ reason: snapshotReason, + failurePolicy: snapshotFailurePolicy, storage: existing, storagePath: getStoragePath(), }); @@ -3441,6 +3445,7 @@ export async function snapshotAndClearAccounts( const currentStorage = await loadAccountsInternal(saveAccountsUnlocked); await snapshotAccountStorage({ reason, + failurePolicy: "error", storage: currentStorage, storagePath, }); diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 8f1d4a4f..48a57beb 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -171,6 +171,7 @@ describe("destructive actions", () => { expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); expect(snapshotAccountStorageMock).toHaveBeenCalledWith({ reason: "delete-account", + failurePolicy: "error", storage, storagePath: "/mock/openai-codex-accounts.json", }); @@ -196,6 +197,37 @@ describe("destructive actions", () => { ); }); + it("aborts account deletion when the safety snapshot cannot be created", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + const snapshotError = Object.assign(new Error("snapshot locked"), { + code: "EPERM", + }); + snapshotAccountStorageMock.mockRejectedValueOnce(snapshotError); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "delete-me", + refreshToken: "refresh-delete-me", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + transactionCurrentStorage = structuredClone(storage); + + await expect(deleteAccountAtIndex({ storage, index: 0 })).rejects.toBe( + snapshotError, + ); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveFlaggedAccountsMock).not.toHaveBeenCalled(); + }); + it("matches the selected delete target by full identity when refresh tokens collide", async () => { const { deleteAccountAtIndex } = await import( "../lib/destructive-actions.js" diff --git a/test/storage.test.ts b/test/storage.test.ts index 8a088036..40081404 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1371,6 +1371,45 @@ describe("storage", () => { expect(restored?.accounts[0]?.accountId).toBe("primary"); }); + it("creates a pre-restore snapshot before restoring into an existing account pool", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "backup-account", + refreshToken: "ref-backup-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("restore-me"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current-account", + refreshToken: "ref-current-account", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const restoreResult = await restoreNamedBackup("restore-me"); + + expect(restoreResult.total).toBe(2); + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect( + entries.some((name) => + name.startsWith("accounts-import-accounts-snapshot-"), + ), + ).toBe(true); + }); + it("honors explicit null currentStorage when assessing a named backup", async () => { await saveAccounts({ version: 3, @@ -2817,6 +2856,109 @@ describe("storage", () => { }); }); + it("creates a snapshot before restoring a named backup", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current", + refreshToken: "ref-current", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("restore-with-snapshot"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "replacement", + refreshToken: "ref-replacement", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + await restoreNamedBackup("restore-with-snapshot"); + + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + const restoreSnapshot = entries.find((name) => + name.startsWith("accounts-import-accounts-snapshot-"), + ); + expect(restoreSnapshot).toBeTruthy(); + const snapshotContent = JSON.parse( + await fs.readFile( + join(getNamedBackupsDirectoryPath(), restoreSnapshot!), + "utf-8", + ), + ); + expect(snapshotContent.accounts).toEqual([ + expect.objectContaining({ + accountId: "replacement", + refreshToken: "ref-replacement", + }), + ]); + expect(await loadAccounts()).toMatchObject({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: "current", + refreshToken: "ref-current", + }), + ]), + }); + }); + + it("does not clear accounts when the pre-delete snapshot fails", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if ( + String(path).includes("accounts-delete-saved-accounts-snapshot-") + ) { + const error = new Error("snapshot failed") as NodeJS.ErrnoException; + error.code = "EROFS"; + throw error; + } + return originalWriteFile(path, data, options as never); + }); + + try { + await expect(deleteSavedAccounts()).rejects.toMatchObject({ + code: "EROFS", + }); + } finally { + writeFileSpy.mockRestore(); + } + + expect(await loadAccounts()).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountId: "primary", + refreshToken: "ref-primary", + }), + ], + }); + }); + it("creates a snapshot before importing into an existing account pool", async () => { const importPath = join(testWorkDir, "import.json"); @@ -2860,6 +3002,45 @@ describe("storage", () => { expect((await loadAccounts())?.accounts).toHaveLength(2); }); + it("keeps accounts intact when the pre-delete snapshot cannot be written", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const originalWriteFile = fs.writeFile; + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + const [path] = args; + if ( + String(path).includes("accounts-delete-saved-accounts-snapshot-") + ) { + const error = new Error("snapshot write failed") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalWriteFile(...args); + }); + + try { + await expect(deleteSavedAccounts()).rejects.toThrow("snapshot write failed"); + } finally { + writeFileSpy.mockRestore(); + } + + expect((await loadAccounts())?.accounts).toHaveLength(1); + expect(await fs.readdir(getNamedBackupsDirectoryPath())).toEqual([]); + }); + it("keeps the pre-import snapshot when the import later exceeds the limit", async () => { const importPath = join(testWorkDir, "over-limit-import.json"); await saveAccounts({ From d169dca1a02dc937fbe759023b8075d96ab8e5b7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:21:16 +0800 Subject: [PATCH 26/76] test(storage): pin win32 snapshot retry coverage --- test/storage.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/storage.test.ts b/test/storage.test.ts index 40081404..eda7e8f2 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2794,6 +2794,7 @@ describe("storage", () => { }, ], }); + const originalPlatform = process.platform; const originalWriteFile = fs.writeFile.bind(fs); let snapshotWriteAttempts = 0; const writeFileSpy = vi @@ -2813,6 +2814,7 @@ describe("storage", () => { }); try { + Object.defineProperty(process, "platform", { value: "win32" }); await expect(deleteSavedAccounts()).resolves.toMatchObject({ accountsCleared: true, }); @@ -2824,6 +2826,7 @@ describe("storage", () => { ), ).toBe(true); } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); writeFileSpy.mockRestore(); } }); From f1a21b099f83cdc9f4de2c9828822fbe3f713637 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:57:56 +0800 Subject: [PATCH 27/76] fix(storage): fail closed on import snapshots --- lib/destructive-actions.ts | 5 ++- lib/storage.ts | 1 + test/destructive-actions.test.ts | 26 +++++++++++++ test/storage.test.ts | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 2e268992..4b89e438 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -108,7 +108,10 @@ export async function deleteAccountAtIndex(options: { if (!requestedTarget) return null; return withAccountAndFlaggedStorageTransaction(async (current, persist) => { - const sourceStorage = current ?? options.storage; + if (!current) { + return null; + } + const sourceStorage = current; const targetIndex = findMatchingAccountIndex( sourceStorage.accounts, requestedTarget, diff --git a/lib/storage.ts b/lib/storage.ts index 583eca45..016cbc27 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3813,5 +3813,6 @@ export async function importAccounts( const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { snapshotReason: "import-accounts", + snapshotFailurePolicy: "error", }); } diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 48a57beb..31ac2794 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -228,6 +228,32 @@ describe("destructive actions", () => { expect(saveFlaggedAccountsMock).not.toHaveBeenCalled(); }); + it("does not delete from stale caller storage when the transaction has no current storage", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "delete-me", + refreshToken: "refresh-delete-me", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + transactionCurrentStorage = null; + + await expect(deleteAccountAtIndex({ storage, index: 0 })).resolves.toBeNull(); + expect(snapshotAccountStorageMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveFlaggedAccountsMock).not.toHaveBeenCalled(); + }); + it("matches the selected delete target by full identity when refresh tokens collide", async () => { const { deleteAccountAtIndex } = await import( "../lib/destructive-actions.js" diff --git a/test/storage.test.ts b/test/storage.test.ts index eda7e8f2..6a6a9be7 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -3005,6 +3005,70 @@ describe("storage", () => { expect((await loadAccounts())?.accounts).toHaveLength(2); }); + it("keeps accounts intact when the pre-import snapshot cannot be written", async () => { + const importPath = join(testWorkDir, "locked-import.json"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + importPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "imported", + refreshToken: "ref-imported", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + const originalWriteFile = fs.writeFile; + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + const [path] = args; + if ( + String(path).includes("accounts-import-accounts-snapshot-") + ) { + const error = new Error("snapshot write failed") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalWriteFile(...args); + }); + + try { + await expect(importAccounts(importPath)).rejects.toThrow( + "snapshot write failed", + ); + } finally { + writeFileSpy.mockRestore(); + } + + expect((await loadAccounts())?.accounts).toEqual([ + expect.objectContaining({ + accountId: "existing", + refreshToken: "ref-existing", + }), + ]); + expect(await fs.readdir(getNamedBackupsDirectoryPath())).toEqual([]); + }); + it("keeps accounts intact when the pre-delete snapshot cannot be written", async () => { await saveAccounts({ version: 3, From 34c483c82e4ffbeb57e1f0c2d3eaffbbac255c98 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 20:04:41 +0800 Subject: [PATCH 28/76] fix(storage): preserve active rotation artifacts --- lib/storage.ts | 25 +++++++++++++++++++++++-- test/storage-recovery-paths.test.ts | 7 ++++++- test/storage.test.ts | 14 -------------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 016cbc27..a5f367d2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,6 +47,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +const ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS = 60_000; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; @@ -792,14 +793,34 @@ async function cleanupStaleRotatingBackupArtifacts( path: string, ): Promise { const directoryPath = dirname(path); + const staleCutoff = Date.now() - ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS; try { const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true, }); - const staleArtifacts = directoryEntries + const staleArtifacts = ( + await Promise.all( + directoryEntries .filter((entry) => entry.isFile()) .map((entry) => join(directoryPath, entry.name)) - .filter((entryPath) => isRotatingBackupTempArtifact(path, entryPath)); + .filter((entryPath) => isRotatingBackupTempArtifact(path, entryPath)) + .map(async (entryPath) => { + try { + const stats = await fs.stat(entryPath); + return stats.mtimeMs <= staleCutoff ? entryPath : null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect rotating backup artifact", { + path: entryPath, + error: String(error), + }); + } + return null; + } + }), + ) + ).filter((entryPath): entryPath is string => entryPath !== null); for (const staleArtifactPath of staleArtifacts) { try { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index d93356b3..6ef13f1b 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -625,7 +625,7 @@ describe("storage recovery paths", () => { ).toBe(false); }); - it("cleans up stale staged backup artifacts during load", async () => { + it("cleans up only stale staged backup artifacts during load", async () => { await fs.writeFile( storagePath, JSON.stringify({ @@ -643,8 +643,12 @@ describe("storage recovery paths", () => { ]; for (const staleArtifactPath of staleArtifacts) { await fs.writeFile(staleArtifactPath, "stale", "utf-8"); + await fs.utimes(staleArtifactPath, new Date(0), new Date(0)); expect(existsSync(staleArtifactPath)).toBe(true); } + const freshArtifactPath = `${storagePath}.bak.rotate.54321.fresh.latest.tmp`; + await fs.writeFile(freshArtifactPath, "fresh", "utf-8"); + expect(existsSync(freshArtifactPath)).toBe(true); const unrelatedArtifactPath = `${storagePath}.rotate.12345.abc123.latest.tmp`; await fs.writeFile(unrelatedArtifactPath, "keep", "utf-8"); expect(existsSync(unrelatedArtifactPath)).toBe(true); @@ -656,6 +660,7 @@ describe("storage recovery paths", () => { for (const staleArtifactPath of staleArtifacts) { expect(existsSync(staleArtifactPath)).toBe(false); } + expect(existsSync(freshArtifactPath)).toBe(true); expect(existsSync(unrelatedArtifactPath)).toBe(true); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 6a6a9be7..b7cf4811 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -3156,20 +3156,6 @@ describe("storage", () => { }); }); - describe("filename migration (TDD)", () => { - it("should migrate from old filename to new filename", async () => { - // This test is tricky because it depends on the internal state of getStoragePath() - // which we are about to change. - - const oldName = "openai-codex-accounts.json"; - const newName = "codex-accounts.json"; - - // We'll need to mock/verify that loadAccounts checks for oldName if newName is missing - // Since we haven't implemented it yet, this is just a placeholder for the logic - expect(true).toBe(true); - }); - }); - describe("StorageError and formatStorageErrorHint", () => { describe("StorageError class", () => { it("should store code, path, and hint properties", () => { From 89922ece9ed4cca87c0b6923cd751c858a57b531 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:17:49 +0800 Subject: [PATCH 29/76] fix(storage): pin transaction storage path --- lib/storage.ts | 27 +-- test/storage.test.ts | 392 ++++++++++++++----------------------------- 2 files changed, 146 insertions(+), 273 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index a5f367d2..b2bc276c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2273,6 +2273,7 @@ function formatSnapshotErrorForLog(error: unknown): string { export async function snapshotAccountStorage( options: AccountSnapshotOptions, ): Promise { + const transactionState = transactionSnapshotContext.getStore(); const { reason, now = Date.now(), @@ -2281,6 +2282,9 @@ export async function snapshotAccountStorage( createBackup = createNamedBackup, storagePath, } = options; + const resolvedStoragePath = + storagePath ?? + (transactionState?.active ? transactionState.storagePath : undefined); const currentStorage = options.storage !== undefined ? options.storage : await loadAccounts(); if (!currentStorage || currentStorage.accounts.length === 0) { @@ -2292,7 +2296,7 @@ export async function snapshotAccountStorage( return await createBackup(backupName, { force, storage: currentStorage, - storagePath, + storagePath: resolvedStoragePath, }); } catch (error) { if (failurePolicy === "error") { @@ -2713,7 +2717,6 @@ async function importNormalizedAccounts( reason: snapshotReason, failurePolicy: snapshotFailurePolicy, storage: existing, - storagePath: getStoragePath(), }); } const existingAccounts = existing?.accounts ?? []; @@ -3171,14 +3174,14 @@ function formatRotatingBackupLabel(slot: number): string { */ export interface SaveAccountsOptions { backupEnabled?: boolean; - pathOverride?: string; + storagePath?: string; } async function saveAccountsUnlocked( storage: AccountStorageV3, options: SaveAccountsOptions = {}, ): Promise { - const path = options.pathOverride ?? getStoragePath(); + const path = options.storagePath ?? getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; @@ -3322,7 +3325,7 @@ export async function withAccountStorageTransaction( const current = state.snapshot; const persist = async (storage: AccountStorageV3): Promise => { await saveAccountsUnlocked(storage, { - pathOverride: state.storagePath, + storagePath: state.storagePath, }); state.snapshot = storage; }; @@ -3343,7 +3346,6 @@ export async function withAccountAndFlaggedStorageTransaction( ): Promise { return withStorageLock(async () => { const storagePath = getStoragePath(); - const flaggedStoragePath = getFlaggedAccountsPathForStoragePath(storagePath); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), active: true, @@ -3357,17 +3359,17 @@ export async function withAccountAndFlaggedStorageTransaction( const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); const nextAccounts = cloneAccountStorageForPersistence(accountStorage); await saveAccountsUnlocked(nextAccounts, { - pathOverride: state.storagePath, + storagePath: state.storagePath, }); try { await saveFlaggedAccountsUnlocked(flaggedStorage, { - pathOverride: flaggedStoragePath, + storagePath: state.storagePath, }); state.snapshot = nextAccounts; } catch (error) { try { await saveAccountsUnlocked(previousAccounts, { - pathOverride: state.storagePath, + storagePath: state.storagePath, }); state.snapshot = previousAccounts; } catch (rollbackError) { @@ -3658,9 +3660,12 @@ export async function loadFlaggedAccounts(): Promise { async function saveFlaggedAccountsUnlocked( storage: FlaggedAccountStorageV1, - options: { pathOverride?: string } = {}, + options: { storagePath?: string } = {}, ): Promise { - const path = options.pathOverride ?? getFlaggedAccountsPath(); + const path = join( + dirname(options.storagePath ?? getStoragePath()), + FLAGGED_ACCOUNTS_FILE_NAME, + ); const markerPath = getIntentionalResetMarkerPath(path); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; diff --git a/test/storage.test.ts b/test/storage.test.ts index b7cf4811..1cea313b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -624,71 +624,70 @@ describe("storage", () => { ).toEqual(new Set(["acct-a", "acct-b"])); }); - it("keeps account transactions bound to the original storage path", async () => { + it("pins transactional snapshots and account writes to the captured storage path", async () => { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "primary-original", - refreshToken: "ref-primary-original", + accountId: "acct-original", + refreshToken: "ref-original", addedAt: 1, lastUsed: 1, }, ], }); - const alternateStoragePath = join(testWorkDir, "alternate-accounts.json"); - setStoragePathDirect(alternateStoragePath); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "alternate-original", - refreshToken: "ref-alternate-original", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - setStoragePathDirect(testStoragePath); + const alternateStoragePath = join( + testWorkDir, + "alternate", + "alternate-accounts.json", + ); - await withAccountStorageTransaction(async (current, persist) => { - if (!current) { - throw new Error("expected existing account storage"); - } - setStoragePathDirect(alternateStoragePath); - try { + try { + await withAccountStorageTransaction(async (current, persist) => { + if (!current) { + throw new Error("expected existing account storage"); + } + + setStoragePathDirect(alternateStoragePath); + await snapshotAccountStorage({ + reason: "import-accounts", + failurePolicy: "error", + storage: current, + }); await persist({ ...current, accounts: [ ...current.accounts, { - accountId: "primary-added", - refreshToken: "ref-primary-added", + accountId: "acct-transactional", + refreshToken: "ref-transactional", addedAt: 2, lastUsed: 2, }, ], }); - } finally { - setStoragePathDirect(testStoragePath); - } - }); + }); + } finally { + setStoragePathDirect(testStoragePath); + } - const primaryLoaded = await loadAccounts(); - expect(primaryLoaded?.accounts.map((account) => account.accountId)).toEqual([ - "primary-original", - "primary-added", - ]); + const saved = JSON.parse(await fs.readFile(testStoragePath, "utf-8")); + expect(saved.accounts.map((account: { accountId: string }) => account.accountId)) + .toEqual(["acct-original", "acct-transactional"]); + expect(existsSync(alternateStoragePath)).toBe(false); + + const originalSnapshots = await listNamedBackups(); + expect( + originalSnapshots.some((backup) => + backup.name.startsWith("accounts-import-accounts-snapshot-"), + ), + ).toBe(true); setStoragePathDirect(alternateStoragePath); try { - const alternateLoaded = await loadAccounts(); - expect( - alternateLoaded?.accounts.map((account) => account.accountId), - ).toEqual(["alternate-original"]); + await expect(listNamedBackups()).resolves.toEqual([]); } finally { setStoragePathDirect(testStoragePath); } @@ -791,6 +790,95 @@ describe("storage", () => { ); }); + it("pins combined account and flagged writes to the captured storage path", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acct-original", + refreshToken: "ref-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-flagged-original", + refreshToken: "ref-flagged-original", + flaggedAt: 1, + }, + ], + }); + + const alternateStoragePath = join( + testWorkDir, + "alternate", + "alternate-accounts.json", + ); + const alternateFlaggedPath = join( + dirname(alternateStoragePath), + "openai-codex-flagged-accounts.json", + ); + + try { + await withAccountAndFlaggedStorageTransaction( + async (current, persist) => { + if (!current) { + throw new Error("expected existing account storage"); + } + + setStoragePathDirect(alternateStoragePath); + await persist( + { + ...current, + accounts: [ + ...current.accounts, + { + accountId: "acct-transactional", + refreshToken: "ref-transactional", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + { + version: 1, + accounts: [ + { + accountId: "acct-flagged-transactional", + refreshToken: "ref-flagged-transactional", + flaggedAt: 2, + }, + ], + }, + ); + }, + ); + } finally { + setStoragePathDirect(testStoragePath); + } + + const savedAccounts = JSON.parse(await fs.readFile(testStoragePath, "utf-8")); + expect( + savedAccounts.accounts.map((account: { accountId: string }) => account.accountId), + ).toEqual(["acct-original", "acct-transactional"]); + expect(existsSync(alternateStoragePath)).toBe(false); + + const flagged = await loadFlaggedAccounts(); + expect(flagged.accounts).toEqual([ + expect.objectContaining({ + accountId: "acct-flagged-transactional", + refreshToken: "ref-flagged-transactional", + }), + ]); + expect(existsSync(alternateFlaggedPath)).toBe(false); + expect(getFlaggedAccountsPath()).not.toBe(alternateFlaggedPath); + }); + it("surfaces rollback failure when flagged persistence and account rollback both fail", async () => { const now = Date.now(); const storagePath = getStoragePath(); @@ -935,129 +1023,6 @@ describe("storage", () => { } }); - it("keeps combined transactions bound to the original account and flagged storage paths", async () => { - const now = Date.now(); - await saveAccounts({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - accountId: "primary-original", - email: "primary@example.com", - refreshToken: "refresh-primary-original", - addedAt: now - 10_000, - lastUsed: now - 10_000, - }, - ], - }); - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - accountId: "flagged-original", - email: "flagged@example.com", - refreshToken: "refresh-flagged-original", - addedAt: now - 5_000, - lastUsed: now - 5_000, - flaggedAt: now - 5_000, - }, - ], - }); - - const alternateStoragePath = join( - testWorkDir, - "alternate-combined", - "alternate-combined.json", - ); - setStoragePathDirect(alternateStoragePath); - await saveAccounts({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - accountId: "alternate-original", - email: "alternate@example.com", - refreshToken: "refresh-alternate-original", - addedAt: now - 1_000, - lastUsed: now - 1_000, - }, - ], - }); - await saveFlaggedAccounts({ - version: 1, - accounts: [ - { - accountId: "alternate-flagged", - email: "alternate-flagged@example.com", - refreshToken: "refresh-alternate-flagged", - addedAt: now - 500, - lastUsed: now - 500, - flaggedAt: now - 500, - }, - ], - }); - setStoragePathDirect(testStoragePath); - - await withAccountAndFlaggedStorageTransaction(async (current, persist) => { - if (!current) { - throw new Error("expected existing account storage"); - } - setStoragePathDirect(alternateStoragePath); - try { - await persist( - { - ...current, - accounts: [ - ...current.accounts, - { - accountId: "primary-added", - email: "primary-added@example.com", - refreshToken: "refresh-primary-added", - addedAt: now, - lastUsed: now, - }, - ], - }, - { - version: 1, - accounts: [], - }, - ); - } finally { - setStoragePathDirect(testStoragePath); - } - }); - - const primaryLoaded = await loadAccounts(); - expect(primaryLoaded?.accounts.map((account) => account.accountId)).toEqual([ - "primary-original", - "primary-added", - ]); - await expect(loadFlaggedAccounts()).resolves.toMatchObject({ - accounts: [], - }); - - setStoragePathDirect(alternateStoragePath); - try { - const alternateLoaded = await loadAccounts(); - expect( - alternateLoaded?.accounts.map((account) => account.accountId), - ).toEqual(["alternate-original"]); - await expect(loadFlaggedAccounts()).resolves.toMatchObject({ - accounts: [ - expect.objectContaining({ - accountId: "alternate-flagged", - refreshToken: "refresh-alternate-flagged", - }), - ], - }); - } finally { - setStoragePathDirect(testStoragePath); - } - }); - it("retries transient flagged storage rename and succeeds", async () => { const now = Date.now(); await saveFlaggedAccounts({ @@ -1524,11 +1489,8 @@ describe("storage", () => { }), ]); - const restoreResult = await restoreNamedBackup("Replace Only", { - assessment, - }); - expect(restoreResult).toEqual({ imported: 0, skipped: 0, total: 1 }); - expect(restoreResult.skipped).toBe(assessment.skipped); + const restoreResult = await restoreNamedBackup("Replace Only"); + expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 }); const restored = await loadAccounts(); expect(restored?.accounts).toEqual([ @@ -1715,40 +1677,6 @@ describe("storage", () => { expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); - it("reassesses named backup contents when a previously previewed backup becomes empty", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "preview-account", - refreshToken: "refresh-preview-account", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - const backup = await createNamedBackup("emptied-after-assessment"); - await clearAccounts(); - const assessment = await assessNamedBackupRestore("emptied-after-assessment"); - expect(assessment.eligibleForRestore).toBe(true); - - await fs.writeFile( - backup.path, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [], - }), - "utf-8", - ); - - await expect( - restoreNamedBackup("emptied-after-assessment", { assessment }), - ).rejects.toThrow(/Backup is empty or invalid/); - expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); - }); - it("reassesses named restores at mutation time when the current pool grows past the limit", async () => { const backupPath = join( dirname(testStoragePath), @@ -1809,7 +1737,7 @@ describe("storage", () => { await expect( restoreNamedBackup("limit-race", { assessment: initialAssessment }), ).rejects.toThrow( - `maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, + `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, ); const persisted = await loadAccounts(); @@ -2065,7 +1993,6 @@ describe("storage", () => { const result = await restoreNamedBackup("retry-restore-read"); expect(result.total).toBe(1); expect(busyFailures).toBe(1); - expect(backupReads).toBe(2); } finally { readFileSpy.mockRestore(); } @@ -3987,65 +3914,6 @@ describe("storage", () => { } }, ); - - it("removes the flagged reset marker when only backup cleanup fails", async () => { - const flaggedPath = getFlaggedAccountsPath(); - const markerPath = `${flaggedPath}.reset-intent`; - const backupPath = `${flaggedPath}.bak`; - await fs.mkdir(dirname(flaggedPath), { recursive: true }); - await fs.writeFile( - flaggedPath, - JSON.stringify({ - version: 1, - accounts: [ - { - accountId: "flagged-primary", - refreshToken: "refresh-flagged-primary", - addedAt: 1, - lastUsed: 1, - flaggedAt: 1, - }, - ], - }), - ); - await fs.writeFile( - backupPath, - JSON.stringify({ - version: 1, - accounts: [ - { - accountId: "flagged-backup", - refreshToken: "refresh-flagged-backup", - addedAt: 2, - lastUsed: 2, - flaggedAt: 2, - }, - ], - }), - ); - - const realUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (targetPath) => { - if (targetPath === backupPath) { - const error = new Error("backup busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return realUnlink(targetPath); - }); - - try { - await expect(clearFlaggedAccounts()).resolves.toBe(false); - } finally { - unlinkSpy.mockRestore(); - } - - expect(existsSync(flaggedPath)).toBe(false); - expect(existsSync(backupPath)).toBe(true); - expect(existsSync(markerPath)).toBe(false); - }); }); describe("setStoragePath", () => { From b39a5a35b49e459c0c548115abeccc10ef3caddb Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 21:29:30 +0800 Subject: [PATCH 30/76] test(storage): harden restore and retry regressions --- test/storage.test.ts | 132 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 28 deletions(-) diff --git a/test/storage.test.ts b/test/storage.test.ts index 1cea313b..8cdb6be7 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2889,6 +2889,71 @@ describe("storage", () => { }); }); + it("does not restore a named backup when the pre-restore snapshot fails", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "current", + refreshToken: "ref-current", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("restore-with-snapshot"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "replacement", + refreshToken: "ref-replacement", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const originalPlatform = process.platform; + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if ( + String(path).includes("accounts-import-accounts-snapshot-") + ) { + const error = new Error("snapshot failed") as NodeJS.ErrnoException; + error.code = "EROFS"; + throw error; + } + return originalWriteFile(path, data as never, options as never); + }); + + try { + Object.defineProperty(process, "platform", { value: "win32" }); + await expect( + restoreNamedBackup("restore-with-snapshot"), + ).rejects.toMatchObject({ + code: "EROFS", + }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + writeFileSpy.mockRestore(); + } + + expect(await loadAccounts()).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountId: "replacement", + refreshToken: "ref-replacement", + }), + ], + }); + }); + it("creates a snapshot before importing into an existing account pool", async () => { const importPath = join(testWorkDir, "import.json"); @@ -3789,6 +3854,7 @@ describe("storage", () => { setStoragePathDirect(nestedStoragePath); await expect(clearAccounts()).resolves.toBe(true); + expect(existsSync(dirname(nestedStoragePath))).toBe(true); }); it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( @@ -4799,11 +4865,13 @@ describe("storage", () => { return originalRename(oldPath as string, newPath as string); }); - await saveAccounts(storage); - expect(attemptCount).toBe(2); - expect(existsSync(testStoragePath)).toBe(true); - - renameSpy.mockRestore(); + try { + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + } finally { + renameSpy.mockRestore(); + } }); it("retries on EBUSY and succeeds on third attempt", async () => { @@ -4828,11 +4896,13 @@ describe("storage", () => { return originalRename(oldPath as string, newPath as string); }); - await saveAccounts(storage); - expect(attemptCount).toBe(3); - expect(existsSync(testStoragePath)).toBe(true); - - renameSpy.mockRestore(); + try { + await saveAccounts(storage); + expect(attemptCount).toBe(3); + expect(existsSync(testStoragePath)).toBe(true); + } finally { + renameSpy.mockRestore(); + } }); it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { @@ -4858,12 +4928,14 @@ describe("storage", () => { return originalRename(oldPath as string, newPath as string); }); - await saveAccounts(storage); - expect(attemptCount).toBe(2); - expect(existsSync(testStoragePath)).toBe(true); - expect(existsSync(walPath)).toBe(false); - - renameSpy.mockRestore(); + try { + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + } finally { + renameSpy.mockRestore(); + } }); it("throws after 5 failed EPERM retries", async () => { @@ -4882,12 +4954,14 @@ describe("storage", () => { throw err; }); - await expect(saveAccounts(storage)).rejects.toThrow( - "Failed to save accounts", - ); - expect(attemptCount).toBe(5); - - renameSpy.mockRestore(); + try { + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(attemptCount).toBe(5); + } finally { + renameSpy.mockRestore(); + } }); it("throws immediately on non-EPERM/EBUSY errors", async () => { @@ -4906,12 +4980,14 @@ describe("storage", () => { throw err; }); - await expect(saveAccounts(storage)).rejects.toThrow( - "Failed to save accounts", - ); - expect(attemptCount).toBe(1); - - renameSpy.mockRestore(); + try { + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(attemptCount).toBe(1); + } finally { + renameSpy.mockRestore(); + } }); it("throws when temp file is written with size 0", async () => { From 432ab73cb36a0c19eaf9cfb79db4bde518b2f016 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 21:41:36 +0800 Subject: [PATCH 31/76] fix(storage): retry reset marker writes --- lib/storage.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index b2bc276c..63a0c30f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3426,11 +3426,15 @@ async function clearAccountsUnlocked(storagePath: string): Promise { ), ), ); - await fs.mkdir(dirname(resetMarkerPath), { recursive: true }); - await fs.writeFile( - resetMarkerPath, - JSON.stringify({ version: 1, createdAt: Date.now() }), - { encoding: "utf-8", mode: 0o600 }, + await retryTransientFilesystemOperation(() => + fs.mkdir(dirname(resetMarkerPath), { recursive: true }), + ); + await retryTransientFilesystemOperation(() => + fs.writeFile( + resetMarkerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + { encoding: "utf-8", mode: 0o600 }, + ), ); let hadError = false; const clearPath = async (targetPath: string): Promise => { From dedece60ccd5e6ed5d5e8ff12d284766a9c80727 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 22:06:39 +0800 Subject: [PATCH 32/76] fix(storage): avoid dangling import snapshots --- lib/destructive-actions.ts | 2 -- lib/storage.ts | 15 ++++++++------- test/destructive-actions.test.ts | 1 - test/storage.test.ts | 8 ++------ 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 4b89e438..0d5a308b 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -7,7 +7,6 @@ import { clearFlaggedAccounts, findMatchingAccountIndex, type FlaggedAccountStorageV1, - getStoragePath, loadFlaggedAccounts, snapshotAccountStorage, snapshotAndClearAccounts, @@ -129,7 +128,6 @@ export async function deleteAccountAtIndex(options: { reason: "delete-account", failurePolicy: "error", storage: sourceStorage, - storagePath: getStoragePath(), }); const nextStorage: AccountStorageV3 = { diff --git a/lib/storage.ts b/lib/storage.ts index 63a0c30f..069dfb24 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2712,13 +2712,6 @@ async function importNormalizedAccounts( total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { - if (snapshotReason) { - await snapshotAccountStorage({ - reason: snapshotReason, - failurePolicy: snapshotFailurePolicy, - storage: existing, - }); - } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; @@ -2733,6 +2726,14 @@ async function importNormalizedAccounts( } } + if (snapshotReason) { + await snapshotAccountStorage({ + reason: snapshotReason, + failurePolicy: snapshotFailurePolicy, + storage: existing, + }); + } + const deduplicatedAccounts = deduplicateAccounts(merged); const newStorage: AccountStorageV3 = { diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 31ac2794..75cd04ee 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -173,7 +173,6 @@ describe("destructive actions", () => { reason: "delete-account", failurePolicy: "error", storage, - storagePath: "/mock/openai-codex-accounts.json", }); expect( snapshotAccountStorageMock.mock.invocationCallOrder[0], diff --git a/test/storage.test.ts b/test/storage.test.ts index 8cdb6be7..e52c1c48 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -3136,12 +3136,8 @@ describe("storage", () => { `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, ); - const entries = await fs.readdir(getNamedBackupsDirectoryPath()); - expect( - entries.some((name) => - name.startsWith("accounts-import-accounts-snapshot-"), - ), - ).toBe(true); + expect(await listNamedBackups()).toEqual([]); + expect(existsSync(getNamedBackupsDirectoryPath())).toBe(false); expect((await loadAccounts())?.accounts).toHaveLength( ACCOUNT_LIMITS.MAX_ACCOUNTS, ); From 93a6463424ad27aa62ea8319d8423bccc4f1800b Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 22:38:29 +0800 Subject: [PATCH 33/76] fix(storage): harden named backup scans and snapshots --- lib/storage.ts | 63 +++++++++++++++------- test/recovery.test.ts | 18 +++++++ test/storage.test.ts | 118 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 18 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 069dfb24..a8539284 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -487,7 +487,7 @@ async function unlinkWithRetry(path: string): Promise { 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; } @@ -2074,6 +2074,21 @@ export function getNamedBackupsDirectoryPath(): string { return getNamedBackupRoot(getStoragePath()); } +async function scanNamedBackupsForActionableRestores(): Promise { + try { + return await scanNamedBackups(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to list named backups", { + path: getNamedBackupRoot(getStoragePath()), + error: String(error), + }); + } + return { backups: [], totalBackups: 0 }; + } +} + export async function getActionableNamedBackupRestores( options: { currentStorage?: AccountStorageV3 | null; @@ -2084,7 +2099,7 @@ export async function getActionableNamedBackupRestores( const usesFastPath = options.backups === undefined && options.assess === undefined; const scannedBackupResult = usesFastPath - ? await scanNamedBackups() + ? await scanNamedBackupsForActionableRestores() : { backups: [], totalBackups: 0 }; const listedBackupResult = !usesFastPath && options.backups === undefined @@ -2202,25 +2217,37 @@ async function writeNamedBackupFromStorage( if (!options.force && existsSync(backupPath)) { throw new Error(`File already exists: ${backupPath}`); } - await retryTransientFilesystemOperation(() => - fs.mkdir(dirname(backupPath), { recursive: true }), + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${backupPath}.${uniqueSuffix}.tmp`; + const content = JSON.stringify( + { + version: storage.version, + accounts: storage.accounts, + activeIndex: storage.activeIndex, + activeIndexByFamily: storage.activeIndexByFamily, + }, + null, + 2, ); await retryTransientFilesystemOperation(() => - fs.writeFile( - backupPath, - JSON.stringify( - { - version: storage.version, - accounts: storage.accounts, - activeIndex: storage.activeIndex, - activeIndexByFamily: storage.activeIndexByFamily, - }, - null, - 2, - ), - { encoding: "utf-8", mode: 0o600 }, - ), + fs.mkdir(dirname(backupPath), { recursive: true }), ); + try { + await retryTransientFilesystemOperation(() => + fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }), + ); + if (options.force) { + await unlinkWithRetry(backupPath); + } + await renameFileWithRetry(tempPath, backupPath); + } catch (error) { + try { + await unlinkWithRetry(tempPath); + } catch { + // Ignore temp cleanup failures after a failed staged backup write. + } + throw error; + } return buildNamedBackupMetadata(name, backupPath); } diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 54f572c8..ef7ed365 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -377,6 +377,24 @@ describe("getActionableNamedBackupRestores (storage-backed paths)", () => { expect(result.assessments[0]?.imported).toBe(1); }); + it("returns no actionable restores when the fast-path backup scan cannot read the directory", async () => { + const storage = await import("../lib/storage.js"); + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(storage.getActionableNamedBackupRestores()).resolves.toEqual({ + assessments: [], + allAssessments: [], + totalBackups: 0, + }); + } finally { + readdirSpy.mockRestore(); + } + }); + it("keeps actionable backups when fast-path scan hits EBUSY", async () => { const storage = await import("../lib/storage.js"); const emptyStorage = { diff --git a/test/storage.test.ts b/test/storage.test.ts index e52c1c48..44850024 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -22,6 +22,7 @@ import { exportNamedBackup, findMatchingAccountIndex, formatStorageErrorHint, + getActionableNamedBackupRestores, getFlaggedAccountsPath, getNamedBackupsDirectoryPath, NAMED_BACKUP_LIST_CONCURRENCY, @@ -1863,6 +1864,23 @@ describe("storage", () => { } }); + it("returns no actionable restores when the fast backup scan hits a locked directory", async () => { + const readdirSpy = vi.spyOn(fs, "readdir"); + const error = new Error("backup directory locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + readdirSpy.mockRejectedValue(error); + + try { + await expect(getActionableNamedBackupRestores()).resolves.toEqual({ + assessments: [], + allAssessments: [], + totalBackups: 0, + }); + } finally { + readdirSpy.mockRestore(); + } + }); + it("retries transient backup directory errors while listing backups", async () => { await saveAccounts({ version: 3, @@ -2758,6 +2776,44 @@ describe("storage", () => { } }); + it("stages the pre-delete snapshot in a temp file before publishing it", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "delete-original", + refreshToken: "ref-delete-original", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + const renameSpy = vi.spyOn(fs, "rename"); + + try { + await expect(deleteSavedAccounts()).resolves.toMatchObject({ + accountsCleared: true, + }); + expect( + renameSpy.mock.calls.some(([sourcePath, destinationPath]) => { + const source = String(sourcePath); + const destination = String(destinationPath); + return ( + source.includes("accounts-delete-saved-accounts-snapshot-") && + source.endsWith(".tmp") && + destination.includes("accounts-delete-saved-accounts-snapshot-") && + destination.endsWith(".json") + ); + }), + ).toBe(true); + const entries = await fs.readdir(getNamedBackupsDirectoryPath()); + expect(entries.some((name) => name.endsWith(".tmp"))).toBe(false); + } finally { + renameSpy.mockRestore(); + } + }); + it("creates a named snapshot before resetLocalState", async () => { await saveAccounts({ version: 3, @@ -3859,6 +3915,10 @@ describe("storage", () => { await fs.writeFile(testStoragePath, "{}"); const walPath = `${testStoragePath}.wal`; await fs.writeFile(walPath, "{}"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const realUnlink = fs.unlink.bind(fs); let failedOnce = false; @@ -3884,10 +3944,43 @@ describe("storage", () => { ), ).toHaveLength(2); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, ); + + it("does not retry EPERM when clearing saved account artifacts on non-Windows platforms", async () => { + await fs.writeFile(testStoragePath, "{}"); + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearAccounts()).resolves.toBe(false); + expect(existsSync(testStoragePath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(1); + } finally { + platformSpy.mockRestore(); + unlinkSpy.mockRestore(); + } + }); }); describe("clearFlaggedAccounts", () => { @@ -3914,6 +4007,10 @@ describe("storage", () => { const flaggedPath = getFlaggedAccountsPath(); await fs.mkdir(dirname(flaggedPath), { recursive: true }); await fs.writeFile(flaggedPath, "{}"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const realUnlink = fs.unlink.bind(fs); let failedOnce = false; @@ -3938,6 +4035,7 @@ describe("storage", () => { ), ).toHaveLength(2); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, @@ -3949,6 +4047,10 @@ describe("storage", () => { const flaggedPath = getFlaggedAccountsPath(); await fs.mkdir(dirname(flaggedPath), { recursive: true }); await fs.writeFile(flaggedPath, "{}"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const unlinkSpy = vi .spyOn(fs, "unlink") @@ -3972,6 +4074,7 @@ describe("storage", () => { ), ).toHaveLength(5); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, @@ -4142,6 +4245,10 @@ describe("storage", () => { "returns false when clearing saved accounts exhausts retryable %s failures", async (code) => { await fs.writeFile(testStoragePath, "{}"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const unlinkSpy = vi .spyOn(fs, "unlink") .mockImplementation(async (targetPath) => { @@ -4164,6 +4271,7 @@ describe("storage", () => { ), ).toHaveLength(5); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, @@ -5659,6 +5767,10 @@ describe("storage", () => { const quotaPath = getQuotaCachePath(); await fs.mkdir(dirname(quotaPath), { recursive: true }); await fs.writeFile(quotaPath, "{}", "utf-8"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const realUnlink = fs.unlink.bind(fs); const unlinkSpy = vi @@ -5677,6 +5789,7 @@ describe("storage", () => { expect(existsSync(quotaPath)).toBe(false); expect(unlinkSpy).toHaveBeenCalledTimes(2); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, @@ -5688,6 +5801,10 @@ describe("storage", () => { const quotaPath = getQuotaCachePath(); await fs.mkdir(dirname(quotaPath), { recursive: true }); await fs.writeFile(quotaPath, "{}", "utf-8"); + const platformSpy = + code === "EPERM" + ? vi.spyOn(process, "platform", "get").mockReturnValue("win32") + : null; const unlinkSpy = vi .spyOn(fs, "unlink") @@ -5707,6 +5824,7 @@ describe("storage", () => { expect(existsSync(quotaPath)).toBe(true); expect(unlinkSpy).toHaveBeenCalledTimes(5); } finally { + platformSpy?.mockRestore(); unlinkSpy.mockRestore(); } }, From 641ec1ff6a304d5374da0a0d98bc9502b5c3c308 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:31:38 +0800 Subject: [PATCH 34/76] fix(storage): preserve replace-only restore skip counts --- lib/storage.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index a8539284..607820fb 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2773,12 +2773,7 @@ async function importNormalizedAccounts( await persist(newStorage); const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = Math.max( - 0, - normalized.accounts.length - - imported - - Math.max(0, options.replacedExistingCount ?? 0), - ); + const skipped = Math.max(0, normalized.accounts.length - imported); return { imported, total: deduplicatedAccounts.length, skipped }; }); From c5c84a8f4fe85bc64d448c0a72afe953d36e1d16 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:19:54 +0800 Subject: [PATCH 35/76] fix(auth): define reset and delete safety flows --- lib/codex-manager.ts | 2 ++ test/codex-manager-cli.test.ts | 25 +++++++++++++++++++++++++ test/storage.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c5632736..3e8bfe0e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -110,6 +110,8 @@ const log = createLogger("codex-manager"); let destructiveActionInFlight = false; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 04074ef7..8df631ae 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -7364,6 +7364,31 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); + deleteAccountAtIndexMock.mockResolvedValueOnce({ + storage: { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + flagged: { version: 1, accounts: [] }, + removedAccount: { + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + accountIdSource: undefined, + enabled: true, + }, + removedFlaggedCount: 0, + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); diff --git a/test/storage.test.ts b/test/storage.test.ts index 44850024..d866d92f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -5042,6 +5042,37 @@ describe("storage", () => { } }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { From 10d4617bc32e37a5d8a044b48e3073767cb679db Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:23:17 +0800 Subject: [PATCH 36/76] feat(auth): add backup restore manager --- docs/reference/public-api.md | 6 + lib/codex-manager.ts | 30 +++ lib/storage.ts | 244 ++++++++++++++++++++- test/codex-manager-cli.test.ts | 3 + test/storage.test.ts | 372 ++++++++++++++++++++++++++++++++- 5 files changed, 648 insertions(+), 7 deletions(-) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3e8bfe0e..3e45d01a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -362,6 +362,7 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", + " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -4062,6 +4063,21 @@ async function runAuthLogin(): Promise { pendingMenuQuotaRefresh = null; }); } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } const flaggedStorage = await loadFlaggedAccounts(); @@ -5453,6 +5469,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } + if (command === "restore-backup") { + setStoragePath(null); + try { + const completedWithoutFailure = + await runBackupRestoreManager(startupDisplaySettings); + return completedWithoutFailure ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return 1; + } + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index 607820fb..566d72f6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { + existsSync, + lstatSync, + promises as fs, + realpathSync, + type Dirent, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -2901,6 +2907,234 @@ async function resolveNamedBackupRestorePath(name: string): Promise { } } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + const errorMessage = + error instanceof SyntaxError + ? `Invalid JSON in import file: ${path}` + : (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Import file not found: ${path}` + : error instanceof Error + ? error.message + : String(error); + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: errorMessage, + }; + } +} + +function equalsNamedBackupEntry(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + +async function findExistingNamedBackupPath( + name: string, +): Promise { + const requested = (name ?? "").trim(); + if (!requested) { + return undefined; + } + + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; + + try { + entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + + return undefined; +} + +function resolvePathForNamedBackupContainment(path: string): string { + const resolvedPath = resolvePath(path); + let existingPrefix = resolvedPath; + const unresolvedSegments: string[] = []; + while (true) { + try { + namedBackupContainmentFs.lstat(existingPrefix); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + const parentPath = dirname(existingPrefix); + if (parentPath === existingPrefix) { + return resolvedPath; + } + unresolvedSegments.unshift(basename(existingPrefix)); + existingPrefix = parentPath; + continue; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } + } + try { + const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); + return unresolvedSegments.reduce( + (currentPath, segment) => join(currentPath, segment), + canonicalPrefix, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return resolvedPath; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const resolvedBackupRoot = resolvePath(backupRoot); + let backupRootIsSymlink = false; + try { + backupRootIsSymlink = + namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + backupRootIsSymlink = false; + } else if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } else { + throw error; + } + } + if (backupRootIsSymlink) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + const canonicalBackupRoot = + resolvePathForNamedBackupContainment(resolvedBackupRoot); + const containedPath = resolvePathForNamedBackupContainment(resolvedPath); + const relativePath = relative(canonicalBackupRoot, containedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + return containedPath; +} + +export function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof BackupContainmentError || + (error instanceof Error && /escapes backup directory/i.test(error.message)) + ); +} + +export function isNamedBackupPathValidationTransientError( + error: unknown, +): error is BackupPathValidationTransientError { + return ( + error instanceof BackupPathValidationTransientError || + (error instanceof Error && + /^Backup path validation failed(\.|:|\b)/i.test(error.message)) + ); +} + +export async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return assertNamedBackupRestorePath(existingPath, backupRoot); + } + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; + try { + builtPath = buildNamedBackupPath(requested); + } catch (error) { + // buildNamedBackupPath rejects names with special characters even when the + // requested backup name is a plain filename inside the backups directory. + // In that case, reporting ENOENT is clearer than surfacing the filename + // validator, but only when no separator/traversal token is present. + if ( + requested.length > 0 && + basename(requestedWithExtension) === requestedWithExtension && + !requestedWithExtension.includes("..") && + !/^[A-Za-z0-9_-]+$/.test(baseName) + ) { + throw new Error( + `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, + ); + } + throw error; + } + return assertNamedBackupRestorePath(builtPath, backupRoot); +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -3861,7 +4095,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { @@ -3869,3 +4103,7 @@ export async function importAccounts( snapshotFailurePolicy: "error", }); } + +export const __testOnly = { + namedBackupContainmentFs, +}; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 8df631ae..65683332 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createAuthorizationFlowMock = vi.fn(); @@ -569,6 +571,7 @@ describe("codex manager cli commands", () => { exchangeAuthorizationCodeMock.mockReset(); startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReturnValue(true); diff --git a/test/storage.test.ts b/test/storage.test.ts index d866d92f..27bdb7cf 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -434,6 +434,243 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); + it("should skip persisting duplicate-only imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + await saveAccounts(existing); + await fs.writeFile(exportPath, JSON.stringify(existing)); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should treat deduplicated current snapshots as a no-op import", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should deduplicate incoming backup rows before reporting skipped imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await clearAccounts(); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + lastUsed: 2, + }); + }); + + it("should persist duplicate-only imports when they refresh stored metadata", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "existing", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("should skip semantically identical duplicate-only imports even when key order differs", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const existing = { version: 3, @@ -478,7 +715,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -528,7 +770,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -573,7 +820,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1107,6 +1359,73 @@ describe("storage", () => { ); }); + it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { + const nearLimitAccounts = Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: nearLimitAccounts, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-one", + refreshToken: "ref-extra-one", + addedAt: 10_000, + lastUsed: 10_000, + }, + ], + }), + ); + + const first = await importAccounts(exportPath); + expect(first).toMatchObject({ + imported: 1, + skipped: 0, + total: ACCOUNT_LIMITS.MAX_ACCOUNTS, + changed: true, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-two", + refreshToken: "ref-extra-two", + addedAt: 20_000, + lastUsed: 20_000, + }, + ], + }), + ); + + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + loaded?.accounts.some((account) => account.accountId === "extra-two"), + ).toBe(false); + }); + it("should fail export when no accounts exist", async () => { const isolatedStorageDir = join( testWorkDir, @@ -1144,6 +1463,51 @@ describe("storage", () => { ); }); + it("retries transient import read errors before parsing the backup", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-import-read", + refreshToken: "ref-retry-import-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === exportPath && busyFailures === 0) { + busyFailures += 1; + const error = new Error("import file busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("should fail import when file contains invalid JSON", async () => { await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); From 6f12f458d1df6366de18554daa86a9309bff0704 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:41:55 +0800 Subject: [PATCH 37/76] feat(auth): prompt for recovery on startup --- docs/reference/commands.md | 2 - docs/reference/public-api.md | 6 - docs/reference/storage-paths.md | 4 - lib/codex-manager.ts | 30 --- lib/storage.ts | 191 +++------------- test/cli.test.ts | 10 - test/codex-manager-cli.test.ts | 41 ++-- test/storage.test.ts | 374 +------------------------------- 8 files changed, 52 insertions(+), 606 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ab142c2b..131cb47a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,7 +26,6 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | -| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -122,7 +121,6 @@ codex auth report --live --json Repair and recovery: ```bash -codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a76eb85a..865189ff 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,12 +37,6 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. -Current additive compatibility note: - -- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. -- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. -- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. - ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 8ea743ec..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,10 +109,6 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. -Direct entrypoint: - -- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. - --- ## oc-chatgpt Target Paths diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3e45d01a..3e8bfe0e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -362,7 +362,6 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", - " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -4063,21 +4062,6 @@ async function runAuthLogin(): Promise { pendingMenuQuotaRefresh = null; }); } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); } const flaggedStorage = await loadFlaggedAccounts(); @@ -5469,20 +5453,6 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } - if (command === "restore-backup") { - setStoragePath(null); - try { - const completedWithoutFailure = - await runBackupRestoreManager(startupDisplaySettings); - return completedWithoutFailure ? 0 : 1; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - return 1; - } - } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index 566d72f6..4e0fc874 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,13 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { - existsSync, - lstatSync, - promises as fs, - realpathSync, - type Dirent, -} from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -2913,19 +2907,11 @@ async function loadBackupCandidate(path: string): Promise loadAccountsFromPath(path), ); } catch (error) { - const errorMessage = - error instanceof SyntaxError - ? `Invalid JSON in import file: ${path}` - : (error as NodeJS.ErrnoException).code === "ENOENT" - ? `Import file not found: ${path}` - : error instanceof Error - ? error.message - : String(error); return { normalized: null, storedVersion: undefined, schemaErrors: [], - error: errorMessage, + error: String(error), }; } } @@ -2955,12 +2941,28 @@ async function findExistingNamedBackupPath( ? requested : `${requested}.json`; const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - let entries: Dirent[]; try { - entries = await retryTransientFilesystemOperation(() => + const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -2973,153 +2975,25 @@ async function findExistingNamedBackupPath( throw error; } - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - return undefined; } -function resolvePathForNamedBackupContainment(path: string): string { - const resolvedPath = resolvePath(path); - let existingPrefix = resolvedPath; - const unresolvedSegments: string[] = []; - while (true) { - try { - namedBackupContainmentFs.lstat(existingPrefix); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - const parentPath = dirname(existingPrefix); - if (parentPath === existingPrefix) { - return resolvedPath; - } - unresolvedSegments.unshift(basename(existingPrefix)); - existingPrefix = parentPath; - continue; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } - } - try { - const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); - return unresolvedSegments.reduce( - (currentPath, segment) => join(currentPath, segment), - canonicalPrefix, - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return resolvedPath; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } -} - -export function assertNamedBackupRestorePath( - path: string, - backupRoot: string, -): string { - const resolvedPath = resolvePath(path); - const resolvedBackupRoot = resolvePath(backupRoot); - let backupRootIsSymlink = false; - try { - backupRootIsSymlink = - namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - backupRootIsSymlink = false; - } else if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } else { - throw error; - } - } - if (backupRootIsSymlink) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - const canonicalBackupRoot = - resolvePathForNamedBackupContainment(resolvedBackupRoot); - const containedPath = resolvePathForNamedBackupContainment(resolvedPath); - const relativePath = relative(canonicalBackupRoot, containedPath); - const firstSegment = relativePath.split(/[\\/]/)[0]; - if ( - relativePath.length === 0 || - firstSegment === ".." || - isAbsolute(relativePath) - ) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - return containedPath; -} - -export function isNamedBackupContainmentError(error: unknown): boolean { - return ( - error instanceof BackupContainmentError || - (error instanceof Error && /escapes backup directory/i.test(error.message)) - ); -} - -export function isNamedBackupPathValidationTransientError( - error: unknown, -): error is BackupPathValidationTransientError { - return ( - error instanceof BackupPathValidationTransientError || - (error instanceof Error && - /^Backup path validation failed(\.|:|\b)/i.test(error.message)) - ); -} - -export async function resolveNamedBackupRestorePath(name: string): Promise { - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); +async function resolveNamedBackupRestorePath(name: string): Promise { const existingPath = await findExistingNamedBackupPath(name); if (existingPath) { - return assertNamedBackupRestorePath(existingPath, backupRoot); + return existingPath; } + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; - const baseName = requestedWithExtension.slice(0, -".json".length); - let builtPath: string; try { - builtPath = buildNamedBackupPath(requested); + return buildNamedBackupPath(name); } catch (error) { - // buildNamedBackupPath rejects names with special characters even when the - // requested backup name is a plain filename inside the backups directory. - // In that case, reporting ENOENT is clearer than surfacing the filename - // validator, but only when no separator/traversal token is present. + const baseName = requestedWithExtension.toLowerCase().endsWith(".json") + ? requestedWithExtension.slice(0, -".json".length) + : requestedWithExtension; if ( requested.length > 0 && basename(requestedWithExtension) === requestedWithExtension && @@ -3132,7 +3006,6 @@ export async function resolveNamedBackupRestorePath(name: string): Promise { +): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { @@ -4103,7 +3976,3 @@ export async function importAccounts( snapshotFailurePolicy: "error", }); } - -export const __testOnly = { - namedBackupContainmentFs, -}; diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,16 +716,6 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); - - mockRl.question.mockResolvedValueOnce("backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); - - mockRl.question.mockResolvedValueOnce("restore-backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 65683332..989729b8 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,5 +1,3 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createAuthorizationFlowMock = vi.fn(); @@ -571,7 +569,6 @@ describe("codex manager cli commands", () => { exchangeAuthorizationCodeMock.mockReset(); startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); - setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReturnValue(true); @@ -5353,28 +5350,26 @@ describe("codex manager cli commands", () => { .mockImplementationOnce(async () => "preview-restore"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), }), ); expect(confirmMock).not.toHaveBeenCalled(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 27bdb7cf..2470f97f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -434,243 +434,6 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); - it("should skip persisting duplicate-only imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - const existing = { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }; - await saveAccounts(existing); - await fs.writeFile(exportPath, JSON.stringify(existing)); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should treat deduplicated current snapshots as a no-op import", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should deduplicate incoming backup rows before reporting skipped imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await clearAccounts(); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - lastUsed: 2, - }); - }); - - it("should persist duplicate-only imports when they refresh stored metadata", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "existing", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - - it("should skip semantically identical duplicate-only imports even when key order differs", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const existing = { version: 3, @@ -715,12 +478,7 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ - imported: 1, - total: 3, - skipped: 0, - changed: true, - }); + expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -770,12 +528,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -820,12 +573,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1359,73 +1107,6 @@ describe("storage", () => { ); }); - it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { - const nearLimitAccounts = Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, - (_, index) => ({ - accountId: `existing-${index}`, - refreshToken: `ref-existing-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: nearLimitAccounts, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-one", - refreshToken: "ref-extra-one", - addedAt: 10_000, - lastUsed: 10_000, - }, - ], - }), - ); - - const first = await importAccounts(exportPath); - expect(first).toMatchObject({ - imported: 1, - skipped: 0, - total: ACCOUNT_LIMITS.MAX_ACCOUNTS, - changed: true, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-two", - refreshToken: "ref-extra-two", - addedAt: 20_000, - lastUsed: 20_000, - }, - ], - }), - ); - - await expect(importAccounts(exportPath)).rejects.toThrow( - /exceed maximum/, - ); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); - expect( - loaded?.accounts.some((account) => account.accountId === "extra-two"), - ).toBe(false); - }); - it("should fail export when no accounts exist", async () => { const isolatedStorageDir = join( testWorkDir, @@ -1463,51 +1144,6 @@ describe("storage", () => { ); }); - it("retries transient import read errors before parsing the backup", async () => { - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-import-read", - refreshToken: "ref-retry-import-read", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - const originalReadFile = fs.readFile.bind(fs); - let busyFailures = 0; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === exportPath && busyFailures === 0) { - busyFailures += 1; - const error = new Error("import file busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const result = await importAccounts(exportPath); - expect(result).toMatchObject({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(busyFailures).toBe(1); - } finally { - readFileSpy.mockRestore(); - } - }); - it("should fail import when file contains invalid JSON", async () => { await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); @@ -2206,10 +1842,8 @@ describe("storage", () => { try { await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); - expect(readdirSpy).toHaveBeenCalledTimes(1); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); From 595870f8ffcc7a4872544f435df84bfa250925f9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:22:01 +0800 Subject: [PATCH 38/76] fix(auth): clean startup recovery prompt branch --- docs/reference/commands.md | 2 ++ docs/reference/storage-paths.md | 4 +++ lib/codex-manager.ts | 2 +- test/cli.test.ts | 10 ++++++ test/codex-manager-cli.test.ts | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 131cb47a..ab142c2b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -121,6 +122,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index cf0747de..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,6 +109,10 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3e8bfe0e..a111f208 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -5098,7 +5098,7 @@ async function runBackupRestorePreview( console.warn( `Failed to re-assess backup "${backupName}" before restore (${errorLabel}).`, ); - return "dismissed"; + return "failed"; } if (output.isTTY) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 269a0eba..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,6 +716,16 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 989729b8..11fa90f0 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1338,6 +1338,61 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ From f3962fc9fe1d6a32a759c75c98fbf4e9d68df727 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 05:57:48 +0800 Subject: [PATCH 39/76] feat(auth): add restore preview --- lib/codex-manager.ts | 1 + test/codex-manager-cli.test.ts | 198 ++++++++++++++++++++++++++++++--- test/storage.test.ts | 4 +- 3 files changed, 183 insertions(+), 20 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a111f208..146f8582 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -5069,6 +5069,7 @@ async function showBackupBrowserDetails( [ { label: "Preview Restore", value: "preview-restore", color: "green" }, { label: "Back", value: "back" }, + { label: "Preview Restore", value: "preview-restore", color: "green" }, ], { message: "Backup Actions", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 11fa90f0..9d07ee20 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1393,6 +1393,166 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("runs restore preview before applying a replace-only named backup", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "current@example.com", + accountId: "current-account", + refreshToken: "refresh-current", + accessToken: "access-current", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + backupAccountCount: 1, + dedupedBackupAccountCount: 1, + conflictsWithinBackup: 0, + conflictsWithExisting: 0, + replacedExistingCount: 1, + keptExistingCount: 0, + keptBackupCount: 1, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "current@example.com", + nextActiveAccountId: "current-account", + currentActiveIndex: 0, + currentActiveEmail: "current@example.com", + currentActiveAccountId: "current-account", + activeAccountOutcome: "unchanged", + activeAccountChanged: false, + activeAccountPreview: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + namedBackupRestorePreview: { + conflicts: [ + { + conflict: { + backupIndex: 0, + currentIndex: 0, + reasons: ["accountId", "email"], + resolution: "backup-kept", + }, + backup: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + }, + ], + activeAccount: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + }, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(assessment) + .mockResolvedValueOnce(assessment); + selectMock + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("preview-restore"); + confirmMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Backup Browser", + }); + expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ + message: "Backup Actions", + subtitle: "named-backup", + }); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -5405,26 +5565,28 @@ describe("codex manager cli commands", () => { .mockImplementationOnce(async () => "preview-restore"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), ); expect(confirmMock).not.toHaveBeenCalled(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 2470f97f..5aa8443d 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1646,7 +1646,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/Import file not found/); + ).rejects.toThrow(/ENOENT: no such file or directory/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1674,7 +1674,7 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/Invalid JSON in import file/); + ).rejects.toThrow(/is not valid JSON/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); From bb43cc2054ced6e1d202a5164479fec3a84db5b4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:33:11 +0800 Subject: [PATCH 40/76] fix(storage): snapshot exact pre-mutation state --- lib/destructive-actions.ts | 1 + lib/storage.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 0d5a308b..1b7b97d4 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -7,6 +7,7 @@ import { clearFlaggedAccounts, findMatchingAccountIndex, type FlaggedAccountStorageV1, + getStoragePath, loadFlaggedAccounts, snapshotAccountStorage, snapshotAndClearAccounts, diff --git a/lib/storage.ts b/lib/storage.ts index 4e0fc874..96ba267e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2739,6 +2739,13 @@ async function importNormalizedAccounts( total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { + if (snapshotReason) { + await snapshotAccountStorage({ + reason: snapshotReason, + storage: existing, + storagePath: getStoragePath(), + }); + } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; From cc002c24ba22498729a46981b34fc249eb11086f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 06:35:58 +0800 Subject: [PATCH 41/76] feat(sync): add manual rollback --- lib/codex-cli/sync.ts | 229 +++++++++++++++++- lib/codex-manager/settings-hub.ts | 67 ++++++ lib/storage.ts | 3 +- lib/ui/copy.ts | 3 +- test/codex-cli-sync.test.ts | 388 ++++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 16 ++ 6 files changed, 699 insertions(+), 7 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index a87a9a6f..542035ce 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -7,7 +7,11 @@ import { findMatchingAccountIndex, getLastAccountsSaveTimestamp, getStoragePath, + type NamedBackupMetadata, + normalizeAccountStorage, normalizeEmailKey, + saveAccounts, + snapshotAccountStorage, } from "../storage.js"; import { incrementCodexCliMetric, @@ -72,6 +76,28 @@ export interface CodexCliSyncSummary { selectionChanged: boolean; } +export type CodexCliSyncTrigger = "manual" | "automatic"; + +export interface CodexCliSyncRollbackSnapshot { + name: string; + path: string; +} + +export interface CodexCliSyncRollbackPlan { + status: "ready" | "unavailable"; + reason: string; + snapshot: CodexCliSyncRollbackSnapshot | null; + accountCount?: number; + storage?: AccountStorageV3; +} + +export interface CodexCliSyncRollbackResult { + status: "restored" | "unavailable" | "error"; + reason: string; + snapshot: CodexCliSyncRollbackSnapshot | null; + accountCount?: number; +} + export interface CodexCliSyncBackupContext { enabled: boolean; targetPath: string; @@ -96,6 +122,8 @@ export interface CodexCliSyncRun { targetPath: string; summary: CodexCliSyncSummary; message?: string; + trigger: CodexCliSyncTrigger; + rollbackSnapshot: CodexCliSyncRollbackSnapshot | null; } export interface PendingCodexCliSyncRun { @@ -135,13 +163,37 @@ function createEmptySyncSummary(): CodexCliSyncSummary { }; } -function cloneCodexCliSyncRun(run: CodexCliSyncRun): CodexCliSyncRun { +function normalizeRollbackSnapshot( + snapshot: CodexCliSyncRollbackSnapshot | null | undefined, +): CodexCliSyncRollbackSnapshot | null { + if (!snapshot || typeof snapshot !== "object") { + return null; + } + if (typeof snapshot.name !== "string" || typeof snapshot.path !== "string") { + return null; + } + return { + name: snapshot.name, + path: snapshot.path, + }; +} + +function normalizeCodexCliSyncRun( + run: CodexCliSyncRun | null, +): CodexCliSyncRun | null { + if (!run) return null; return { ...run, summary: { ...run.summary }, + trigger: run.trigger === "manual" ? "manual" : "automatic", + rollbackSnapshot: normalizeRollbackSnapshot(run.rollbackSnapshot), }; } +function cloneCodexCliSyncRun(run: CodexCliSyncRun): CodexCliSyncRun { + return normalizeCodexCliSyncRun(run) ?? run; +} + function normalizeIndexCandidate(value: number, fallback: number): number { if (!Number.isFinite(value)) { return Number.isFinite(fallback) ? Math.trunc(fallback) : 0; @@ -200,16 +252,20 @@ function buildSyncRunError( ...run, outcome: "error", message: error instanceof Error ? error.message : String(error), + rollbackSnapshot: null, }; } function createSyncRun( - run: Omit, + run: Omit & + Partial>, ): CodexCliSyncRun { - return { + return cloneCodexCliSyncRun({ ...run, runAt: Date.now(), - }; + trigger: run.trigger ?? "automatic", + rollbackSnapshot: run.rollbackSnapshot ?? null, + }); } function hasSourceStateOverride(options: { @@ -264,6 +320,9 @@ export function commitPendingCodexCliSyncRun( { ...pendingRun.run, runAt: Date.now(), + rollbackSnapshot: normalizeRollbackSnapshot( + pendingRun.run.rollbackSnapshot, + ), }, allocateCodexCliSyncRunRevision(), ); @@ -297,6 +356,154 @@ export function __resetLastCodexCliSyncRunForTests(): void { lastCodexCliSyncHistoryLoadAttempted = false; } +async function captureRollbackSnapshot(): Promise { + const snapshot: NamedBackupMetadata | null = await snapshotAccountStorage({ + reason: "codex-cli-sync", + failurePolicy: "warn", + }); + if (!snapshot) return null; + return { + name: snapshot.name, + path: snapshot.path, + }; +} + +function isManualChangedSyncRun(run: CodexCliSyncRun | null): run is CodexCliSyncRun { + return Boolean(run && run.outcome === "changed" && run.trigger === "manual"); +} + +async function findLatestManualRollbackRun(): Promise< + CodexCliSyncRun | null +> { + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (!entry || entry.kind !== "codex-cli-sync") continue; + const run = normalizeCodexCliSyncRun(entry.run); + if (isManualChangedSyncRun(run)) { + return run; + } + } + return null; +} + +async function loadRollbackSnapshot( + snapshot: CodexCliSyncRollbackSnapshot | null, +): Promise { + if (!snapshot) { + return { + status: "unavailable", + reason: "No rollback checkpoint is available for the last manual apply.", + snapshot: null, + }; + } + if (!snapshot.name.trim()) { + return { + status: "unavailable", + reason: "Rollback checkpoint is missing its snapshot name.", + snapshot: null, + }; + } + if (!snapshot.path.trim()) { + return { + status: "unavailable", + reason: "Rollback checkpoint is missing its snapshot path.", + snapshot: null, + }; + } + + try { + const raw = await fs.readFile(snapshot.path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeAccountStorage(parsed); + if (!normalized) { + return { + status: "unavailable", + reason: "Rollback checkpoint is invalid or empty.", + snapshot, + }; + } + return { + status: "ready", + reason: `Rollback checkpoint ready (${normalized.accounts.length} account(s)).`, + snapshot, + accountCount: normalized.accounts.length, + storage: normalized, + }; + } catch (error) { + const reason = + (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Rollback checkpoint is missing at ${snapshot.path}.` + : `Failed to read rollback checkpoint: ${ + error instanceof Error ? error.message : String(error) + }`; + return { + status: "unavailable", + reason, + snapshot, + }; + } +} + +export async function getLatestCodexCliSyncRollbackPlan(): Promise { + const lastManualRun = await findLatestManualRollbackRun(); + if (!lastManualRun) { + return { + status: "unavailable", + reason: "No manual Codex CLI apply with a rollback checkpoint is available.", + snapshot: null, + }; + } + return loadRollbackSnapshot(lastManualRun.rollbackSnapshot); +} + +export async function rollbackLatestCodexCliSync( + plan?: CodexCliSyncRollbackPlan, +): Promise { + const resolvedPlan = + plan && plan.status === "ready" + ? plan + : await getLatestCodexCliSyncRollbackPlan(); + if (resolvedPlan.status !== "ready" || !resolvedPlan.storage) { + return { + status: "unavailable", + reason: resolvedPlan.reason, + snapshot: resolvedPlan.snapshot, + }; + } + + try { + await saveAccounts(resolvedPlan.storage); + return { + status: "restored", + reason: resolvedPlan.reason, + snapshot: resolvedPlan.snapshot, + accountCount: + resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length, + }; + } catch (error) { + return { + status: "error", + reason: error instanceof Error ? error.message : String(error), + snapshot: resolvedPlan.snapshot, + }; + } +} + +export async function rollbackLastCodexCliSync(): Promise< + CodexCliSyncRollbackResult & { status: "restored"; snapshot: CodexCliSyncRollbackSnapshot } +> { + const result = await rollbackLatestCodexCliSync(); + if (result.status !== "restored" || !result.snapshot) { + throw new Error(result.reason); + } + return { + ...result, + status: "restored", + snapshot: result.snapshot, + }; +} + function hasConflictingIdentity( accounts: AccountMetadataV3[], snapshot: CodexCliAccountSnapshot, @@ -763,7 +970,11 @@ export async function previewCodexCliSync( */ export async function applyCodexCliSyncToStorage( current: AccountStorageV3 | null, - options: { forceRefresh?: boolean; sourceState?: CodexCliState | null } = {}, + options: { + forceRefresh?: boolean; + sourceState?: CodexCliState | null; + trigger?: CodexCliSyncTrigger; + } = {}, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean; @@ -771,6 +982,7 @@ export async function applyCodexCliSyncToStorage( }> { incrementCodexCliMetric("reconcileAttempts"); const targetPath = getStoragePath(); + const trigger: CodexCliSyncTrigger = options.trigger ?? "automatic"; try { if (!isCodexCliSyncEnabled()) { incrementCodexCliMetric("reconcileNoops"); @@ -785,6 +997,7 @@ export async function applyCodexCliSyncToStorage( targetAccountCountAfter: current?.accounts.length ?? 0, }, message: "Codex CLI sync disabled by environment override.", + trigger, }), allocateCodexCliSyncRunRevision(), ); @@ -808,6 +1021,7 @@ export async function applyCodexCliSyncToStorage( targetAccountCountAfter: current?.accounts.length ?? 0, }, message: "No Codex CLI sync source was available.", + trigger, }), allocateCodexCliSyncRunRevision(), ); @@ -821,11 +1035,15 @@ export async function applyCodexCliSyncToStorage( const changed = reconciled.changed; const storage = next.accounts.length === 0 ? (current ?? next) : next; + const rollbackSnapshot = + trigger === "manual" && changed ? await captureRollbackSnapshot() : null; const syncRun = createSyncRun({ outcome: changed ? "changed" : "noop", sourcePath: state.path, targetPath, summary: reconciled.summary, + trigger, + rollbackSnapshot, }); if (!changed) { @@ -868,6 +1086,7 @@ export async function applyCodexCliSyncToStorage( targetAccountCountAfter: current?.accounts.length ?? 0, }, message: error instanceof Error ? error.message : String(error), + trigger, }), allocateCodexCliSyncRunRevision(), ); diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 5af5a386..697eebb0 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -14,12 +14,15 @@ import { commitCodexCliSyncRunFailure, commitPendingCodexCliSyncRun, type CodexCliSyncPreview, + type CodexCliSyncRollbackPlan, + type CodexCliSyncRollbackResult, type CodexCliSyncRun, type CodexCliSyncSummary, formatRollbackPaths, getLastCodexCliSyncRun, previewCodexCliSync, } from "../codex-cli/sync.js"; +import * as codexCliSyncModule from "../codex-cli/sync.js"; import { getDefaultPluginConfig, getStorageBackupEnabled, @@ -304,6 +307,7 @@ type SettingsHubAction = type SyncCenterAction = | { type: "refresh" } | { type: "apply" } + | { type: "rollback" } | { type: "back" }; interface SyncCenterOverviewContext { @@ -315,6 +319,32 @@ interface SyncCenterOverviewContext { syncEnabled: boolean; } +async function getSyncCenterRollbackPlan(): Promise { + if ( + typeof codexCliSyncModule.getLatestCodexCliSyncRollbackPlan === "function" + ) { + return codexCliSyncModule.getLatestCodexCliSyncRollbackPlan(); + } + return { + status: "unavailable", + reason: "Rollback checkpoint is unavailable.", + snapshot: null, + }; +} + +async function runSyncCenterRollback( + plan: CodexCliSyncRollbackPlan, +): Promise { + if (typeof codexCliSyncModule.rollbackLatestCodexCliSync === "function") { + return codexCliSyncModule.rollbackLatestCodexCliSync(plan); + } + return { + status: "unavailable", + reason: "Rollback checkpoint is unavailable.", + snapshot: plan.snapshot, + }; +} + type ExperimentalSettingsAction = | { type: "sync" } | { type: "backup" } @@ -2774,6 +2804,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { }; let { preview, context, sourceState } = await buildPreviewSafely(true); + let rollbackPlan = await getSyncCenterRollbackPlan(); while (true) { const overview = buildSyncCenterOverview(preview, context); const items: MenuItem[] = [ @@ -2803,6 +2834,13 @@ async function promptSyncCenter(config: PluginConfig): Promise { color: preview.status === "ready" ? "green" : "yellow", disabled: preview.status !== "ready", }, + { + label: UI_COPY.settings.syncCenterRollback, + hint: rollbackPlan.reason, + value: { type: "rollback" }, + color: rollbackPlan.status === "ready" ? "green" : "yellow", + disabled: rollbackPlan.status !== "ready", + }, { label: UI_COPY.settings.syncCenterRefresh, hint: "Re-read the source files and rebuild the sync preview.", @@ -2830,6 +2868,9 @@ async function promptSyncCenter(config: PluginConfig): Promise { if (lower === "a" && preview.status === "ready") { return { type: "apply" }; } + if (lower === "l" && rollbackPlan.status === "ready") { + return { type: "rollback" }; + } return undefined; }, }); @@ -2840,6 +2881,28 @@ async function promptSyncCenter(config: PluginConfig): Promise { true, preview, )); + rollbackPlan = await getSyncCenterRollbackPlan(); + continue; + } + if (result.type === "rollback") { + const rollbackResult = await withQueuedRetry(preview.targetPath, async () => + runSyncCenterRollback(rollbackPlan), + ); + if (rollbackResult.status !== "restored") { + preview = { + ...preview, + status: "error", + lastSync: getLastCodexCliSyncRun(), + statusDetail: rollbackResult.reason, + }; + rollbackPlan = await getSyncCenterRollbackPlan(); + continue; + } + ({ preview, context, sourceState } = await buildPreviewSafely( + true, + preview, + )); + rollbackPlan = await getSyncCenterRollbackPlan(); continue; } @@ -2848,6 +2911,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { const current = await loadAccounts(); return applyCodexCliSyncToStorage(current, { sourceState, + trigger: "manual", }); }); const storageBackupEnabled = getStorageBackupEnabled(config); @@ -2870,6 +2934,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { error instanceof Error ? error.message : String(error) }`, }; + rollbackPlan = await getSyncCenterRollbackPlan(); continue; } } @@ -2877,6 +2942,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { true, preview, )); + rollbackPlan = await getSyncCenterRollbackPlan(); } catch (error) { preview = { ...preview, @@ -2886,6 +2952,7 @@ async function promptSyncCenter(config: PluginConfig): Promise { error instanceof Error ? error.message : String(error) }`, }; + rollbackPlan = await getSyncCenterRollbackPlan(); } } } diff --git a/lib/storage.ts b/lib/storage.ts index 96ba267e..056f7397 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -210,7 +210,8 @@ export type AccountSnapshotReason = | "delete-account" | "delete-saved-accounts" | "reset-local-state" - | "import-accounts"; + | "import-accounts" + | "codex-cli-sync"; export type AccountSnapshotFailurePolicy = "warn" | "error"; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index bb42b09b..07dfb54e 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -128,11 +128,12 @@ export const UI_COPY = { syncCenterTitle: "Codex CLI Sync", syncCenterSubtitle: "Inspect source files and preview one-way sync before applying it", - syncCenterHelp: "Enter Select | A Apply | R Refresh | Q Back", + syncCenterHelp: "Enter Select | A Apply | L Rollback | R Refresh | Q Back", syncCenterOverviewHeading: "Sync Overview", syncCenterActionsHeading: "Actions", syncCenterRefresh: "Refresh Preview", syncCenterApply: "Apply Preview To Target", + syncCenterRollback: "Rollback Last Apply", syncCenterBack: "Back", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index c128d8b7..5da19888 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -6,6 +6,7 @@ import type { AccountStorageV3 } from "../lib/storage.js"; import * as storageModule from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; +import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; import { __resetLastCodexCliSyncRunForTests, applyCodexCliSyncToStorage, @@ -13,12 +14,16 @@ import { commitPendingCodexCliSyncRun, getActiveSelectionForFamily, getLastCodexCliSyncRun, + getLatestCodexCliSyncRollbackPlan, previewCodexCliSync, + rollbackLastCodexCliSync, + rollbackLatestCodexCliSync, SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS, syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; import { __resetSyncHistoryForTests, + appendSyncHistoryEntry, configureSyncHistoryForTests, readSyncHistory, } from "../lib/sync-history.js"; @@ -1341,6 +1346,389 @@ describe("codex-cli sync", () => { expect(persisted?.summary.addedAccountCount).toBe(1); }); + it("records rollback snapshot metadata for manual applies", async () => { + const snapshotSpy = vi.spyOn(storageModule, "snapshotAccountStorage"); + snapshotSpy.mockResolvedValue({ + name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_001", + path: join(tempDir, "rollback-snapshot.json"), + createdAt: 1, + updatedAt: 1, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + }); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + trigger: "manual", + sourceState: { + path: accountsPath, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + isActive: true, + }, + ], + }, + }); + + expect(result.changed).toBe(true); + expect(snapshotSpy).toHaveBeenCalledWith({ + reason: "codex-cli-sync", + failurePolicy: "warn", + }); + expect(result.pendingRun?.run.trigger).toBe("manual"); + expect(result.pendingRun?.run.rollbackSnapshot).toEqual({ + name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_001", + path: join(tempDir, "rollback-snapshot.json"), + }); + + if (result.storage) { + await storageModule.saveAccounts(result.storage); + } + commitPendingCodexCliSyncRun(result.pendingRun); + + const lastRun = getLastCodexCliSyncRun(); + expect(lastRun?.trigger).toBe("manual"); + expect(lastRun?.rollbackSnapshot).toEqual({ + name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_001", + path: join(tempDir, "rollback-snapshot.json"), + }); + + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + expect(history.at(-1)?.run.trigger).toBe("manual"); + expect(history.at(-1)?.run.rollbackSnapshot).toEqual({ + name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_001", + path: join(tempDir, "rollback-snapshot.json"), + }); + }); + + it("does not snapshot manual sync runs when nothing would change", async () => { + const snapshotSpy = vi.spyOn(storageModule, "snapshotAccountStorage"); + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await applyCodexCliSyncToStorage(current, { + trigger: "manual", + sourceState: { + path: accountsPath, + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + isActive: true, + }, + ], + }, + }); + + expect(result.changed).toBe(false); + expect(result.pendingRun).toBeNull(); + expect(snapshotSpy).not.toHaveBeenCalled(); + expect(getLastCodexCliSyncRun()?.trigger).toBe("manual"); + expect(getLastCodexCliSyncRun()?.rollbackSnapshot).toBeNull(); + }); + + it("restores the latest manual apply even when newer runs do not have checkpoints", async () => { + const snapshotPath = join(tempDir, "rollback-snapshot.json"); + const snapshotStorage: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + await writeFile(snapshotPath, JSON.stringify(snapshotStorage, null, 2), "utf-8"); + + await storageModule.saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acc_new", + accountIdSource: "token", + email: "new@example.com", + refreshToken: "refresh-new", + accessToken: "access-new", + addedAt: 2, + lastUsed: 2, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + + const summary = { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + const manualChangedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_002", + path: snapshotPath, + }, + }; + const automaticRun: CodexCliSyncRun = { + outcome: "noop", + runAt: 20, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { ...summary, updatedAccountCount: 0, unchangedAccountCount: 1 }, + trigger: "automatic", + rollbackSnapshot: null, + }; + const manualNoopRun: CodexCliSyncRun = { + outcome: "noop", + runAt: 30, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { ...summary, updatedAccountCount: 0, unchangedAccountCount: 1 }, + trigger: "manual", + rollbackSnapshot: null, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: manualChangedRun.runAt, + run: manualChangedRun, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: automaticRun.runAt, + run: automaticRun, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: manualNoopRun.runAt, + run: manualNoopRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + expect(plan.snapshot).toEqual(manualChangedRun.rollbackSnapshot); + + const rollbackResult = await rollbackLastCodexCliSync(); + expect(rollbackResult.status).toBe("restored"); + expect(rollbackResult.snapshot).toEqual(manualChangedRun.rollbackSnapshot); + + const restored = await storageModule.loadAccounts(); + expect(restored?.accounts).toHaveLength(1); + expect(restored?.accounts[0]?.refreshToken).toBe("refresh-old"); + }); + + it("marks the rollback plan unavailable when the checkpoint file is missing", async () => { + const missingRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-missing", + path: join(tempDir, "missing-rollback-snapshot.json"), + }, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: missingRun.runAt, + run: missingRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("unavailable"); + expect(plan.reason).toContain("missing"); + + const rollbackResult = await rollbackLatestCodexCliSync(plan); + expect(rollbackResult.status).toBe("unavailable"); + expect(rollbackResult.reason).toContain("missing"); + }); + + it.each([ + ["snapshot name", /name/i], + ["snapshot path", /path/i], + ] satisfies Array<[string, RegExp]>)( + "rejects rollback when the recorded %s is blank", + async (field, messagePattern) => { + const invalidRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: + field === "snapshot name" + ? { + name: "", + path: join(tempDir, "rollback-snapshot.json"), + } + : { + name: "accounts-codex-cli-sync-snapshot-invalid", + path: "", + }, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: invalidRun.runAt, + run: invalidRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("unavailable"); + expect(plan.reason).toMatch(messagePattern); + await expect(rollbackLastCodexCliSync()).rejects.toThrow(messagePattern); + }, + ); + + it("surfaces rollback save failures through both rollback APIs", async () => { + const snapshotPath = join(tempDir, "rollback-error-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + const recordedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-error", + path: snapshotPath, + }, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: recordedRun.runAt, + run: recordedRun, + }); + + const saveSpy = vi + .spyOn(storageModule, "saveAccounts") + .mockRejectedValue(new Error("save busy")); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + + const rollbackResult = await rollbackLatestCodexCliSync(plan); + expect(rollbackResult.status).toBe("error"); + expect(rollbackResult.reason).toBe("save busy"); + await expect(rollbackLastCodexCliSync()).rejects.toThrow("save busy"); + + saveSpy.mockRestore(); + }); + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { await writeFile( accountsPath, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 9d07ee20..0890a45d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -32,6 +32,8 @@ const previewCodexCliSyncMock = vi.fn(); const applyCodexCliSyncToStorageMock = vi.fn(); const commitPendingCodexCliSyncRunMock = vi.fn(); const commitCodexCliSyncRunFailureMock = vi.fn(); +const getLatestCodexCliSyncRollbackPlanMock = vi.fn(); +const rollbackLatestCodexCliSyncMock = vi.fn(); const formatRollbackPathsMock = vi.fn((targetPath: string) => [ `${targetPath}.bak`, `${targetPath}.bak.1`, @@ -170,8 +172,10 @@ vi.mock("../lib/codex-cli/sync.js", () => ({ commitCodexCliSyncRunFailure: commitCodexCliSyncRunFailureMock, commitPendingCodexCliSyncRun: commitPendingCodexCliSyncRunMock, formatRollbackPaths: formatRollbackPathsMock, + getLatestCodexCliSyncRollbackPlan: getLatestCodexCliSyncRollbackPlanMock, getLastCodexCliSyncRun: getLastCodexCliSyncRunMock, previewCodexCliSync: previewCodexCliSyncMock, + rollbackLatestCodexCliSync: rollbackLatestCodexCliSyncMock, })); vi.mock("../lib/codex-cli/state.js", () => ({ @@ -585,6 +589,8 @@ describe("codex manager cli commands", () => { applyCodexCliSyncToStorageMock.mockReset(); commitPendingCodexCliSyncRunMock.mockReset(); commitCodexCliSyncRunFailureMock.mockReset(); + getLatestCodexCliSyncRollbackPlanMock.mockReset(); + rollbackLatestCodexCliSyncMock.mockReset(); formatRollbackPathsMock.mockReset(); formatRollbackPathsMock.mockImplementation((targetPath: string) => [ `${targetPath}.bak`, @@ -769,6 +775,16 @@ describe("codex manager cli commands", () => { storage: null, pendingRun: null, }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValue({ + status: "unavailable", + reason: "No manual Codex CLI apply with a rollback checkpoint is available.", + snapshot: null, + }); + rollbackLatestCodexCliSyncMock.mockResolvedValue({ + status: "unavailable", + reason: "No manual Codex CLI apply with a rollback checkpoint is available.", + snapshot: null, + }); getCodexCliAccountsPathMock.mockReturnValue("/mock/codex/accounts.json"); getCodexCliAuthPathMock.mockReturnValue("/mock/codex/auth.json"); getCodexCliConfigPathMock.mockReturnValue("/mock/codex/config.toml"); From 20aff555bb50edad7e8731811ce3b3f957e28e43 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:42:34 +0800 Subject: [PATCH 42/76] Fix rollback checkpoint selection --- lib/codex-cli/sync.ts | 15 +++++- test/codex-cli-sync.test.ts | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 542035ce..264b0da3 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -372,19 +372,30 @@ function isManualChangedSyncRun(run: CodexCliSyncRun | null): run is CodexCliSyn return Boolean(run && run.outcome === "changed" && run.trigger === "manual"); } +function hasUsableRollbackSnapshot( + snapshot: CodexCliSyncRollbackSnapshot | null, +): snapshot is CodexCliSyncRollbackSnapshot { + return Boolean(snapshot?.name.trim() && snapshot.path.trim()); +} + async function findLatestManualRollbackRun(): Promise< CodexCliSyncRun | null > { const history = await readSyncHistory({ kind: "codex-cli-sync" }); + let fallbackRun: CodexCliSyncRun | null = null; for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; if (!entry || entry.kind !== "codex-cli-sync") continue; const run = normalizeCodexCliSyncRun(entry.run); - if (isManualChangedSyncRun(run)) { + if (!isManualChangedSyncRun(run)) { + continue; + } + fallbackRun ??= run; + if (hasUsableRollbackSnapshot(run.rollbackSnapshot)) { return run; } } - return null; + return fallbackRun; } async function loadRollbackSnapshot( diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 5da19888..77751fb8 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1575,6 +1575,98 @@ describe("codex-cli sync", () => { expect(restored?.accounts[0]?.refreshToken).toBe("refresh-old"); }); + it.each([ + ["null checkpoint", null], + [ + "blank checkpoint path", + { + name: "accounts-codex-cli-sync-snapshot-invalid", + path: " ", + }, + ], + ] satisfies Array< + [ + string, + { name: string; path: string } | null, + ] + >)( + "falls back to the newest valid rollback checkpoint when a newer manual change has a %s", + async (_label, invalidSnapshot) => { + const snapshotPath = join(tempDir, "rollback-fallback-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + const summary = { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + const validRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-valid", + path: snapshotPath, + }, + }; + const newerInvalidRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 20, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary, + trigger: "manual", + rollbackSnapshot: invalidSnapshot, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: validRun.runAt, + run: validRun, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: newerInvalidRun.runAt, + run: newerInvalidRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + expect(plan.snapshot).toEqual(validRun.rollbackSnapshot); + }, + ); + it("marks the rollback plan unavailable when the checkpoint file is missing", async () => { const missingRun: CodexCliSyncRun = { outcome: "changed", From 48c9ba7384c0955886f9f43a11a2429ce85fae0e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:42:35 +0800 Subject: [PATCH 43/76] fix(sync): harden rollback checkpoint recovery --- lib/codex-cli/sync.ts | 40 ++++++++++++++++--- test/codex-cli-sync.test.ts | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 264b0da3..51da9edc 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -6,6 +6,7 @@ import { type AccountStorageV3, findMatchingAccountIndex, getLastAccountsSaveTimestamp, + getRedactedFilesystemErrorLabel, getStoragePath, type NamedBackupMetadata, normalizeAccountStorage, @@ -35,6 +36,8 @@ import { const log = createLogger("codex-cli-sync"); const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; +const RETRYABLE_ROLLBACK_SAVE_CODES = new Set(["EBUSY", "EAGAIN"]); +const ROLLBACK_SAVE_MAX_ATTEMPTS = 5; function createEmptyStorage(): AccountStorageV3 { return { @@ -442,12 +445,11 @@ async function loadRollbackSnapshot( storage: normalized, }; } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); const reason = (error as NodeJS.ErrnoException).code === "ENOENT" - ? `Rollback checkpoint is missing at ${snapshot.path}.` - : `Failed to read rollback checkpoint: ${ - error instanceof Error ? error.message : String(error) - }`; + ? `Rollback checkpoint file not found (snapshot: ${snapshot.name}).` + : `Failed to read rollback checkpoint for snapshot ${snapshot.name} [${errorLabel}].`; return { status: "unavailable", reason, @@ -456,6 +458,34 @@ async function loadRollbackSnapshot( } } +function isRetryableRollbackSaveError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + if (typeof code !== "string") { + return false; + } + if (RETRYABLE_ROLLBACK_SAVE_CODES.has(code)) { + return true; + } + return code === "EPERM" && process.platform === "win32"; +} + +async function saveRollbackStorageWithRetry(storage: AccountStorageV3): Promise { + for (let attempt = 0; attempt < ROLLBACK_SAVE_MAX_ATTEMPTS; attempt += 1) { + try { + await saveAccounts(storage); + return; + } catch (error) { + if ( + !isRetryableRollbackSaveError(error) || + attempt + 1 >= ROLLBACK_SAVE_MAX_ATTEMPTS + ) { + throw error; + } + await sleep(10 * 2 ** attempt); + } + } +} + export async function getLatestCodexCliSyncRollbackPlan(): Promise { const lastManualRun = await findLatestManualRollbackRun(); if (!lastManualRun) { @@ -484,7 +514,7 @@ export async function rollbackLatestCodexCliSync( } try { - await saveAccounts(resolvedPlan.storage); + await saveRollbackStorageWithRetry(resolvedPlan.storage); return { status: "restored", reason: resolvedPlan.reason, diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 77751fb8..96f22a3a 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1699,10 +1699,15 @@ describe("codex-cli sync", () => { const plan = await getLatestCodexCliSyncRollbackPlan(); expect(plan.status).toBe("unavailable"); expect(plan.reason).toContain("missing"); + expect(plan.reason).toContain(missingRun.rollbackSnapshot?.name ?? ""); + expect(plan.reason).not.toContain(missingRun.rollbackSnapshot?.path ?? ""); const rollbackResult = await rollbackLatestCodexCliSync(plan); expect(rollbackResult.status).toBe("unavailable"); expect(rollbackResult.reason).toContain("missing"); + expect(rollbackResult.reason).not.toContain( + missingRun.rollbackSnapshot?.path ?? "", + ); }); it.each([ @@ -1821,6 +1826,78 @@ describe("codex-cli sync", () => { saveSpy.mockRestore(); }); + it("retries transient rollback save failures before succeeding", async () => { + const snapshotPath = join(tempDir, "rollback-retry-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + const recordedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-retry", + path: snapshotPath, + }, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: recordedRun.runAt, + run: recordedRun, + }); + + const transientError = Object.assign(new Error("save busy"), { + code: "EBUSY", + }); + const saveSpy = vi + .spyOn(storageModule, "saveAccounts") + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce(undefined); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + + const rollbackResult = await rollbackLatestCodexCliSync(plan); + expect(rollbackResult.status).toBe("restored"); + expect(saveSpy).toHaveBeenCalledTimes(2); + + saveSpy.mockRestore(); + }); + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { await writeFile( accountsPath, From 78587d4eb06713ba2d2e6ef239b3bbde907efd72 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 06:12:00 +0800 Subject: [PATCH 44/76] fix(sync): redact rollback save failures --- lib/codex-cli/sync.ts | 8 +- lib/codex-manager/settings-hub.ts | 20 +---- test/codex-cli-sync.test.ts | 143 ++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 24 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 51da9edc..00a0d8bb 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -481,6 +481,11 @@ async function saveRollbackStorageWithRetry(storage: AccountStorageV3): Promise< ) { throw error; } + log.warn("Retrying rollback checkpoint save after transient filesystem error", { + attempt: attempt + 1, + maxAttempts: ROLLBACK_SAVE_MAX_ATTEMPTS, + error: getRedactedFilesystemErrorLabel(error), + }); await sleep(10 * 2 ** attempt); } } @@ -523,9 +528,10 @@ export async function rollbackLatestCodexCliSync( resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length, }; } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); return { status: "error", - reason: error instanceof Error ? error.message : String(error), + reason: `Failed to restore rollback checkpoint [${errorLabel}].`, snapshot: resolvedPlan.snapshot, }; } diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 697eebb0..976f40ca 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -320,29 +320,13 @@ interface SyncCenterOverviewContext { } async function getSyncCenterRollbackPlan(): Promise { - if ( - typeof codexCliSyncModule.getLatestCodexCliSyncRollbackPlan === "function" - ) { - return codexCliSyncModule.getLatestCodexCliSyncRollbackPlan(); - } - return { - status: "unavailable", - reason: "Rollback checkpoint is unavailable.", - snapshot: null, - }; + return codexCliSyncModule.getLatestCodexCliSyncRollbackPlan(); } async function runSyncCenterRollback( plan: CodexCliSyncRollbackPlan, ): Promise { - if (typeof codexCliSyncModule.rollbackLatestCodexCliSync === "function") { - return codexCliSyncModule.rollbackLatestCodexCliSync(plan); - } - return { - status: "unavailable", - reason: "Rollback checkpoint is unavailable.", - snapshot: plan.snapshot, - }; + return codexCliSyncModule.rollbackLatestCodexCliSync(plan); } type ExperimentalSettingsAction = diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 96f22a3a..638fb88a 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1811,17 +1811,28 @@ describe("codex-cli sync", () => { run: recordedRun, }); - const saveSpy = vi - .spyOn(storageModule, "saveAccounts") - .mockRejectedValue(new Error("save busy")); + const leakingError = Object.assign( + new Error( + `EPERM: operation not permitted, open '${join(tempDir, "openai-codex-accounts.json")}'`, + ), + { code: "EPERM" }, + ); + const saveSpy = vi.spyOn(storageModule, "saveAccounts").mockRejectedValue( + leakingError, + ); const plan = await getLatestCodexCliSyncRollbackPlan(); expect(plan.status).toBe("ready"); const rollbackResult = await rollbackLatestCodexCliSync(plan); expect(rollbackResult.status).toBe("error"); - expect(rollbackResult.reason).toBe("save busy"); - await expect(rollbackLastCodexCliSync()).rejects.toThrow("save busy"); + expect(rollbackResult.reason).toBe( + "Failed to restore rollback checkpoint [EPERM].", + ); + expect(rollbackResult.reason).not.toContain(tempDir); + await expect(rollbackLastCodexCliSync()).rejects.toThrow( + "Failed to restore rollback checkpoint [EPERM].", + ); saveSpy.mockRestore(); }); @@ -1898,6 +1909,128 @@ describe("codex-cli sync", () => { saveSpy.mockRestore(); }); + it("logs retried rollback save failures with redacted filesystem labels", async () => { + const warnSpy = vi.fn(); + vi.resetModules(); + vi.doMock("../lib/logger.js", async () => { + const actual = await vi.importActual( + "../lib/logger.js", + ); + return { + ...actual, + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: warnSpy, + error: vi.fn(), + time: () => () => 0, + timeEnd: () => undefined, + }), + }; + }); + + try { + const freshStorageModule = await import("../lib/storage.js"); + const freshStateModule = await import("../lib/codex-cli/state.js"); + const freshSyncHistoryModule = await import("../lib/sync-history.js"); + const freshSyncModule = await import("../lib/codex-cli/sync.js"); + + freshStorageModule.setStoragePathDirect(targetStoragePath); + freshSyncHistoryModule.configureSyncHistoryForTests(join(tempDir, "logs")); + await freshSyncHistoryModule.__resetSyncHistoryForTests(); + freshStateModule.clearCodexCliStateCache(); + freshSyncModule.__resetLastCodexCliSyncRunForTests(); + + const snapshotPath = join(tempDir, "rollback-retry-logged-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + await freshSyncHistoryModule.appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 10, + run: { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-retry-logged", + path: snapshotPath, + }, + }, + }); + + const transientError = Object.assign( + new Error( + `EPERM: operation not permitted, open '${join(tempDir, "openai-codex-accounts.json")}'`, + ), + { code: "EPERM" }, + ); + const saveSpy = vi + .spyOn(freshStorageModule, "saveAccounts") + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce(undefined); + + const plan = await freshSyncModule.getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + + const rollbackResult = await freshSyncModule.rollbackLatestCodexCliSync(plan); + expect(rollbackResult.status).toBe("restored"); + expect(saveSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith( + "Retrying rollback checkpoint save after transient filesystem error", + expect.objectContaining({ + attempt: 1, + maxAttempts: 5, + error: "EPERM", + }), + ); + + saveSpy.mockRestore(); + } finally { + vi.doUnmock("../lib/logger.js"); + vi.resetModules(); + clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(join(tempDir, "logs")); + storageModule.setStoragePathDirect(targetStoragePath); + } + }); + it("re-reads Codex CLI state on apply when forceRefresh is requested", async () => { await writeFile( accountsPath, From 7c3a21d3b66ff38447115d67da47b31ed44b8ff5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:56:43 +0800 Subject: [PATCH 45/76] fix(sync): scope rollback selection and snapshots --- lib/codex-cli/sync.ts | 57 +++++++++++++++++- lib/sync-history.ts | 2 +- lib/ui/copy.ts | 2 +- test/codex-cli-sync.test.ts | 117 ++++++++++++++++++++++++++++++++++-- 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 00a0d8bb..9fa6675b 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -38,6 +38,7 @@ const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; const RETRYABLE_ROLLBACK_SAVE_CODES = new Set(["EBUSY", "EAGAIN"]); const ROLLBACK_SAVE_MAX_ATTEMPTS = 5; +const ROLLBACK_HISTORY_SCAN_LIMIT = 200; function createEmptyStorage(): AccountStorageV3 { return { @@ -181,6 +182,30 @@ function normalizeRollbackSnapshot( }; } +function normalizePathForComparison( + path: string | null | undefined, +): string | null { + if (typeof path !== "string" || path.length === 0) { + return null; + } + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/"); + const trimmed = + normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; + const isWindowsPath = path.includes("\\") || /^[a-z]:\//i.test(trimmed); + return isWindowsPath ? trimmed.toLowerCase() : trimmed; +} + +function matchesRollbackTargetPath( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const normalizedLeft = normalizePathForComparison(left); + const normalizedRight = normalizePathForComparison(right); + return Boolean( + normalizedLeft && normalizedRight && normalizedLeft === normalizedRight, + ); +} + function normalizeCodexCliSyncRun( run: CodexCliSyncRun | null, ): CodexCliSyncRun | null { @@ -359,10 +384,18 @@ export function __resetLastCodexCliSyncRunForTests(): void { lastCodexCliSyncHistoryLoadAttempted = false; } -async function captureRollbackSnapshot(): Promise { +async function captureRollbackSnapshot( + current: AccountStorageV3 | null, + targetPath: string, +): Promise { + if (!current) { + return null; + } const snapshot: NamedBackupMetadata | null = await snapshotAccountStorage({ reason: "codex-cli-sync", failurePolicy: "warn", + storage: current, + storagePath: targetPath, }); if (!snapshot) return null; return { @@ -384,7 +417,20 @@ function hasUsableRollbackSnapshot( async function findLatestManualRollbackRun(): Promise< CodexCliSyncRun | null > { - const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const targetPath = getStoragePath(); + const lastRun = getLastCodexCliSyncRun(); + if ( + isManualChangedSyncRun(lastRun) && + matchesRollbackTargetPath(lastRun.targetPath, targetPath) && + hasUsableRollbackSnapshot(lastRun.rollbackSnapshot) + ) { + return lastRun; + } + + const history = await readSyncHistory({ + kind: "codex-cli-sync", + limit: ROLLBACK_HISTORY_SCAN_LIMIT, + }); let fallbackRun: CodexCliSyncRun | null = null; for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; @@ -393,6 +439,9 @@ async function findLatestManualRollbackRun(): Promise< if (!isManualChangedSyncRun(run)) { continue; } + if (!matchesRollbackTargetPath(run.targetPath, targetPath)) { + continue; + } fallbackRun ??= run; if (hasUsableRollbackSnapshot(run.rollbackSnapshot)) { return run; @@ -1083,7 +1132,9 @@ export async function applyCodexCliSyncToStorage( const storage = next.accounts.length === 0 ? (current ?? next) : next; const rollbackSnapshot = - trigger === "manual" && changed ? await captureRollbackSnapshot() : null; + trigger === "manual" && changed + ? await captureRollbackSnapshot(current, targetPath) + : null; const syncRun = createSyncRun({ outcome: changed ? "changed" : "noop", sourcePath: state.path, diff --git a/lib/sync-history.ts b/lib/sync-history.ts index dcc5ece1..57e538c6 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -228,7 +228,7 @@ export async function readSyncHistory( await waitForPendingHistoryWrites(); try { if (typeof limit === "number" && limit > 0) { - return readHistoryTail(getSyncHistoryPaths().historyPath, { + return await readHistoryTail(getSyncHistoryPaths().historyPath, { kind, limit, }); diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 07dfb54e..937a0b01 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -133,7 +133,7 @@ export const UI_COPY = { syncCenterActionsHeading: "Actions", syncCenterRefresh: "Refresh Preview", syncCenterApply: "Apply Preview To Target", - syncCenterRollback: "Rollback Last Apply", + syncCenterRollback: "Rollback Last Manual Apply", syncCenterBack: "Back", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 638fb88a..f1b6dc93 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1395,16 +1395,19 @@ describe("codex-cli sync", () => { }); expect(result.changed).toBe(true); - expect(snapshotSpy).toHaveBeenCalledWith({ - reason: "codex-cli-sync", - failurePolicy: "warn", - }); + expect(snapshotSpy).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "codex-cli-sync", + failurePolicy: "warn", + storage: current, + storagePath: targetStoragePath, + }), + ); expect(result.pendingRun?.run.trigger).toBe("manual"); expect(result.pendingRun?.run.rollbackSnapshot).toEqual({ name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_001", path: join(tempDir, "rollback-snapshot.json"), }); - if (result.storage) { await storageModule.saveAccounts(result.storage); } @@ -1575,6 +1578,110 @@ describe("codex-cli sync", () => { expect(restored?.accounts[0]?.refreshToken).toBe("refresh-old"); }); + it("ignores rollback candidates recorded for a different target path", async () => { + const matchingSnapshotPath = join(tempDir, "rollback-target-match.json"); + const otherSnapshotPath = join(tempDir, "rollback-target-other.json"); + await writeFile( + matchingSnapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_match", + accountIdSource: "token", + email: "match@example.com", + refreshToken: "refresh-match", + accessToken: "access-match", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + await writeFile( + otherSnapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_other", + accountIdSource: "token", + email: "other@example.com", + refreshToken: "refresh-other", + accessToken: "access-other", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + const matchingRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-target-match", + path: matchingSnapshotPath, + }, + }; + const otherTargetRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 20, + sourcePath: accountsPath, + targetPath: join(tempDir, "other-target", "openai-codex-accounts.json"), + summary: { + ...matchingRun.summary, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-target-other", + path: otherSnapshotPath, + }, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: matchingRun.runAt, + run: matchingRun, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: otherTargetRun.runAt, + run: otherTargetRun, + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + expect(plan.snapshot).toEqual(matchingRun.rollbackSnapshot); + }); + it.each([ ["null checkpoint", null], [ From d853ad80238584a8f81ff73942f99c4825255b70 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 18:47:12 +0800 Subject: [PATCH 46/76] fix(sync): record rollback restore state --- lib/codex-cli/sync.ts | 57 +++++++++++++++++--- lib/sync-history.ts | 5 +- test/codex-cli-sync.test.ts | 26 +++++++++ test/sync-history.test.ts | 102 ++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 8 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 9fa6675b..9bd5e80c 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -153,6 +153,7 @@ let lastCodexCliSyncRunRevision = 0; let nextCodexCliSyncRunRevision = 0; const activePendingCodexCliSyncRunRevisions = new Set(); let lastCodexCliSyncHistoryLoadAttempted = false; +const ROLLBACK_HISTORY_MESSAGE_PREFIX = "Rollback restored checkpoint:"; function createEmptySyncSummary(): CodexCliSyncSummary { return { @@ -414,17 +415,30 @@ function hasUsableRollbackSnapshot( return Boolean(snapshot?.name.trim() && snapshot.path.trim()); } +function isRollbackHistoryRun(run: CodexCliSyncRun | null): boolean { + return Boolean(run?.message?.startsWith(ROLLBACK_HISTORY_MESSAGE_PREFIX)); +} + async function findLatestManualRollbackRun(): Promise< CodexCliSyncRun | null > { const targetPath = getStoragePath(); const lastRun = getLastCodexCliSyncRun(); + const latestRun = lastRun; + if ( + latestRun && + isRollbackHistoryRun(latestRun) && + matchesRollbackTargetPath(latestRun.targetPath, targetPath) + ) { + return null; + } if ( - isManualChangedSyncRun(lastRun) && - matchesRollbackTargetPath(lastRun.targetPath, targetPath) && - hasUsableRollbackSnapshot(lastRun.rollbackSnapshot) + latestRun && + isManualChangedSyncRun(latestRun) && + matchesRollbackTargetPath(latestRun.targetPath, targetPath) && + hasUsableRollbackSnapshot(latestRun.rollbackSnapshot) ) { - return lastRun; + return latestRun; } const history = await readSyncHistory({ @@ -436,6 +450,13 @@ async function findLatestManualRollbackRun(): Promise< const entry = history[index]; if (!entry || entry.kind !== "codex-cli-sync") continue; const run = normalizeCodexCliSyncRun(entry.run); + if ( + run && + isRollbackHistoryRun(run) && + matchesRollbackTargetPath(run.targetPath, targetPath) + ) { + return null; + } if (!isManualChangedSyncRun(run)) { continue; } @@ -569,12 +590,36 @@ export async function rollbackLatestCodexCliSync( try { await saveRollbackStorageWithRetry(resolvedPlan.storage); + const rollbackSnapshot = resolvedPlan.snapshot; + if (!rollbackSnapshot) { + return { + status: "unavailable", + reason: "Rollback checkpoint is unavailable after loading the restore plan.", + snapshot: null, + }; + } + const restoredAccountCount = + resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length; + publishCodexCliSyncRun( + createSyncRun({ + outcome: "changed", + sourcePath: null, + targetPath: getStoragePath(), + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: restoredAccountCount, + targetAccountCountAfter: restoredAccountCount, + }, + trigger: "manual", + message: `${ROLLBACK_HISTORY_MESSAGE_PREFIX} ${rollbackSnapshot.name}`, + }), + allocateCodexCliSyncRunRevision(), + ); return { status: "restored", reason: resolvedPlan.reason, snapshot: resolvedPlan.snapshot, - accountCount: - resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length, + accountCount: restoredAccountCount, }; } catch (error) { const errorLabel = getRedactedFilesystemErrorLabel(error); diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 57e538c6..cc749171 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -78,8 +78,9 @@ function withHistoryLock(fn: () => Promise): Promise { } async function waitForPendingHistoryWrites(): Promise { - if (pendingHistoryWrites.size === 0) return; - await Promise.allSettled(Array.from(pendingHistoryWrites)); + while (pendingHistoryWrites.size > 0) { + await Promise.allSettled(Array.from(pendingHistoryWrites)); + } } async function ensureHistoryDir(directory: string): Promise { diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index f1b6dc93..fb974843 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1576,6 +1576,32 @@ describe("codex-cli sync", () => { const restored = await storageModule.loadAccounts(); expect(restored?.accounts).toHaveLength(1); expect(restored?.accounts[0]?.refreshToken).toBe("refresh-old"); + + expect(getLastCodexCliSyncRun()).toMatchObject({ + outcome: "changed", + trigger: "manual", + targetPath: targetStoragePath, + rollbackSnapshot: null, + message: expect.stringContaining("Rollback restored checkpoint:"), + }); + + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + expect(history.at(-1)).toMatchObject({ + kind: "codex-cli-sync", + run: expect.objectContaining({ + outcome: "changed", + trigger: "manual", + targetPath: targetStoragePath, + rollbackSnapshot: null, + message: expect.stringContaining("Rollback restored checkpoint:"), + }), + }); + + const followUpPlan = await getLatestCodexCliSyncRollbackPlan(); + expect(followUpPlan.status).toBe("unavailable"); + expect(followUpPlan.reason).toBe( + "No manual Codex CLI apply with a rollback checkpoint is available.", + ); }); it("ignores rollback candidates recorded for a different target path", async () => { diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 4fcbd14d..b03b65ae 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -174,4 +174,106 @@ describe("sync history", () => { }); }); }); + + it("waits for writes queued while history reads are draining", async () => { + let releaseFirstAppend!: () => void; + let releaseSecondAppend!: () => void; + let resolveFirstStarted!: () => void; + let resolveSecondStarted!: () => void; + const firstAppendStarted = new Promise((resolve) => { + resolveFirstStarted = resolve; + }); + const secondAppendStarted = new Promise((resolve) => { + resolveSecondStarted = resolve; + }); + const firstAppendGate = new Promise((resolve) => { + releaseFirstAppend = resolve; + }); + const secondAppendGate = new Promise((resolve) => { + releaseSecondAppend = resolve; + }); + const originalAppendFile = nodeFs.appendFile; + let appendCallCount = 0; + vi.spyOn(nodeFs, "appendFile").mockImplementation( + async (...args: Parameters) => { + appendCallCount += 1; + if (appendCallCount === 1) { + resolveFirstStarted(); + await firstAppendGate; + } else if (appendCallCount === 2) { + resolveSecondStarted(); + await secondAppendGate; + } + return originalAppendFile(...args); + }, + ); + + const firstWrite = appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: { + outcome: "changed", + runAt: 1, + sourcePath: "source-1.json", + targetPath: "target-1.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 0, + targetAccountCountAfter: 1, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: true, + }, + }, + }); + await firstAppendStarted; + + let readResolved = false; + const readPromise = readSyncHistory({ kind: "codex-cli-sync" }).then((history) => { + readResolved = true; + return history; + }); + + const secondWrite = appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 2, + run: { + outcome: "noop", + runAt: 2, + sourcePath: "source-2.json", + targetPath: "target-2.json", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + }, + }); + releaseFirstAppend(); + await secondAppendStarted; + await Promise.resolve(); + await Promise.resolve(); + expect(readResolved).toBe(false); + + releaseSecondAppend(); + const history = await readPromise; + await firstWrite; + await secondWrite; + + expect(history).toHaveLength(2); + expect(history.at(-1)).toMatchObject({ + kind: "codex-cli-sync", + recordedAt: 2, + run: expect.objectContaining({ + sourcePath: "source-2.json", + }), + }); + }); }); From 4bd494997015081b073be75edbf6b75e3acb0f50 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 20:20:24 +0800 Subject: [PATCH 47/76] fix(sync): preserve pending run ordering --- lib/codex-cli/sync.ts | 99 ++++++++++++++++++++++--------------- test/codex-cli-sync.test.ts | 67 +++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 45 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 9bd5e80c..8cde59bc 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -39,6 +39,8 @@ export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; const RETRYABLE_ROLLBACK_SAVE_CODES = new Set(["EBUSY", "EAGAIN"]); const ROLLBACK_SAVE_MAX_ATTEMPTS = 5; const ROLLBACK_HISTORY_SCAN_LIMIT = 200; +let inFlightCodexCliSyncRollbackPromise: Promise | null = + null; function createEmptyStorage(): AccountStorageV3 { return { @@ -353,7 +355,7 @@ export function commitPendingCodexCliSyncRun( pendingRun.run.rollbackSnapshot, ), }, - allocateCodexCliSyncRunRevision(), + pendingRun.revision, ); } @@ -373,7 +375,7 @@ export function commitCodexCliSyncRunFailure( }, error, ), - allocateCodexCliSyncRunRevision(), + pendingRun.revision, ); } @@ -383,6 +385,7 @@ export function __resetLastCodexCliSyncRunForTests(): void { nextCodexCliSyncRunRevision = 0; activePendingCodexCliSyncRunRevisions.clear(); lastCodexCliSyncHistoryLoadAttempted = false; + inFlightCodexCliSyncRollbackPromise = null; } async function captureRollbackSnapshot( @@ -588,47 +591,63 @@ export async function rollbackLatestCodexCliSync( }; } - try { - await saveRollbackStorageWithRetry(resolvedPlan.storage); - const rollbackSnapshot = resolvedPlan.snapshot; - if (!rollbackSnapshot) { + if (inFlightCodexCliSyncRollbackPromise) { + return inFlightCodexCliSyncRollbackPromise; + } + + const rollbackStorage = resolvedPlan.storage; + const rollbackSnapshot = resolvedPlan.snapshot; + const rollbackReason = resolvedPlan.reason; + const restoredAccountCount = + resolvedPlan.accountCount ?? rollbackStorage.accounts.length; + + const rollbackPromise = (async (): Promise => { + try { + await saveRollbackStorageWithRetry(rollbackStorage); + if (!rollbackSnapshot) { + return { + status: "unavailable", + reason: "Rollback checkpoint is unavailable after loading the restore plan.", + snapshot: null, + }; + } + publishCodexCliSyncRun( + createSyncRun({ + outcome: "changed", + sourcePath: null, + targetPath: getStoragePath(), + summary: { + ...createEmptySyncSummary(), + targetAccountCountBefore: restoredAccountCount, + targetAccountCountAfter: restoredAccountCount, + }, + trigger: "manual", + message: `${ROLLBACK_HISTORY_MESSAGE_PREFIX} ${rollbackSnapshot.name}`, + }), + allocateCodexCliSyncRunRevision(), + ); return { - status: "unavailable", - reason: "Rollback checkpoint is unavailable after loading the restore plan.", - snapshot: null, + status: "restored", + reason: rollbackReason, + snapshot: rollbackSnapshot, + accountCount: restoredAccountCount, + }; + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + return { + status: "error", + reason: `Failed to restore rollback checkpoint [${errorLabel}].`, + snapshot: rollbackSnapshot, }; } - const restoredAccountCount = - resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length; - publishCodexCliSyncRun( - createSyncRun({ - outcome: "changed", - sourcePath: null, - targetPath: getStoragePath(), - summary: { - ...createEmptySyncSummary(), - targetAccountCountBefore: restoredAccountCount, - targetAccountCountAfter: restoredAccountCount, - }, - trigger: "manual", - message: `${ROLLBACK_HISTORY_MESSAGE_PREFIX} ${rollbackSnapshot.name}`, - }), - allocateCodexCliSyncRunRevision(), - ); - return { - status: "restored", - reason: resolvedPlan.reason, - snapshot: resolvedPlan.snapshot, - accountCount: restoredAccountCount, - }; - } catch (error) { - const errorLabel = getRedactedFilesystemErrorLabel(error); - return { - status: "error", - reason: `Failed to restore rollback checkpoint [${errorLabel}].`, - snapshot: resolvedPlan.snapshot, - }; - } + })(); + inFlightCodexCliSyncRollbackPromise = rollbackPromise; + void rollbackPromise.finally(() => { + if (inFlightCodexCliSyncRollbackPromise === rollbackPromise) { + inFlightCodexCliSyncRollbackPromise = null; + } + }); + return rollbackPromise; } export async function rollbackLastCodexCliSync(): Promise< diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index fb974843..5d498405 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -2042,6 +2042,65 @@ describe("codex-cli sync", () => { saveSpy.mockRestore(); }); + it("shares an in-flight rollback restore across concurrent callers", async () => { + let resolveSave: (() => void) | undefined; + const inFlightSave = new Promise((resolve) => { + resolveSave = resolve; + }); + const saveSpy = vi + .spyOn(storageModule, "saveAccounts") + .mockImplementation(() => inFlightSave); + + try { + const plan = { + status: "ready" as const, + reason: "Restores 1 account from rollback checkpoint.", + snapshot: { + name: "accounts-codex-cli-sync-snapshot-shared", + path: join(tempDir, "rollback-shared-snapshot.json"), + }, + storage: { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token" as const, + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + accountCount: 1, + }; + + const firstRollback = rollbackLatestCodexCliSync(plan); + const secondRollback = rollbackLatestCodexCliSync(plan); + + expect(saveSpy).toHaveBeenCalledTimes(1); + + resolveSave?.(); + + const [firstResult, secondResult] = await Promise.all([ + firstRollback, + secondRollback, + ]); + expect(firstResult).toEqual(secondResult); + expect(firstResult).toMatchObject({ + status: "restored", + accountCount: 1, + snapshot: plan.snapshot, + }); + expect(getLastCodexCliSyncRun()?.message).toContain(plan.snapshot.name); + } finally { + saveSpy.mockRestore(); + } + }); + it("logs retried rollback save failures with redacted filesystem labels", async () => { const warnSpy = vi.fn(); vi.resetModules(); @@ -2559,7 +2618,7 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.addedAccountCount).toBe(1); }); - it("publishes the completion that finishes last even when it started earlier", async () => { + it("keeps the newer pending sync outcome when an older commit finishes later", async () => { const current: AccountStorageV3 = { version: 3, accounts: [ @@ -2618,12 +2677,10 @@ describe("codex-cli sync", () => { expect(getLastCodexCliSyncRun()).toEqual( expect.objectContaining({ - outcome: "changed", + outcome: "error", sourcePath: accountsPath, targetPath: targetStoragePath, - summary: expect.objectContaining({ - addedAccountCount: 1, - }), + message: "later run failed", }), ); }); From 355ac29cdc3a960fb9e0be2cf9bbf34fdd3076dc Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 20:27:16 +0800 Subject: [PATCH 48/76] fix(sync): fail closed on rollback history read --- lib/codex-cli/sync.ts | 12 +++++++++++- test/codex-cli-sync.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 8cde59bc..314f4dd9 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -565,7 +565,17 @@ async function saveRollbackStorageWithRetry(storage: AccountStorageV3): Promise< } export async function getLatestCodexCliSyncRollbackPlan(): Promise { - const lastManualRun = await findLatestManualRollbackRun(); + let lastManualRun: CodexCliSyncRun | null; + try { + lastManualRun = await findLatestManualRollbackRun(); + } catch (error) { + const errorLabel = getRedactedFilesystemErrorLabel(error); + return { + status: "unavailable", + reason: `Rollback history is unavailable [${errorLabel}].`, + snapshot: null, + }; + } if (!lastManualRun) { return { status: "unavailable", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 5d498405..18c07654 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1890,6 +1890,41 @@ describe("codex-cli sync", () => { }, ); + it("fails closed when rollback history cannot be read", async () => { + const readError = Object.assign(new Error("history busy"), { + code: "EBUSY", + }); + vi.resetModules(); + vi.doMock("../lib/sync-history.js", async () => { + const actual = await vi.importActual( + "../lib/sync-history.js", + ); + return { + ...actual, + readSyncHistory: vi.fn().mockRejectedValue(readError), + }; + }); + + try { + const freshSyncModule = await import("../lib/codex-cli/sync.js"); + freshSyncModule.__resetLastCodexCliSyncRunForTests(); + + await expect(freshSyncModule.getLatestCodexCliSyncRollbackPlan()).resolves.toEqual({ + status: "unavailable", + reason: "Rollback history is unavailable [EBUSY].", + snapshot: null, + }); + } finally { + vi.doUnmock("../lib/sync-history.js"); + vi.resetModules(); + clearCodexCliStateCache(); + __resetLastCodexCliSyncRunForTests(); + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(join(tempDir, "logs")); + storageModule.setStoragePathDirect(targetStoragePath); + } + }); + it("surfaces rollback save failures through both rollback APIs", async () => { const snapshotPath = join(tempDir, "rollback-error-snapshot.json"); await writeFile( From bfd4ea0c1d45b15ed0a53c11478a6f0330f1ee7a Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:37:43 +0800 Subject: [PATCH 49/76] fix(sync): restack rollback branch on preview stack --- lib/codex-manager.ts | 3 - lib/storage.ts | 114 --------------------------------- test/codex-manager-cli.test.ts | 12 +++- test/storage.test.ts | 4 +- 4 files changed, 11 insertions(+), 122 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 146f8582..c959acb8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -110,8 +110,6 @@ const log = createLogger("codex-manager"); let destructiveActionInFlight = false; -let destructiveActionInFlight = false; - function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -5069,7 +5067,6 @@ async function showBackupBrowserDetails( [ { label: "Preview Restore", value: "preview-restore", color: "green" }, { label: "Back", value: "back" }, - { label: "Preview Restore", value: "preview-restore", color: "green" }, ], { message: "Backup Actions", diff --git a/lib/storage.ts b/lib/storage.ts index 056f7397..d106c77a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2740,13 +2740,6 @@ async function importNormalizedAccounts( total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { - if (snapshotReason) { - await snapshotAccountStorage({ - reason: snapshotReason, - storage: existing, - storagePath: getStoragePath(), - }); - } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; @@ -2909,113 +2902,6 @@ async function resolveNamedBackupRestorePath(name: string): Promise { } } -async function loadBackupCandidate(path: string): Promise { - try { - return await retryTransientFilesystemOperation(() => - loadAccountsFromPath(path), - ); - } catch (error) { - return { - normalized: null, - storedVersion: undefined, - schemaErrors: [], - error: String(error), - }; - } -} - -function equalsNamedBackupEntry(left: string, right: string): boolean { - return process.platform === "win32" - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function stripNamedBackupJsonExtension(name: string): string { - return name.toLowerCase().endsWith(".json") - ? name.slice(0, -".json".length) - : name; -} - -async function findExistingNamedBackupPath( - name: string, -): Promise { - const requested = (name ?? "").trim(); - if (!requested) { - return undefined; - } - - const backupRoot = getNamedBackupRoot(getStoragePath()); - const requestedWithExtension = requested.toLowerCase().endsWith(".json") - ? requested - : `${requested}.json`; - const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - - try { - const entries = await retryTransientFilesystemOperation(() => - fs.readdir(backupRoot, { withFileTypes: true }), - ); - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return undefined; - } - log.warn("Failed to read named backup directory", { - path: backupRoot, - error: String(error), - }); - throw error; - } - - return undefined; -} - -async function resolveNamedBackupRestorePath(name: string): Promise { - const existingPath = await findExistingNamedBackupPath(name); - if (existingPath) { - return existingPath; - } - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); - const requestedWithExtension = requested.toLowerCase().endsWith(".json") - ? requested - : `${requested}.json`; - try { - return buildNamedBackupPath(name); - } catch (error) { - const baseName = requestedWithExtension.toLowerCase().endsWith(".json") - ? requestedWithExtension.slice(0, -".json".length) - : requestedWithExtension; - if ( - requested.length > 0 && - basename(requestedWithExtension) === requestedWithExtension && - !requestedWithExtension.includes("..") && - !/^[A-Za-z0-9_-]+$/.test(baseName) - ) { - throw new Error( - `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, - ); - } - throw error; - } -} - async function loadAccountsFromJournal( path: string, ): Promise { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0890a45d..ddfa757c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1351,7 +1351,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(1); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { + assessment, + }); }); it("returns a non-zero exit code when the direct restore-backup command fails", async () => { @@ -1406,7 +1408,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(1); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { + assessment, + }); }); it("runs restore preview before applying a replace-only named backup", async () => { @@ -1566,7 +1570,9 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledWith( "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { + assessment, + }); }); it("restores healthy flagged accounts into active storage", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 5aa8443d..2470f97f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1646,7 +1646,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/ENOENT: no such file or directory/); + ).rejects.toThrow(/Import file not found/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1674,7 +1674,7 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/is not valid JSON/); + ).rejects.toThrow(/Invalid JSON in import file/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); From a4ac77b14e34f0d1d251b1e94c896df77c360c64 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:19:54 +0800 Subject: [PATCH 50/76] fix(auth): define reset and delete safety flows --- lib/codex-manager.ts | 2 ++ test/storage.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c959acb8..a111f208 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -110,6 +110,8 @@ const log = createLogger("codex-manager"); let destructiveActionInFlight = false; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 2470f97f..bcab4efe 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -5071,6 +5071,37 @@ describe("storage", () => { renameSpy.mockRestore(); }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { From dbc539444128c96cd48359db4feef163a7391e5f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:23:17 +0800 Subject: [PATCH 51/76] feat(auth): add backup restore manager --- docs/reference/public-api.md | 6 + lib/codex-manager.ts | 30 +++ lib/storage.ts | 244 ++++++++++++++++++++- test/codex-manager-cli.test.ts | 3 + test/storage.test.ts | 372 ++++++++++++++++++++++++++++++++- 5 files changed, 648 insertions(+), 7 deletions(-) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..a76eb85a 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,6 +37,12 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. +Current additive compatibility note: + +- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. +- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. +- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. + ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a111f208..26fc7a60 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -362,6 +362,7 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", + " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -4062,6 +4063,21 @@ async function runAuthLogin(): Promise { pendingMenuQuotaRefresh = null; }); } + pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); } const flaggedStorage = await loadFlaggedAccounts(); @@ -5453,6 +5469,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } + if (command === "restore-backup") { + setStoragePath(null); + try { + const completedWithoutFailure = + await runBackupRestoreManager(startupDisplaySettings); + return completedWithoutFailure ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + return 1; + } + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index d106c77a..deb4f73c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { + existsSync, + lstatSync, + promises as fs, + realpathSync, + type Dirent, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -2902,6 +2908,234 @@ async function resolveNamedBackupRestorePath(name: string): Promise { } } +async function loadBackupCandidate(path: string): Promise { + try { + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); + } catch (error) { + const errorMessage = + error instanceof SyntaxError + ? `Invalid JSON in import file: ${path}` + : (error as NodeJS.ErrnoException).code === "ENOENT" + ? `Import file not found: ${path}` + : error instanceof Error + ? error.message + : String(error); + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: errorMessage, + }; + } +} + +function equalsNamedBackupEntry(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + +async function findExistingNamedBackupPath( + name: string, +): Promise { + const requested = (name ?? "").trim(); + if (!requested) { + return undefined; + } + + const backupRoot = getNamedBackupRoot(getStoragePath()); + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; + + try { + entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; + } + + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } + + return undefined; +} + +function resolvePathForNamedBackupContainment(path: string): string { + const resolvedPath = resolvePath(path); + let existingPrefix = resolvedPath; + const unresolvedSegments: string[] = []; + while (true) { + try { + namedBackupContainmentFs.lstat(existingPrefix); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + const parentPath = dirname(existingPrefix); + if (parentPath === existingPrefix) { + return resolvedPath; + } + unresolvedSegments.unshift(basename(existingPrefix)); + existingPrefix = parentPath; + continue; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } + } + try { + const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); + return unresolvedSegments.reduce( + (currentPath, segment) => join(currentPath, segment), + canonicalPrefix, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return resolvedPath; + } + if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } +} + +export function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const resolvedBackupRoot = resolvePath(backupRoot); + let backupRootIsSymlink = false; + try { + backupRootIsSymlink = + namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + backupRootIsSymlink = false; + } else if (isRetryableFilesystemErrorCode(code)) { + throw new BackupPathValidationTransientError( + "Backup path validation failed. Try again.", + { cause: error instanceof Error ? error : undefined }, + ); + } else { + throw error; + } + } + if (backupRootIsSymlink) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + const canonicalBackupRoot = + resolvePathForNamedBackupContainment(resolvedBackupRoot); + const containedPath = resolvePathForNamedBackupContainment(resolvedPath); + const relativePath = relative(canonicalBackupRoot, containedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; + if ( + relativePath.length === 0 || + firstSegment === ".." || + isAbsolute(relativePath) + ) { + throw new BackupContainmentError("Backup path escapes backup directory"); + } + return containedPath; +} + +export function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof BackupContainmentError || + (error instanceof Error && /escapes backup directory/i.test(error.message)) + ); +} + +export function isNamedBackupPathValidationTransientError( + error: unknown, +): error is BackupPathValidationTransientError { + return ( + error instanceof BackupPathValidationTransientError || + (error instanceof Error && + /^Backup path validation failed(\.|:|\b)/i.test(error.message)) + ); +} + +export async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); + const existingPath = await findExistingNamedBackupPath(name); + if (existingPath) { + return assertNamedBackupRestorePath(existingPath, backupRoot); + } + const requestedWithExtension = requested.toLowerCase().endsWith(".json") + ? requested + : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; + try { + builtPath = buildNamedBackupPath(requested); + } catch (error) { + // buildNamedBackupPath rejects names with special characters even when the + // requested backup name is a plain filename inside the backups directory. + // In that case, reporting ENOENT is clearer than surfacing the filename + // validator, but only when no separator/traversal token is present. + if ( + requested.length > 0 && + basename(requestedWithExtension) === requestedWithExtension && + !requestedWithExtension.includes("..") && + !/^[A-Za-z0-9_-]+$/.test(baseName) + ) { + throw new Error( + `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, + ); + } + throw error; + } + return assertNamedBackupRestorePath(builtPath, backupRoot); +} + async function loadAccountsFromJournal( path: string, ): Promise { @@ -3862,7 +4096,7 @@ export async function exportAccounts( */ export async function importAccounts( filePath: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const resolvedPath = resolvePath(filePath); const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { @@ -3870,3 +4104,7 @@ export async function importAccounts( snapshotFailurePolicy: "error", }); } + +export const __testOnly = { + namedBackupContainmentFs, +}; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ddfa757c..228a30f0 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createAuthorizationFlowMock = vi.fn(); @@ -573,6 +575,7 @@ describe("codex manager cli commands", () => { exchangeAuthorizationCodeMock.mockReset(); startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReturnValue(true); diff --git a/test/storage.test.ts b/test/storage.test.ts index bcab4efe..3c5a3047 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -434,6 +434,243 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); + it("should skip persisting duplicate-only imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + await saveAccounts(existing); + await fs.writeFile(exportPath, JSON.stringify(existing)); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should treat deduplicated current snapshots as a no-op import", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + + it("should deduplicate incoming backup rows before reporting skipped imports", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await clearAccounts(); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-old", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "duplicate-import", + email: "duplicate-import@example.com", + refreshToken: "ref-duplicate-import-new", + lastUsed: 2, + }); + }); + + it("should persist duplicate-only imports when they refresh stored metadata", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "stale-access", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + email: "existing@example.com", + refreshToken: "ref-existing", + accessToken: "fresh-access", + addedAt: 1, + lastUsed: 10, + }, + ], + }), + ); + + const result = await importAccounts(exportPath); + const loaded = await loadAccounts(); + + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "existing", + accessToken: "fresh-access", + lastUsed: 10, + }); + }); + + it("should skip semantically identical duplicate-only imports even when key order differs", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref-existing", + addedAt: 1, + lastUsed: 2, + }, + ], + }); + await fs.writeFile( + exportPath, + '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', + ); + + const writeFileSpy = vi.spyOn(fs, "writeFile"); + try { + const result = await importAccounts(exportPath); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const existing = { version: 3, @@ -478,7 +715,12 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); + expect(imported).toEqual({ + imported: 1, + total: 3, + skipped: 0, + changed: true, + }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -528,7 +770,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -573,7 +820,12 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); + expect(result).toEqual({ + imported: 1, + skipped: 0, + total: 2, + changed: true, + }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1107,6 +1359,73 @@ describe("storage", () => { ); }); + it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { + const nearLimitAccounts = Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, + (_, index) => ({ + accountId: `existing-${index}`, + refreshToken: `ref-existing-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: nearLimitAccounts, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-one", + refreshToken: "ref-extra-one", + addedAt: 10_000, + lastUsed: 10_000, + }, + ], + }), + ); + + const first = await importAccounts(exportPath); + expect(first).toMatchObject({ + imported: 1, + skipped: 0, + total: ACCOUNT_LIMITS.MAX_ACCOUNTS, + changed: true, + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "extra-two", + refreshToken: "ref-extra-two", + addedAt: 20_000, + lastUsed: 20_000, + }, + ], + }), + ); + + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); + expect( + loaded?.accounts.some((account) => account.accountId === "extra-two"), + ).toBe(false); + }); + it("should fail export when no accounts exist", async () => { const isolatedStorageDir = join( testWorkDir, @@ -1144,6 +1463,51 @@ describe("storage", () => { ); }); + it("retries transient import read errors before parsing the backup", async () => { + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "retry-import-read", + refreshToken: "ref-retry-import-read", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + const originalReadFile = fs.readFile.bind(fs); + let busyFailures = 0; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [path] = args; + if (String(path) === exportPath && busyFailures === 0) { + busyFailures += 1; + const error = new Error("import file busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return originalReadFile(...(args as Parameters)); + }); + + try { + const result = await importAccounts(exportPath); + expect(result).toMatchObject({ + imported: 1, + skipped: 0, + total: 1, + changed: true, + }); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } + }); + it("should fail import when file contains invalid JSON", async () => { await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); From 01277aa430d6ecbdfe02fc5497cf293cd05b0419 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 23:41:55 +0800 Subject: [PATCH 52/76] feat(auth): prompt for recovery on startup --- docs/reference/commands.md | 2 - docs/reference/public-api.md | 6 - docs/reference/storage-paths.md | 4 - lib/codex-manager.ts | 30 --- lib/storage.ts | 191 +++------------- test/cli.test.ts | 10 - test/codex-manager-cli.test.ts | 41 ++-- test/storage.test.ts | 372 +------------------------------- 8 files changed, 52 insertions(+), 604 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ab142c2b..131cb47a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,7 +26,6 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | -| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -122,7 +121,6 @@ codex auth report --live --json Repair and recovery: ```bash -codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a76eb85a..865189ff 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -37,12 +37,6 @@ Compatibility policy for Tier B: - Existing exported symbols must not be removed in this release line. - Deprecated usage may be documented, but hard removals require a major version transition plan. -Current additive compatibility note: - -- `importAccounts()` now returns `{ imported, total, skipped, changed }` at runtime. -- The exported `ImportAccountsResult` type keeps `changed` optional so older callers modeling the legacy shape remain source-compatible. -- New callers should read `changed` to distinguish duplicate-only no-ops from metadata-refresh writes. - ### Tier C: Internal APIs Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 8ea743ec..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,10 +109,6 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. -Direct entrypoint: - -- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. - --- ## oc-chatgpt Target Paths diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 26fc7a60..a111f208 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -362,7 +362,6 @@ function printUsage(): void { " codex auth report [--live] [--json] [--model ] [--out ]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", - " codex auth restore-backup", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -4063,21 +4062,6 @@ async function runAuthLogin(): Promise { pendingMenuQuotaRefresh = null; }); } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); } const flaggedStorage = await loadFlaggedAccounts(); @@ -5469,20 +5453,6 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { if (command === "doctor") { return runDoctor(rest); } - if (command === "restore-backup") { - setStoragePath(null); - try { - const completedWithoutFailure = - await runBackupRestoreManager(startupDisplaySettings); - return completedWithoutFailure ? 0 : 1; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, - ); - return 1; - } - } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/storage.ts b/lib/storage.ts index deb4f73c..a60a8a1c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,13 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { - existsSync, - lstatSync, - promises as fs, - realpathSync, - type Dirent, -} from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -2914,19 +2908,11 @@ async function loadBackupCandidate(path: string): Promise loadAccountsFromPath(path), ); } catch (error) { - const errorMessage = - error instanceof SyntaxError - ? `Invalid JSON in import file: ${path}` - : (error as NodeJS.ErrnoException).code === "ENOENT" - ? `Import file not found: ${path}` - : error instanceof Error - ? error.message - : String(error); return { normalized: null, storedVersion: undefined, schemaErrors: [], - error: errorMessage, + error: String(error), }; } } @@ -2956,12 +2942,28 @@ async function findExistingNamedBackupPath( ? requested : `${requested}.json`; const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - let entries: Dirent[]; try { - entries = await retryTransientFilesystemOperation(() => + const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); + for (const entry of entries) { + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); + const matchesRequestedEntry = + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); + if (!matchesRequestedEntry) { + continue; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error( + `Named backup "${entryBaseName}" is not a regular backup file`, + ); + } + return resolvePath(join(backupRoot, entry.name)); + } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -2974,153 +2976,25 @@ async function findExistingNamedBackupPath( throw error; } - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - return undefined; } -function resolvePathForNamedBackupContainment(path: string): string { - const resolvedPath = resolvePath(path); - let existingPrefix = resolvedPath; - const unresolvedSegments: string[] = []; - while (true) { - try { - namedBackupContainmentFs.lstat(existingPrefix); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - const parentPath = dirname(existingPrefix); - if (parentPath === existingPrefix) { - return resolvedPath; - } - unresolvedSegments.unshift(basename(existingPrefix)); - existingPrefix = parentPath; - continue; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } - } - try { - const canonicalPrefix = namedBackupContainmentFs.realpath(existingPrefix); - return unresolvedSegments.reduce( - (currentPath, segment) => join(currentPath, segment), - canonicalPrefix, - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return resolvedPath; - } - if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } - throw error; - } -} - -export function assertNamedBackupRestorePath( - path: string, - backupRoot: string, -): string { - const resolvedPath = resolvePath(path); - const resolvedBackupRoot = resolvePath(backupRoot); - let backupRootIsSymlink = false; - try { - backupRootIsSymlink = - namedBackupContainmentFs.lstat(resolvedBackupRoot).isSymbolicLink(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - backupRootIsSymlink = false; - } else if (isRetryableFilesystemErrorCode(code)) { - throw new BackupPathValidationTransientError( - "Backup path validation failed. Try again.", - { cause: error instanceof Error ? error : undefined }, - ); - } else { - throw error; - } - } - if (backupRootIsSymlink) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - const canonicalBackupRoot = - resolvePathForNamedBackupContainment(resolvedBackupRoot); - const containedPath = resolvePathForNamedBackupContainment(resolvedPath); - const relativePath = relative(canonicalBackupRoot, containedPath); - const firstSegment = relativePath.split(/[\\/]/)[0]; - if ( - relativePath.length === 0 || - firstSegment === ".." || - isAbsolute(relativePath) - ) { - throw new BackupContainmentError("Backup path escapes backup directory"); - } - return containedPath; -} - -export function isNamedBackupContainmentError(error: unknown): boolean { - return ( - error instanceof BackupContainmentError || - (error instanceof Error && /escapes backup directory/i.test(error.message)) - ); -} - -export function isNamedBackupPathValidationTransientError( - error: unknown, -): error is BackupPathValidationTransientError { - return ( - error instanceof BackupPathValidationTransientError || - (error instanceof Error && - /^Backup path validation failed(\.|:|\b)/i.test(error.message)) - ); -} - -export async function resolveNamedBackupRestorePath(name: string): Promise { - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); +async function resolveNamedBackupRestorePath(name: string): Promise { const existingPath = await findExistingNamedBackupPath(name); if (existingPath) { - return assertNamedBackupRestorePath(existingPath, backupRoot); + return existingPath; } + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; - const baseName = requestedWithExtension.slice(0, -".json".length); - let builtPath: string; try { - builtPath = buildNamedBackupPath(requested); + return buildNamedBackupPath(name); } catch (error) { - // buildNamedBackupPath rejects names with special characters even when the - // requested backup name is a plain filename inside the backups directory. - // In that case, reporting ENOENT is clearer than surfacing the filename - // validator, but only when no separator/traversal token is present. + const baseName = requestedWithExtension.toLowerCase().endsWith(".json") + ? requestedWithExtension.slice(0, -".json".length) + : requestedWithExtension; if ( requested.length > 0 && basename(requestedWithExtension) === requestedWithExtension && @@ -3133,7 +3007,6 @@ export async function resolveNamedBackupRestorePath(name: string): Promise { +): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); const candidate = await loadImportableBackupCandidate(resolvedPath); return importNormalizedAccounts(candidate.normalized, resolvedPath, { @@ -4104,7 +3977,3 @@ export async function importAccounts( snapshotFailurePolicy: "error", }); } - -export const __testOnly = { - namedBackupContainmentFs, -}; diff --git a/test/cli.test.ts b/test/cli.test.ts index efbffdce..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,16 +716,6 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); - - mockRl.question.mockResolvedValueOnce("backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); - - mockRl.question.mockResolvedValueOnce("restore-backup"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ - mode: "restore-backup", - }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 228a30f0..a7c3baf1 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,5 +1,3 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createAuthorizationFlowMock = vi.fn(); @@ -575,7 +573,6 @@ describe("codex manager cli commands", () => { exchangeAuthorizationCodeMock.mockReset(); startLocalOAuthServerMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); - setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReset(); isInteractiveLoginMenuAvailableMock.mockReturnValue(true); @@ -5590,28 +5587,26 @@ describe("codex manager cli commands", () => { .mockImplementationOnce(async () => "preview-restore"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), - }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), }), ); expect(confirmMock).not.toHaveBeenCalled(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 3c5a3047..bcab4efe 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { @@ -434,243 +434,6 @@ describe("storage", () => { expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); }); - it("should skip persisting duplicate-only imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - const existing = { - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }; - await saveAccounts(existing); - await fs.writeFile(exportPath, JSON.stringify(existing)); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should treat deduplicated current snapshots as a no-op import", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 2, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - - it("should deduplicate incoming backup rows before reporting skipped imports", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await clearAccounts(); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-old", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "duplicate-import", - email: "duplicate-import@example.com", - refreshToken: "ref-duplicate-import-new", - lastUsed: 2, - }); - }); - - it("should persist duplicate-only imports when they refresh stored metadata", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "stale-access", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - email: "existing@example.com", - refreshToken: "ref-existing", - accessToken: "fresh-access", - addedAt: 1, - lastUsed: 10, - }, - ], - }), - ); - - const result = await importAccounts(exportPath); - const loaded = await loadAccounts(); - - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: true, - }); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]).toMatchObject({ - accountId: "existing", - accessToken: "fresh-access", - lastUsed: 10, - }); - }); - - it("should skip semantically identical duplicate-only imports even when key order differs", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "existing", - refreshToken: "ref-existing", - addedAt: 1, - lastUsed: 2, - }, - ], - }); - await fs.writeFile( - exportPath, - '{"version":3,"activeIndex":0,"accounts":[{"lastUsed":2,"addedAt":1,"refreshToken":"ref-existing","accountId":"existing"}]}', - ); - - const writeFileSpy = vi.spyOn(fs, "writeFile"); - try { - const result = await importAccounts(exportPath); - expect(result).toEqual({ - imported: 0, - skipped: 1, - total: 1, - changed: false, - }); - const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { - const target = String(targetPath); - return ( - target === testStoragePath || - target.startsWith(`${testStoragePath}.`) - ); - }); - expect(storageWrites).toHaveLength(0); - } finally { - writeFileSpy.mockRestore(); - } - }); - it("should preserve distinct shared-accountId imports when the imported row has no email", async () => { const existing = { version: 3, @@ -715,12 +478,7 @@ describe("storage", () => { const imported = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(imported).toEqual({ - imported: 1, - total: 3, - skipped: 0, - changed: true, - }); + expect(imported).toEqual({ imported: 1, total: 3, skipped: 0 }); expect(loaded?.accounts).toHaveLength(3); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual( expect.arrayContaining([ @@ -770,12 +528,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -820,12 +573,7 @@ describe("storage", () => { const result = await importAccounts(exportPath); const loaded = await loadAccounts(); - expect(result).toEqual({ - imported: 1, - skipped: 0, - total: 2, - changed: true, - }); + expect(result).toEqual({ imported: 1, skipped: 0, total: 2 }); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts.map((account) => account.refreshToken)).toEqual([ "refresh-existing", @@ -1359,73 +1107,6 @@ describe("storage", () => { ); }); - it("rejects a second import that would exceed MAX_ACCOUNTS", async () => { - const nearLimitAccounts = Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS - 1 }, - (_, index) => ({ - accountId: `existing-${index}`, - refreshToken: `ref-existing-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ); - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: nearLimitAccounts, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-one", - refreshToken: "ref-extra-one", - addedAt: 10_000, - lastUsed: 10_000, - }, - ], - }), - ); - - const first = await importAccounts(exportPath); - expect(first).toMatchObject({ - imported: 1, - skipped: 0, - total: ACCOUNT_LIMITS.MAX_ACCOUNTS, - changed: true, - }); - - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "extra-two", - refreshToken: "ref-extra-two", - addedAt: 20_000, - lastUsed: 20_000, - }, - ], - }), - ); - - await expect(importAccounts(exportPath)).rejects.toThrow( - /exceed maximum/, - ); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(ACCOUNT_LIMITS.MAX_ACCOUNTS); - expect( - loaded?.accounts.some((account) => account.accountId === "extra-two"), - ).toBe(false); - }); - it("should fail export when no accounts exist", async () => { const isolatedStorageDir = join( testWorkDir, @@ -1463,51 +1144,6 @@ describe("storage", () => { ); }); - it("retries transient import read errors before parsing the backup", async () => { - await fs.writeFile( - exportPath, - JSON.stringify({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "retry-import-read", - refreshToken: "ref-retry-import-read", - addedAt: 1, - lastUsed: 1, - }, - ], - }), - ); - const originalReadFile = fs.readFile.bind(fs); - let busyFailures = 0; - const readFileSpy = vi - .spyOn(fs, "readFile") - .mockImplementation(async (...args) => { - const [path] = args; - if (String(path) === exportPath && busyFailures === 0) { - busyFailures += 1; - const error = new Error("import file busy") as NodeJS.ErrnoException; - error.code = "EAGAIN"; - throw error; - } - return originalReadFile(...(args as Parameters)); - }); - - try { - const result = await importAccounts(exportPath); - expect(result).toMatchObject({ - imported: 1, - skipped: 0, - total: 1, - changed: true, - }); - expect(busyFailures).toBe(1); - } finally { - readFileSpy.mockRestore(); - } - }); - it("should fail import when file contains invalid JSON", async () => { await fs.writeFile(exportPath, "not valid json {["); await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); From 1090e2ce8aeeb58d77505ea4ec42cf59c8b8d9ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 11:22:01 +0800 Subject: [PATCH 53/76] fix(auth): clean startup recovery prompt branch --- docs/reference/commands.md | 2 ++ docs/reference/storage-paths.md | 4 +++ test/cli.test.ts | 10 ++++++ test/codex-manager-cli.test.ts | 55 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 131cb47a..ab142c2b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,6 +26,7 @@ Compatibility aliases are supported: | `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | | `codex auth features` | Print implemented feature summary | +| `codex auth restore-backup` | Open the backup restore picker directly | --- @@ -121,6 +122,7 @@ codex auth report --live --json Repair and recovery: ```bash +codex auth restore-backup codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index cf0747de..8ea743ec 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -109,6 +109,10 @@ Restore workflow: 3. Choose `Restore From Backup`. 4. Pick a backup and confirm the merge summary before import. +Direct entrypoint: + +- Run `codex auth restore-backup` to open the same picker without entering the full login dashboard first. + --- ## oc-chatgpt Target Paths diff --git a/test/cli.test.ts b/test/cli.test.ts index 269a0eba..efbffdce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -716,6 +716,16 @@ describe("CLI Module", () => { await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "restore-backup", }); + + mockRl.question.mockResolvedValueOnce("backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); + + mockRl.question.mockResolvedValueOnce("restore-backup"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "restore-backup", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a7c3baf1..b0f07ba3 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1575,6 +1575,61 @@ describe("codex manager cli commands", () => { }); }); + it("returns a non-zero exit code when the direct restore-backup command fails", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "settings@example.com", + accountId: "acc_settings", + refreshToken: "refresh-settings", + accessToken: "access-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + restoreNamedBackupMock.mockRejectedValueOnce(new Error("backup locked")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(1); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ From 0395cf4e79b5ac971c8479af5cf2d4b5b125c7e3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 05:57:48 +0800 Subject: [PATCH 54/76] feat(auth): add restore preview --- lib/codex-manager.ts | 1 + test/codex-manager-cli.test.ts | 198 ++++++++++++++++++++++++++++++--- test/storage.test.ts | 4 +- 3 files changed, 183 insertions(+), 20 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a111f208..146f8582 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -5069,6 +5069,7 @@ async function showBackupBrowserDetails( [ { label: "Preview Restore", value: "preview-restore", color: "green" }, { label: "Back", value: "back" }, + { label: "Preview Restore", value: "preview-restore", color: "green" }, ], { message: "Backup Actions", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b0f07ba3..b004c9b3 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1630,6 +1630,166 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("runs restore preview before applying a replace-only named backup", async () => { + setInteractiveTTY(true); + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "current@example.com", + accountId: "current-account", + refreshToken: "refresh-current", + accessToken: "access-current", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: null, + updatedAt: now, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + backupAccountCount: 1, + dedupedBackupAccountCount: 1, + conflictsWithinBackup: 0, + conflictsWithExisting: 0, + replacedExistingCount: 1, + keptExistingCount: 0, + keptBackupCount: 1, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 0, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + nextActiveIndex: 0, + nextActiveEmail: "current@example.com", + nextActiveAccountId: "current-account", + currentActiveIndex: 0, + currentActiveEmail: "current@example.com", + currentActiveAccountId: "current-account", + activeAccountOutcome: "unchanged", + activeAccountChanged: false, + activeAccountPreview: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + namedBackupRestorePreview: { + conflicts: [ + { + conflict: { + backupIndex: 0, + currentIndex: 0, + reasons: ["accountId", "email"], + resolution: "backup-kept", + }, + backup: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + }, + ], + activeAccount: { + current: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + next: { + index: 0, + email: "current@example.com", + accountId: "current-account", + }, + outcome: "unchanged", + changed: false, + }, + }, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(assessment) + .mockResolvedValueOnce(assessment); + selectMock + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("preview-restore"); + confirmMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]); + + expect(exitCode).toBe(0); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ + message: "Backup Browser", + }); + expect(selectMock.mock.calls[1]?.[1]).toMatchObject({ + message: "Backup Actions", + subtitle: "named-backup", + }); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), + }), + ); + expect(confirmMock).toHaveBeenCalledWith( + "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("restores healthy flagged accounts into active storage", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -5642,26 +5802,28 @@ describe("codex manager cli commands", () => { .mockImplementationOnce(async () => "preview-restore"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 1, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 1, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), - }), - ); - expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( - 2, - "named-backup", - expect.objectContaining({ - currentStorage: expect.objectContaining({ - accounts: expect.any(Array), + ); + expect(assessNamedBackupRestoreMock).toHaveBeenNthCalledWith( + 2, + "named-backup", + expect.objectContaining({ + currentStorage: expect.objectContaining({ + accounts: expect.any(Array), + }), }), ); expect(confirmMock).not.toHaveBeenCalled(); diff --git a/test/storage.test.ts b/test/storage.test.ts index bcab4efe..f0f995cc 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1646,7 +1646,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/Import file not found/); + ).rejects.toThrow(/ENOENT: no such file or directory/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1674,7 +1674,7 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/Invalid JSON in import file/); + ).rejects.toThrow(/is not valid JSON/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); From 4bfebed20a02e2c6a2c082bfef98a68e76b5af50 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:33:11 +0800 Subject: [PATCH 55/76] fix(storage): snapshot exact pre-mutation state --- lib/storage.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/storage.ts b/lib/storage.ts index a60a8a1c..056f7397 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2740,6 +2740,13 @@ async function importNormalizedAccounts( total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { + if (snapshotReason) { + await snapshotAccountStorage({ + reason: snapshotReason, + storage: existing, + storagePath: getStoragePath(), + }); + } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; From 39eeefd9b1f71011ae90a8d45316042944838313 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 06:56:25 +0800 Subject: [PATCH 56/76] feat(auth): add retention and recovery doctor --- lib/codex-manager.ts | 137 ++++++++++++++++++++++- lib/storage.ts | 166 +++++++++++++++++++++++++++- lib/sync-history.ts | 165 ++++++++++++++++++++++----- test/codex-manager-cli.test.ts | 103 +++++++++++++++++ test/storage-recovery-paths.test.ts | 103 +++++++++++++++++ test/sync-history.test.ts | 147 ++++++++++++------------ 6 files changed, 716 insertions(+), 105 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 146f8582..d8adf8fe 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -67,6 +67,7 @@ import { getActionableNamedBackupRestores, getRedactedFilesystemErrorLabel, getNamedBackupsDirectoryPath, + listAccountSnapshots, listNamedBackups, listRotatingBackups, NAMED_BACKUP_LIST_CONCURRENCY, @@ -90,6 +91,7 @@ import { getCodexCliConfigPath, loadCodexCliState, } from "./codex-cli/state.js"; +import { getLatestCodexCliSyncRollbackPlan } from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { confirm } from "./ui/confirm.js"; @@ -3489,6 +3491,9 @@ async function runDoctor(args: string[]): Promise { setStoragePath(null); const storagePath = getStoragePath(); + const walPath = `${storagePath}.wal`; + const storageExists = existsSync(storagePath); + const walExists = existsSync(walPath); const checks: DoctorCheck[] = []; const addCheck = (check: DoctorCheck): void => { checks.push(check); @@ -3496,14 +3501,14 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-file", - severity: existsSync(storagePath) ? "ok" : "warn", - message: existsSync(storagePath) + severity: storageExists ? "ok" : "warn", + message: storageExists ? "Account storage file found" : "Account storage file does not exist yet (first login pending)", details: storagePath, }); - if (existsSync(storagePath)) { + if (storageExists) { try { const stat = await fs.stat(storagePath); addCheck({ @@ -3522,6 +3527,82 @@ async function runDoctor(args: string[]): Promise { } } + addCheck({ + key: "storage-journal", + severity: walExists ? "ok" : "warn", + message: walExists + ? "Write-ahead journal found" + : "Write-ahead journal missing; recovery will rely on backups", + details: walPath, + }); + + const rotatingBackups = await listRotatingBackups(); + const validRotatingBackups = rotatingBackups.filter((backup) => backup.valid); + const invalidRotatingBackups = rotatingBackups.filter( + (backup) => !backup.valid, + ); + addCheck({ + key: "rotating-backups", + severity: + validRotatingBackups.length > 0 + ? "ok" + : rotatingBackups.length > 0 + ? "error" + : "warn", + message: + validRotatingBackups.length > 0 + ? `${validRotatingBackups.length} rotating backup(s) available` + : rotatingBackups.length > 0 + ? "Rotating backups are unreadable" + : "No rotating backups found yet", + details: + invalidRotatingBackups.length > 0 + ? `${invalidRotatingBackups.length} invalid backup(s); recreate by saving accounts` + : dirname(storagePath), + }); + + const snapshotBackups = await listAccountSnapshots(); + const validSnapshots = snapshotBackups.filter((snapshot) => snapshot.valid); + const invalidSnapshots = snapshotBackups.filter((snapshot) => !snapshot.valid); + addCheck({ + key: "snapshot-backups", + severity: + validSnapshots.length > 0 + ? "ok" + : snapshotBackups.length > 0 + ? "error" + : "warn", + message: + validSnapshots.length > 0 + ? `${validSnapshots.length} recovery snapshot(s) available` + : snapshotBackups.length > 0 + ? "Snapshot backups are unreadable" + : "No recovery snapshots found", + details: + invalidSnapshots.length > 0 + ? `${invalidSnapshots.length} invalid snapshot(s); create a fresh snapshot before destructive actions` + : getNamedBackupsDirectoryPath(), + }); + + addCheck({ + key: "recovery-chain", + severity: + storageExists || + walExists || + validRotatingBackups.length > 0 || + validSnapshots.length > 0 + ? "ok" + : "warn", + message: + storageExists || + walExists || + validRotatingBackups.length > 0 || + validSnapshots.length > 0 + ? "Recovery artifacts present" + : "No recovery artifacts found; create a snapshot or backup before destructive actions", + details: `storage=${storageExists}, wal=${walExists}, rotating=${validRotatingBackups.length}, snapshots=${validSnapshots.length}`, + }); + const codexAuthPath = getCodexCliAuthPath(); const codexConfigPath = getCodexCliConfigPath(); let codexAuthEmail: string | undefined; @@ -3626,6 +3707,56 @@ async function runDoctor(args: string[]): Promise { }); const storage = await loadAccounts(); + const rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + if (rollbackPlan.status === "ready") { + const accountCount = + rollbackPlan.accountCount ?? rollbackPlan.storage?.accounts.length; + addCheck({ + key: "codex-cli-rollback-checkpoint", + severity: "ok", + message: `Latest manual Codex CLI rollback checkpoint ready (${accountCount ?? "?"} account${accountCount === 1 ? "" : "s"})`, + details: rollbackPlan.snapshot?.path, + }); + } else { + const isBlocked = Boolean(rollbackPlan.snapshot); + addCheck({ + key: "codex-cli-rollback-checkpoint", + severity: isBlocked ? "error" : "warn", + message: isBlocked + ? "Latest manual Codex CLI rollback checkpoint cannot be restored" + : "No manual Codex CLI rollback checkpoint has been recorded yet", + details: [ + rollbackPlan.snapshot?.path ?? rollbackPlan.snapshot?.name, + rollbackPlan.reason, + isBlocked + ? "Action: Recreate the rollback checkpoint with a fresh manual Codex CLI sync before attempting rollback." + : "Action: Run a manual Codex CLI sync with backups enabled to capture a rollback checkpoint before applying changes.", + ] + .filter(Boolean) + .join(" | "), + }); + } + + const actionableNamedBackupRestores = await getActionableNamedBackupRestores({ + currentStorage: storage, + }); + const actionableBackupCount = actionableNamedBackupRestores.assessments.length; + addCheck({ + key: "named-backup-restores", + severity: actionableBackupCount > 0 ? "ok" : "warn", + message: + actionableBackupCount > 0 + ? `Found ${actionableBackupCount} actionable named backup restore${actionableBackupCount === 1 ? "" : "s"}` + : "No actionable named backup restores available", + details: [ + `total backups: ${actionableNamedBackupRestores.totalBackups}`, + actionableBackupCount > 0 + ? undefined + : `Action: Add or copy a named backup into ${getNamedBackupsDirectoryPath()} before attempting recovery.`, + ] + .filter(Boolean) + .join(" | "), + }); let fixChanged = false; let fixActions: DoctorFixAction[] = []; if (options.fix && storage && storage.accounts.length > 0) { diff --git a/lib/storage.ts b/lib/storage.ts index 056f7397..5bb2f115 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -49,6 +49,9 @@ const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; const ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS = 60_000; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3; +const AUTO_SNAPSHOT_NAME_PATTERN = + /^accounts-(?[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -2004,6 +2007,11 @@ export async function listNamedBackups(): Promise { return scanResult.backups.map((entry) => entry.backup); } +export async function listAccountSnapshots(): Promise { + const backups = await listNamedBackups(); + return backups.filter((backup) => isAccountSnapshotName(backup.name)); +} + export async function listRotatingBackups(): Promise { let storagePath: string | null = null; try { @@ -2272,6 +2280,149 @@ function buildAccountSnapshotName( return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`; } +export function isAccountSnapshotName(name: string): boolean { + return AUTO_SNAPSHOT_NAME_PATTERN.test(name); +} + +function getAccountSnapshotReason(name: string): string | null { + const match = name.match(AUTO_SNAPSHOT_NAME_PATTERN); + return match?.groups?.reason?.toLowerCase() ?? null; +} + +type AutoSnapshotDetails = { + backup: NamedBackupMetadata; + name: string; + reason: string; + sortTimestamp: number; +}; + +function parseAutoSnapshot( + backup: NamedBackupMetadata, +): AutoSnapshotDetails | null { + const reason = getAccountSnapshotReason(backup.name); + if (!reason) { + return null; + } + return { + backup, + name: backup.name, + reason, + sortTimestamp: backup.updatedAt ?? backup.createdAt ?? 0, + }; +} + +async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< + Set +> { + try { + const syncHistoryModule = await import("./sync-history.js"); + if (typeof syncHistoryModule.readSyncHistory !== "function") { + return new Set(); + } + const history = await syncHistoryModule.readSyncHistory({ + kind: "codex-cli-sync", + }); + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (!entry || entry.kind !== "codex-cli-sync") { + continue; + } + const run = entry.run; + if (run?.trigger !== "manual" || run.outcome !== "changed") { + continue; + } + const snapshotName = run.rollbackSnapshot?.name?.trim(); + if (!snapshotName) { + break; + } + return new Set([snapshotName]); + } + } catch (error) { + log.debug("Failed to load rollback snapshot names for retention", { + error: String(error), + }); + } + return new Set(); +} + +export interface AutoSnapshotPruneOptions { + backups?: NamedBackupMetadata[]; + preserveNames?: Iterable; + keepLatestPerReason?: number; +} + +export interface AutoSnapshotPruneResult { + pruned: NamedBackupMetadata[]; + kept: NamedBackupMetadata[]; +} + +export async function pruneAutoGeneratedSnapshots( + options: AutoSnapshotPruneOptions = {}, +): Promise { + const backups = options.backups ?? (await listNamedBackups()); + const keepLatestPerReason = Math.max( + 1, + options.keepLatestPerReason ?? 1, + ); + const preserveNames = new Set(options.preserveNames ?? []); + for (const rollbackName of await getLatestManualCodexCliRollbackSnapshotNames()) { + preserveNames.add(rollbackName); + } + + const autoSnapshots = backups + .map((backup) => parseAutoSnapshot(backup)) + .filter((snapshot): snapshot is AutoSnapshotDetails => snapshot !== null); + if (autoSnapshots.length === 0) { + return { pruned: [], kept: [] }; + } + + const keepSet = new Set(preserveNames); + const snapshotsByReason = new Map(); + for (const snapshot of autoSnapshots) { + const bucket = snapshotsByReason.get(snapshot.reason) ?? []; + bucket.push(snapshot); + snapshotsByReason.set(snapshot.reason, bucket); + } + + for (const snapshots of snapshotsByReason.values()) { + snapshots.sort((left, right) => right.sortTimestamp - left.sortTimestamp); + for (const snapshot of snapshots.slice(0, keepLatestPerReason)) { + keepSet.add(snapshot.name); + } + } + + const keptNames = new Set(keepSet); + const pruned: NamedBackupMetadata[] = []; + for (const snapshot of autoSnapshots) { + if (keepSet.has(snapshot.name)) { + continue; + } + try { + await unlinkWithRetry(snapshot.backup.path); + pruned.push(snapshot.backup); + } catch (error) { + keptNames.add(snapshot.name); + log.warn("Failed to prune auto-generated snapshot", { + name: snapshot.name, + path: snapshot.backup.path, + error: String(error), + }); + } + } + + const kept = autoSnapshots + .filter((snapshot) => keptNames.has(snapshot.name)) + .map((snapshot) => snapshot.backup); + + return { pruned, kept }; +} + +async function enforceSnapshotRetention(): Promise { + await pruneAutoGeneratedSnapshots({ + keepLatestPerReason: ACCOUNT_SNAPSHOT_RETENTION_PER_REASON, + }); +} + function extractPathTail(pathValue: string): string { const segments = pathValue.split(/[\\/]+/).filter(Boolean); return segments.at(-1) ?? pathValue; @@ -2320,8 +2471,9 @@ export async function snapshotAccountStorage( } const backupName = buildAccountSnapshotName(reason, now); + let snapshot: NamedBackupMetadata; try { - return await createBackup(backupName, { + snapshot = await createBackup(backupName, { force, storage: currentStorage, storagePath: resolvedStoragePath, @@ -2337,6 +2489,18 @@ export async function snapshotAccountStorage( }); return null; } + + try { + await enforceSnapshotRetention(); + } catch (error) { + log.warn("Failed to enforce account snapshot retention", { + reason, + backupName, + error: formatSnapshotErrorForLog(error), + }); + } + + return snapshot; } export async function assessNamedBackupRestore( diff --git a/lib/sync-history.ts b/lib/sync-history.ts index cc749171..e2eb7e51 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -68,6 +68,12 @@ function serializeEntry(entry: SyncHistoryEntry): string { return JSON.stringify(entry); } +export interface PrunedSyncHistory { + entries: SyncHistoryEntry[]; + removed: number; + latest: SyncHistoryEntry | null; +} + function withHistoryLock(fn: () => Promise): Promise { const previous = historyMutex; let release: () => void = () => {}; @@ -113,6 +119,31 @@ function parseEntry(line: string): SyncHistoryEntry | null { } } +function parseHistoryContent(content: string): SyncHistoryEntry[] { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => parseEntry(line)) + .filter((entry): entry is SyncHistoryEntry => entry !== null); +} + +async function loadHistoryEntriesFromDisk( + paths: SyncHistoryPaths, +): Promise { + const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return ""; + } + throw error; + }); + if (!content) { + return []; + } + return parseHistoryContent(content); +} + async function readHistoryTail( historyPath: string, options: { limit: number; kind?: SyncHistoryKind }, @@ -168,28 +199,87 @@ async function readHistoryTail( } } -async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { - const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return ""; +export function pruneSyncHistoryEntries( + entries: SyncHistoryEntry[], + maxEntries: number = MAX_HISTORY_ENTRIES, +): PrunedSyncHistory { + if (entries.length === 0) { + return { entries: [], removed: 0, latest: null }; + } + + const boundedMaxEntries = Math.max(0, maxEntries); + const latestByKind = new Map(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || latestByKind.has(entry.kind)) { + continue; + } + latestByKind.set(entry.kind, entry); + } + + const requiredEntries = new Set(latestByKind.values()); + const keptEntries: SyncHistoryEntry[] = []; + const seenEntries = new Set(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || seenEntries.has(entry)) { + continue; + } + if ( + keptEntries.length < boundedMaxEntries || + requiredEntries.has(entry) + ) { + keptEntries.push(entry); + seenEntries.add(entry); } - throw error; - }); - if (!content) { - return; } - const lines = content.split(/\r?\n/).filter(Boolean); - if (lines.length <= MAX_HISTORY_ENTRIES) { + + const chronologicalEntries = keptEntries + .reverse() + .map((entry) => cloneEntry(entry)); + const latest = cloneEntry(chronologicalEntries.at(-1) ?? null); + return { + entries: chronologicalEntries, + removed: entries.length - chronologicalEntries.length, + latest, + }; +} + +async function rewriteLatestEntry( + latest: SyncHistoryEntry | null, + paths: SyncHistoryPaths, +): Promise { + if (!latest) { + await fs.rm(paths.latestPath, { force: true }); return; } - const trimmedContent = `${lines.slice(-MAX_HISTORY_ENTRIES).join("\n")}\n`; - await fs.writeFile(paths.historyPath, trimmedContent, { + await fs.writeFile(paths.latestPath, `${JSON.stringify(latest, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); } +async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { + const entries = await loadHistoryEntriesFromDisk(paths); + const result = pruneSyncHistoryEntries(entries, MAX_HISTORY_ENTRIES); + if (result.removed === 0) { + return result; + } + if (result.entries.length === 0) { + await fs.rm(paths.historyPath, { force: true }); + return result; + } + await fs.writeFile( + paths.historyPath, + `${result.entries.map((entry) => serializeEntry(entry)).join("\n")}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); + return result; +} + export async function appendSyncHistoryEntry( entry: SyncHistoryEntry, ): Promise { @@ -201,11 +291,8 @@ export async function appendSyncHistoryEntry( encoding: "utf8", mode: 0o600, }); - await trimHistoryFileIfNeeded(paths); - await fs.writeFile(paths.latestPath, `${JSON.stringify(entry, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + const prunedHistory = await trimHistoryFileIfNeeded(paths); + await rewriteLatestEntry(prunedHistory.latest ?? entry, paths); lastAppendError = null; }); pendingHistoryWrites.add(writePromise); @@ -228,19 +315,14 @@ export async function readSyncHistory( const { kind, limit } = options; await waitForPendingHistoryWrites(); try { + const paths = getSyncHistoryPaths(); if (typeof limit === "number" && limit > 0) { return await readHistoryTail(getSyncHistoryPaths().historyPath, { kind, limit, }); } - const content = await fs.readFile(getSyncHistoryPaths().historyPath, "utf8"); - const parsed = content - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => parseEntry(line)) - .filter((entry): entry is SyncHistoryEntry => entry !== null); + const parsed = await loadHistoryEntriesFromDisk(paths); const filtered = kind ? parsed.filter((entry) => entry.kind === kind) : parsed; @@ -272,6 +354,39 @@ export function readLatestSyncHistorySync(): SyncHistoryEntry | null { } } +export async function pruneSyncHistory( + options: { maxEntries?: number } = {}, +): Promise<{ removed: number; kept: number; latest: SyncHistoryEntry | null }> { + const maxEntries = options.maxEntries ?? MAX_HISTORY_ENTRIES; + await waitForPendingHistoryWrites(); + return withHistoryLock(async () => { + const paths = getSyncHistoryPaths(); + await ensureHistoryDir(paths.directory); + const entries = await loadHistoryEntriesFromDisk(paths); + const result = pruneSyncHistoryEntries(entries, maxEntries); + if (result.entries.length === 0) { + await fs.rm(paths.historyPath, { force: true }); + } else { + await fs.writeFile( + paths.historyPath, + `${result.entries.map((entry) => serializeEntry(entry)).join("\n")}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); + } + await rewriteLatestEntry(result.latest, paths); + lastAppendPaths = paths; + lastAppendError = null; + return { + removed: result.removed, + kept: result.entries.length, + latest: result.latest, + }; + }); +} + export function cloneSyncHistoryEntry( entry: SyncHistoryEntry | null, ): SyncHistoryEntry | null { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b004c9b3..b36a4a4d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -10,6 +10,7 @@ const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); const getActionableNamedBackupRestoresMock = vi.fn(); +const listAccountSnapshotsMock = vi.fn(); const listNamedBackupsMock = vi.fn(); const listRotatingBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); @@ -149,6 +150,7 @@ vi.mock("../lib/storage.js", async () => { setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, + listAccountSnapshots: listAccountSnapshotsMock, listNamedBackups: listNamedBackupsMock, listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, @@ -643,6 +645,7 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listAccountSnapshotsMock.mockReset(); listNamedBackupsMock.mockReset(); listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); @@ -655,6 +658,7 @@ describe("codex manager cli commands", () => { allAssessments: [], totalBackups: 0, }); + listAccountSnapshotsMock.mockResolvedValue([]); listNamedBackupsMock.mockResolvedValue([]); listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ @@ -5041,6 +5045,105 @@ describe("codex manager cli commands", () => { ); }); + it("reports rollback checkpoint check in doctor json output", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "checkpoint@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + ], + }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValueOnce({ + status: "ready", + reason: "Rollback checkpoint ready (1 account).", + snapshot: { + name: "codex-checkpoint", + path: "/mock/backups/rollback.json", + }, + accountCount: 1, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ key: string; severity: string; details?: string }>; + }; + const checkpoint = payload.checks.find( + (check) => check.key === "codex-cli-rollback-checkpoint", + ); + expect(checkpoint).toBeDefined(); + expect(checkpoint?.severity).toBe("ok"); + expect(checkpoint?.details).toBe("/mock/backups/rollback.json"); + }); + + it("reports actionable named backup restores in doctor json output", async () => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restore@example.net", + refreshToken: "restore-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + getActionableNamedBackupRestoresMock.mockResolvedValueOnce({ + assessments: [ + { + backup: { + name: "restore-me", + path: "/mock/backups/restore-me.json", + createdAt: null, + updatedAt: null, + sizeBytes: null, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + }, + currentAccountCount: 1, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + eligibleForRestore: true, + }, + ], + allAssessments: [], + totalBackups: 1, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ key: string; severity: string; message: string }>; + }; + const restoreCheck = payload.checks.find( + (check) => check.key === "named-backup-restores", + ); + expect(restoreCheck).toBeDefined(); + expect(restoreCheck?.severity).toBe("ok"); + expect(restoreCheck?.message).toContain("actionable named backup restore"); + }); + it("runs doctor --fix in dry-run mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 6ef13f1b..4c1b34ce 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -5,14 +5,22 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + getNamedBackupsDirectoryPath, loadAccounts, getBackupMetadata, + pruneAutoGeneratedSnapshots, saveAccounts, setStorageBackupEnabled, setStoragePathDirect, + snapshotAccountStorage, clearAccounts, getRestoreAssessment, } from "../lib/storage.js"; +import { + __resetSyncHistoryForTests, + appendSyncHistoryEntry, + configureSyncHistoryForTests, +} from "../lib/sync-history.js"; function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { if (value && typeof value === "object" && "restoreEligible" in value) { @@ -37,11 +45,14 @@ describe("storage recovery paths", () => { workDir = join(tmpdir(), `codex-storage-recovery-${Date.now()}-${Math.random().toString(36).slice(2)}`); storagePath = join(workDir, "openai-codex-accounts.json"); await fs.mkdir(workDir, { recursive: true }); + configureSyncHistoryForTests(join(workDir, "logs")); setStoragePathDirect(storagePath); setStorageBackupEnabled(true); }); afterEach(async () => { + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); setStoragePathDirect(null); setStorageBackupEnabled(true); await removeWithRetry(workDir, { recursive: true, force: true }); @@ -794,5 +805,97 @@ describe("storage recovery paths", () => { const metadata = await getBackupMetadata(); expect(metadata.accounts.latestValidPath).toBe(newerManualPath); }); + + it("prunes older auto snapshots while keeping the latest per reason and manual backups", async () => { + const backupsDir = getNamedBackupsDirectoryPath(); + await fs.mkdir(backupsDir, { recursive: true }); + const payload = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-a" }], + }; + const olderName = + "accounts-codex-cli-sync-snapshot-2026-03-10_00-00-00_000"; + const olderPath = join(backupsDir, `${olderName}.json`); + const newerName = + "accounts-codex-cli-sync-snapshot-2026-03-12_00-00-00_000"; + const newerPath = join(backupsDir, `${newerName}.json`); + const manualPath = join(backupsDir, "manual-checkpoint.json"); + await fs.writeFile(olderPath, JSON.stringify(payload), "utf-8"); + await new Promise((resolve) => setTimeout(resolve, 20)); + await fs.writeFile(newerPath, JSON.stringify(payload), "utf-8"); + await fs.writeFile(manualPath, JSON.stringify(payload), "utf-8"); + + const result = await pruneAutoGeneratedSnapshots(); + expect(result.pruned.map((entry) => entry.name)).toContain(olderName); + expect(result.kept.map((entry) => entry.name)).toContain(newerName); + expect(existsSync(olderPath)).toBe(false); + expect(existsSync(newerPath)).toBe(true); + expect(existsSync(manualPath)).toBe(true); + }); + + it("retains rollback-referenced snapshots during snapshot retention", async () => { + const now = Date.UTC(2026, 2, 16, 0, 0, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-refresh", + accountId: "retention-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const firstSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(firstSnapshot).not.toBeNull(); + if (!firstSnapshot) { + throw new Error("expected first snapshot"); + } + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: storagePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: firstSnapshot.name, + path: firstSnapshot.path, + }, + }, + }); + + for (let index = 1; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + const backupsDir = getNamedBackupsDirectoryPath(); + const secondSnapshotName = + "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-01_000"; + expect(existsSync(firstSnapshot.path)).toBe(true); + expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); + }); }); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index b03b65ae..cfcfe840 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -1,54 +1,34 @@ -import { promises as nodeFs } from "node:fs"; +import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; import { __resetLastCodexCliSyncRunForTests, getLastCodexCliSyncRun, } from "../lib/codex-cli/sync.js"; +import type { LiveAccountSyncSnapshot } from "../lib/live-account-sync.js"; import { __resetSyncHistoryForTests, appendSyncHistoryEntry, configureSyncHistoryForTests, getSyncHistoryPaths, + pruneSyncHistory, + pruneSyncHistoryEntries, + readLatestSyncHistorySync, readSyncHistory, } from "../lib/sync-history.js"; - -const RETRYABLE_REMOVE_CODES = new Set([ - "EBUSY", - "EPERM", - "ENOTEMPTY", - "EACCES", - "ETIMEDOUT", -]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - for (let attempt = 0; attempt < 6; attempt += 1) { - try { - await nodeFs.rm(targetPath, options); - return; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return; - } - if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("sync history", () => { let workDir = ""; + let logDir = ""; beforeEach(async () => { - workDir = await nodeFs.mkdtemp(join(tmpdir(), "codex-sync-history-")); - configureSyncHistoryForTests(join(workDir, "logs")); + workDir = await fs.mkdtemp(join(tmpdir(), "codex-sync-history-")); + logDir = join(workDir, "logs"); + configureSyncHistoryForTests(logDir); + await fs.mkdir(logDir, { recursive: true }); await __resetSyncHistoryForTests(); __resetLastCodexCliSyncRunForTests(); }); @@ -61,26 +41,63 @@ describe("sync history", () => { vi.restoreAllMocks(); }); + function createSummary( + overrides: Partial = {}, + ): CodexCliSyncRun["summary"] { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + ...overrides, + }; + } + + function createCodexRun( + runAt: number, + targetPath: string, + options: { + outcome?: CodexCliSyncRun["outcome"]; + sourcePath?: string | null; + summary?: Partial; + } = {}, + ): CodexCliSyncRun { + return { + outcome: options.outcome ?? "noop", + runAt, + sourcePath: options.sourcePath ?? null, + targetPath, + summary: createSummary(options.summary), + trigger: "manual", + rollbackSnapshot: null, + }; + } + + function createLiveSnapshot( + now: number, + path: string = `/live-${now}`, + ): LiveAccountSyncSnapshot { + return { + path, + running: true, + lastKnownMtimeMs: now, + lastSyncAt: now, + reloadCount: 1, + errorCount: 0, + }; + } + it("reads the last matching history entry without loading the whole file", async () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 1, - run: { - outcome: "noop", - runAt: 1, + run: createCodexRun(1, "target.json", { sourcePath: "source-1.json", - targetPath: "target.json", - summary: { - sourceAccountCount: 0, - targetAccountCountBefore: 0, - targetAccountCountAfter: 0, - addedAccountCount: 0, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, - selectionChanged: false, - }, - }, + }), }); await appendSyncHistoryEntry({ kind: "live-account-sync", @@ -88,40 +105,24 @@ describe("sync history", () => { reason: "watch", outcome: "success", path: "openai-codex-accounts.json", - snapshot: { - path: "openai-codex-accounts.json", - running: true, - lastReason: "watch", - lastError: null, - lastSuccessAt: 2, - lastAttemptAt: 2, - reloadCount: 1, - errorCount: 0, - }, + snapshot: createLiveSnapshot(2, "openai-codex-accounts.json"), }); await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 3, - run: { + run: createCodexRun(3, "target.json", { outcome: "changed", - runAt: 3, sourcePath: "source-2.json", - targetPath: "target.json", summary: { sourceAccountCount: 1, - targetAccountCountBefore: 0, targetAccountCountAfter: 1, addedAccountCount: 1, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, selectionChanged: true, }, - }, + }), }); - const readFileSpy = vi.spyOn(nodeFs, "readFile"); - + const readFileSpy = vi.spyOn(fs, "readFile"); const history = await readSyncHistory({ kind: "codex-cli-sync", limit: 1 }); expect(history).toHaveLength(1); @@ -140,25 +141,19 @@ describe("sync history", () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", recordedAt: 11, - run: { + run: createCodexRun(11, "target.json", { outcome: "changed", - runAt: 11, sourcePath: "source.json", - targetPath: "target.json", summary: { sourceAccountCount: 1, - targetAccountCountBefore: 0, targetAccountCountAfter: 1, addedAccountCount: 1, - updatedAccountCount: 0, - unchangedAccountCount: 0, - destinationOnlyPreservedCount: 0, selectionChanged: true, }, - }, + }), }); - await nodeFs.rm(getSyncHistoryPaths().latestPath, { force: true }); + await fs.rm(getSyncHistoryPaths().latestPath, { force: true }); __resetLastCodexCliSyncRunForTests(); expect(getLastCodexCliSyncRun()).toBeNull(); From ca1666dd1223d26f131cdfed28d513c4a141d237 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 17:56:06 +0800 Subject: [PATCH 57/76] fix(storage): keep deterministic retention ordering --- lib/storage.ts | 16 +++++++++++++++- lib/sync-history.ts | 1 - test/storage-recovery-paths.test.ts | 3 +-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 5bb2f115..142a6d24 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -52,6 +52,8 @@ export const NAMED_BACKUP_LIST_CONCURRENCY = 8; export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3; const AUTO_SNAPSHOT_NAME_PATTERN = /^accounts-(?[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i; +const AUTO_SNAPSHOT_TIMESTAMP_PATTERN = + /-snapshot-(?\d{4})-(?\d{2})-(?\d{2})_(?\d{2})-(?\d{2})-(?\d{2})_(?\d{3})$/i; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -2303,11 +2305,23 @@ function parseAutoSnapshot( if (!reason) { return null; } + const timestampMatch = backup.name.match(AUTO_SNAPSHOT_TIMESTAMP_PATTERN); + const sortTimestamp = timestampMatch?.groups + ? Date.UTC( + Number(timestampMatch.groups.year), + Number(timestampMatch.groups.month) - 1, + Number(timestampMatch.groups.day), + Number(timestampMatch.groups.hour), + Number(timestampMatch.groups.minute), + Number(timestampMatch.groups.second), + Number(timestampMatch.groups.millisecond), + ) + : (backup.updatedAt ?? backup.createdAt ?? 0); return { backup, name: backup.name, reason, - sortTimestamp: backup.updatedAt ?? backup.createdAt ?? 0, + sortTimestamp, }; } diff --git a/lib/sync-history.ts b/lib/sync-history.ts index e2eb7e51..a3a08371 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -378,7 +378,6 @@ export async function pruneSyncHistory( } await rewriteLatestEntry(result.latest, paths); lastAppendPaths = paths; - lastAppendError = null; return { removed: result.removed, kept: result.entries.length, diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 4c1b34ce..df2dfc7b 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -821,9 +821,8 @@ describe("storage recovery paths", () => { "accounts-codex-cli-sync-snapshot-2026-03-12_00-00-00_000"; const newerPath = join(backupsDir, `${newerName}.json`); const manualPath = join(backupsDir, "manual-checkpoint.json"); - await fs.writeFile(olderPath, JSON.stringify(payload), "utf-8"); - await new Promise((resolve) => setTimeout(resolve, 20)); await fs.writeFile(newerPath, JSON.stringify(payload), "utf-8"); + await fs.writeFile(olderPath, JSON.stringify(payload), "utf-8"); await fs.writeFile(manualPath, JSON.stringify(payload), "utf-8"); const result = await pruneAutoGeneratedSnapshots(); From 55f48b8ad5d5535df5dc74e9151f369cb76c52c7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:16:29 +0800 Subject: [PATCH 58/76] fix(storage): make sync-history rewrites atomic --- lib/storage.ts | 2 +- lib/sync-history.ts | 40 ++++++++++++++++++----------- test/storage-recovery-paths.test.ts | 33 +++++++++++++++++++++++- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 142a6d24..28e66bdd 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2376,7 +2376,7 @@ export async function pruneAutoGeneratedSnapshots( const backups = options.backups ?? (await listNamedBackups()); const keepLatestPerReason = Math.max( 1, - options.keepLatestPerReason ?? 1, + options.keepLatestPerReason ?? ACCOUNT_SNAPSHOT_RETENTION_PER_REASON, ); const preserveNames = new Set(options.preserveNames ?? []); for (const rollbackName of await getLatestManualCodexCliRollbackSnapshotNames()) { diff --git a/lib/sync-history.ts b/lib/sync-history.ts index a3a08371..94cdbef7 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -253,10 +253,29 @@ async function rewriteLatestEntry( await fs.rm(paths.latestPath, { force: true }); return; } - await fs.writeFile(paths.latestPath, `${JSON.stringify(latest, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + await writeHistoryFileAtomically( + paths.latestPath, + `${JSON.stringify(latest, null, 2)}\n`, + ); +} + +async function writeHistoryFileAtomically( + targetPath: string, + content: string, +): Promise { + const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + try { + await fs.writeFile(tempPath, content, { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(tempPath, targetPath); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } } async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { @@ -269,13 +288,9 @@ async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise serializeEntry(entry)).join("\n")}\n`, - { - encoding: "utf8", - mode: 0o600, - }, ); return result; } @@ -367,17 +382,12 @@ export async function pruneSyncHistory( if (result.entries.length === 0) { await fs.rm(paths.historyPath, { force: true }); } else { - await fs.writeFile( + await writeHistoryFileAtomically( paths.historyPath, `${result.entries.map((entry) => serializeEntry(entry)).join("\n")}\n`, - { - encoding: "utf8", - mode: 0o600, - }, ); } await rewriteLatestEntry(result.latest, paths); - lastAppendPaths = paths; return { removed: result.removed, kept: result.entries.length, diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index df2dfc7b..fa00e224 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -825,7 +825,9 @@ describe("storage recovery paths", () => { await fs.writeFile(olderPath, JSON.stringify(payload), "utf-8"); await fs.writeFile(manualPath, JSON.stringify(payload), "utf-8"); - const result = await pruneAutoGeneratedSnapshots(); + const result = await pruneAutoGeneratedSnapshots({ + keepLatestPerReason: 1, + }); expect(result.pruned.map((entry) => entry.name)).toContain(olderName); expect(result.kept.map((entry) => entry.name)).toContain(newerName); expect(existsSync(olderPath)).toBe(false); @@ -896,5 +898,34 @@ describe("storage recovery paths", () => { expect(existsSync(firstSnapshot.path)).toBe(true); expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); }); + + it("uses the production per-reason snapshot retention default", async () => { + const payload = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-a" }], + }; + const snapshotNames = [ + "accounts-codex-cli-sync-snapshot-2026-03-10_00-00-00_000", + "accounts-codex-cli-sync-snapshot-2026-03-11_00-00-00_000", + "accounts-codex-cli-sync-snapshot-2026-03-12_00-00-00_000", + "accounts-codex-cli-sync-snapshot-2026-03-13_00-00-00_000", + ]; + await fs.mkdir(getNamedBackupsDirectoryPath(), { recursive: true }); + for (const name of snapshotNames) { + await fs.writeFile( + join(getNamedBackupsDirectoryPath(), `${name}.json`), + JSON.stringify(payload), + "utf-8", + ); + } + + const result = await pruneAutoGeneratedSnapshots(); + + expect( + result.kept.map((entry) => entry.name).sort(), + ).toEqual(snapshotNames.slice(1).sort()); + expect(result.pruned.map((entry) => entry.name)).toEqual([snapshotNames[0]]); + }); }); From 77f8f18cb6c8651663de0898e787966cd892a1d8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 19:54:57 +0800 Subject: [PATCH 59/76] fix(storage): retain older rollback snapshots --- lib/storage.ts | 2 +- test/storage-recovery-paths.test.ts | 83 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index 28e66bdd..902db2ba 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2347,7 +2347,7 @@ async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< } const snapshotName = run.rollbackSnapshot?.name?.trim(); if (!snapshotName) { - break; + continue; } return new Set([snapshotName]); } diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index fa00e224..1210daef 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -899,6 +899,89 @@ describe("storage recovery paths", () => { expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); }); + it("retains rollback-referenced snapshots when a newer manual sync has no checkpoint", async () => { + const now = Date.UTC(2026, 2, 16, 1, 0, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-fallback-refresh", + accountId: "retention-fallback-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const firstSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(firstSnapshot).not.toBeNull(); + if (!firstSnapshot) { + throw new Error("expected first snapshot"); + } + + const summary = { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: storagePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: firstSnapshot.name, + path: firstSnapshot.path, + }, + }, + }); + + for (let index = 1; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 10_000, + run: { + outcome: "changed", + runAt: now + 10_000, + sourcePath: storagePath, + targetPath: storagePath, + summary, + trigger: "manual", + rollbackSnapshot: null, + }, + }); + + const result = await pruneAutoGeneratedSnapshots(); + const backupsDir = getNamedBackupsDirectoryPath(); + const secondSnapshotName = + "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-01_000"; + + expect(result.kept.map((entry) => entry.name)).toContain(firstSnapshot.name); + expect(existsSync(firstSnapshot.path)).toBe(true); + expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); + }); + it("uses the production per-reason snapshot retention default", async () => { const payload = { version: 3, From 00e94467c0a64fc20fca6f1cc2e374be8ffec0cc Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 06:24:31 +0800 Subject: [PATCH 60/76] fix(sync): harden history retention rewrites --- lib/codex-manager.ts | 2 - lib/sync-history.ts | 107 +++++++++++++++------- test/codex-manager-cli.test.ts | 37 ++++++++ test/sync-history.test.ts | 158 +++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 33 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d8adf8fe..d844aa9b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3587,14 +3587,12 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "recovery-chain", severity: - storageExists || walExists || validRotatingBackups.length > 0 || validSnapshots.length > 0 ? "ok" : "warn", message: - storageExists || walExists || validRotatingBackups.length > 0 || validSnapshots.length > 0 diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 94cdbef7..77fc3386 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -10,7 +10,13 @@ const log = createLogger("sync-history"); const HISTORY_FILE_NAME = "sync-history.ndjson"; const LATEST_FILE_NAME = "sync-history-latest.json"; const MAX_HISTORY_ENTRIES = 200; -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); +const RETRYABLE_REMOVE_CODES = new Set([ + "EBUSY", + "EPERM", + "ENOTEMPTY", + "EACCES", +]); +const RETRYABLE_RENAME_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -89,6 +95,17 @@ async function waitForPendingHistoryWrites(): Promise { } } +function trackPendingHistoryWrite(promise: Promise): Promise { + const trackedPromise = promise.then( + () => undefined, + () => undefined, + ); + pendingHistoryWrites.add(trackedPromise); + return promise.finally(() => { + pendingHistoryWrites.delete(trackedPromise); + }); +} + async function ensureHistoryDir(directory: string): Promise { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); } @@ -245,12 +262,60 @@ export function pruneSyncHistoryEntries( }; } +async function waitForHistoryRetry(attempt: number): Promise { + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); +} + +async function removeHistoryFileWithRetry(targetPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(targetPath, { force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if ( + !code || + !RETRYABLE_REMOVE_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await waitForHistoryRetry(attempt); + } + } +} + +async function renameHistoryFileWithRetry( + tempPath: string, + targetPath: string, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rename(tempPath, targetPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !code || + !RETRYABLE_RENAME_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await waitForHistoryRetry(attempt); + } + } +} + async function rewriteLatestEntry( latest: SyncHistoryEntry | null, paths: SyncHistoryPaths, ): Promise { if (!latest) { - await fs.rm(paths.latestPath, { force: true }); + await removeHistoryFileWithRetry(paths.latestPath); return; } await writeHistoryFileAtomically( @@ -271,9 +336,9 @@ async function writeHistoryFileAtomically( encoding: "utf8", mode: 0o600, }); - await fs.rename(tempPath, targetPath); + await renameHistoryFileWithRetry(tempPath, targetPath); } catch (error) { - await fs.rm(tempPath, { force: true }).catch(() => {}); + await removeHistoryFileWithRetry(tempPath).catch(() => {}); throw error; } } @@ -285,7 +350,7 @@ async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { - const writePromise = withHistoryLock(async () => { + const writePromise = trackPendingHistoryWrite(withHistoryLock(async () => { const paths = getSyncHistoryPaths(); lastAppendPaths = paths; await ensureHistoryDir(paths.directory); @@ -309,8 +374,7 @@ export async function appendSyncHistoryEntry( const prunedHistory = await trimHistoryFileIfNeeded(paths); await rewriteLatestEntry(prunedHistory.latest ?? entry, paths); lastAppendError = null; - }); - pendingHistoryWrites.add(writePromise); + })); try { await writePromise; } catch (error) { @@ -319,8 +383,6 @@ export async function appendSyncHistoryEntry( error: lastAppendError, }); throw error; - } finally { - pendingHistoryWrites.delete(writePromise); } } @@ -374,13 +436,13 @@ export async function pruneSyncHistory( ): Promise<{ removed: number; kept: number; latest: SyncHistoryEntry | null }> { const maxEntries = options.maxEntries ?? MAX_HISTORY_ENTRIES; await waitForPendingHistoryWrites(); - return withHistoryLock(async () => { + return trackPendingHistoryWrite(withHistoryLock(async () => { const paths = getSyncHistoryPaths(); await ensureHistoryDir(paths.directory); const entries = await loadHistoryEntriesFromDisk(paths); const result = pruneSyncHistoryEntries(entries, maxEntries); if (result.entries.length === 0) { - await fs.rm(paths.historyPath, { force: true }); + await removeHistoryFileWithRetry(paths.historyPath); } else { await writeHistoryFileAtomically( paths.historyPath, @@ -393,7 +455,7 @@ export async function pruneSyncHistory( kept: result.entries.length, latest: result.latest, }; - }); + })); } export function cloneSyncHistoryEntry( @@ -411,24 +473,7 @@ export async function __resetSyncHistoryForTests(): Promise { await waitForPendingHistoryWrites(); await withHistoryLock(async () => { for (const target of [paths.historyPath, paths.latestPath]) { - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await fs.rm(target, { force: true }); - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if ( - !code || - !RETRYABLE_REMOVE_CODES.has(code) || - attempt === 4 - ) { - throw error; - } - await new Promise((resolve) => - setTimeout(resolve, 25 * 2 ** attempt), - ); - } - } + await removeHistoryFileWithRetry(target); } }); lastAppendError = null; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b36a4a4d..74463d45 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,4 +1,8 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; const createAuthorizationFlowMock = vi.fn(); const exchangeAuthorizationCodeMock = vi.fn(); @@ -5087,6 +5091,39 @@ describe("codex manager cli commands", () => { expect(checkpoint?.details).toBe("/mock/backups/rollback.json"); }); + it("reports recovery-chain as warn when only the storage file exists", async () => { + const storageDir = await fs.mkdtemp(join(tmpdir(), "codex-doctor-storage-")); + const storagePath = join(storageDir, "openai-codex-accounts.json"); + await fs.writeFile(storagePath, JSON.stringify({ version: 3, accounts: [] }), "utf8"); + getStoragePathMock.mockReturnValueOnce(storagePath); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ key: string; severity: string; details?: string }>; + }; + const recoveryChain = payload.checks.find( + (check) => check.key === "recovery-chain", + ); + expect(recoveryChain).toBeDefined(); + expect(recoveryChain?.severity).toBe("warn"); + expect(recoveryChain?.details).toContain("storage=true"); + expect(recoveryChain?.details).toContain("wal=false"); + } finally { + await removeWithRetry(storageDir, { recursive: true, force: true }); + } + }); + it("reports actionable named backup restores in doctor json output", async () => { loadAccountsMock.mockResolvedValueOnce({ version: 3, diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index cfcfe840..423b8cdd 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -91,6 +91,17 @@ describe("sync history", () => { }; } + function createDeferred(): { + promise: Promise; + resolve: () => void; + } { + let resolve = (): void => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; + } + it("reads the last matching history entry without loading the whole file", async () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", @@ -271,4 +282,151 @@ describe("sync history", () => { }), }); }); + + it("retries transient rename failures while pruning history files", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2, "/watch-1"), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), + }); + + const originalRename = fs.rename.bind(fs); + let failedHistoryRename = false; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (...args: Parameters) => { + const [, targetPath] = args; + if ( + !failedHistoryRename && + typeof targetPath === "string" && + targetPath.endsWith("sync-history.ndjson") + ) { + failedHistoryRename = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalRename(...args); + }); + + const result = await pruneSyncHistory({ maxEntries: 1 }); + + expect(result.kept).toBe(2); + expect(renameSpy).toHaveBeenCalled(); + expect(failedHistoryRename).toBe(true); + expect((await readSyncHistory()).map((entry) => entry.recordedAt)).toEqual([ + 2, + 3, + ]); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(3); + }); + + it("waits for in-flight prune rewrites before serving reads", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2, "/watch-1"), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), + }); + + const writeGate = createDeferred(); + const originalWriteFile = fs.writeFile.bind(fs); + let blockedPruneWrite = false; + vi.spyOn(fs, "writeFile").mockImplementation( + async (...args: Parameters) => { + const [targetPath] = args; + if ( + !blockedPruneWrite && + typeof targetPath === "string" && + targetPath.includes("sync-history.ndjson.tmp") + ) { + blockedPruneWrite = true; + await writeGate.promise; + } + return originalWriteFile(...args); + }, + ); + + const prunePromise = pruneSyncHistory({ maxEntries: 1 }); + await vi.waitFor(() => { + expect(blockedPruneWrite).toBe(true); + }); + + let readResolved = false; + const readPromise = readSyncHistory().then((history) => { + readResolved = true; + return history; + }); + + await Promise.resolve(); + expect(readResolved).toBe(false); + + writeGate.resolve(); + const [pruneResult, history] = await Promise.all([prunePromise, readPromise]); + + expect(pruneResult.kept).toBe(2); + expect(history.map((entry) => entry.recordedAt)).toEqual([2, 3]); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(3); + }); + + it("retries transient latest-file removal when pruning empty history", async () => { + const latestPath = getSyncHistoryPaths().latestPath; + await fs.writeFile( + latestPath, + `${JSON.stringify({ + kind: "codex-cli-sync", + recordedAt: 7, + run: createCodexRun(7, "/stale"), + })}\n`, + "utf8", + ); + + const originalRm = fs.rm.bind(fs); + let failedLatestRemove = false; + vi.spyOn(fs, "rm").mockImplementation(async (...args: Parameters) => { + const [targetPath] = args; + if ( + !failedLatestRemove && + typeof targetPath === "string" && + targetPath === latestPath + ) { + failedLatestRemove = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalRm(...args); + }); + + const result = await pruneSyncHistory(); + + expect(result.kept).toBe(0); + expect(failedLatestRemove).toBe(true); + expect(readLatestSyncHistorySync()).toBeNull(); + }); }); From 8f2d9cbe7d3eaf3764f290c0e8c02ac48e7aa7d7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 18:08:03 +0800 Subject: [PATCH 61/76] fix(auth): harden retention doctor blockers --- lib/codex-manager.ts | 18 +-- lib/storage.ts | 122 +++++++++++++++++--- lib/sync-history.ts | 34 +++++- test/codex-manager-cli.test.ts | 85 ++++++++++++++ test/storage-recovery-paths.test.ts | 167 +++++++++++++++++++++++++++- test/sync-history.test.ts | 26 +++++ 6 files changed, 422 insertions(+), 30 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d844aa9b..db8162e2 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3529,14 +3529,17 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-journal", - severity: walExists ? "ok" : "warn", + severity: walExists ? "warn" : "ok", message: walExists - ? "Write-ahead journal found" - : "Write-ahead journal missing; recovery will rely on backups", + ? "Write-ahead journal present; the last save may have been interrupted" + : "Write-ahead journal cleanly absent after the last save", details: walPath, }); - const rotatingBackups = await listRotatingBackups(); + const [rotatingBackups, snapshotBackups] = await Promise.all([ + listRotatingBackups(), + listAccountSnapshots(), + ]); const validRotatingBackups = rotatingBackups.filter((backup) => backup.valid); const invalidRotatingBackups = rotatingBackups.filter( (backup) => !backup.valid, @@ -3561,7 +3564,6 @@ async function runDoctor(args: string[]): Promise { : dirname(storagePath), }); - const snapshotBackups = await listAccountSnapshots(); const validSnapshots = snapshotBackups.filter((snapshot) => snapshot.valid); const invalidSnapshots = snapshotBackups.filter((snapshot) => !snapshot.valid); addCheck({ @@ -3704,8 +3706,10 @@ async function runDoctor(args: string[]): Promise { details: codexCliState?.path, }); - const storage = await loadAccounts(); - const rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + const [storage, rollbackPlan] = await Promise.all([ + loadAccounts(), + getLatestCodexCliSyncRollbackPlan(), + ]); if (rollbackPlan.status === "ready") { const accountCount = rollbackPlan.accountCount ?? rollbackPlan.storage?.accounts.length; diff --git a/lib/storage.ts b/lib/storage.ts index 902db2ba..068838c6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -50,6 +50,13 @@ const BACKUP_COPY_BASE_DELAY_MS = 10; const ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS = 60_000; export const NAMED_BACKUP_LIST_CONCURRENCY = 8; export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3; +const ACCOUNT_SNAPSHOT_REASONS: ReadonlySet = new Set([ + "delete-account", + "delete-saved-accounts", + "reset-local-state", + "import-accounts", + "codex-cli-sync", +]); const AUTO_SNAPSHOT_NAME_PATTERN = /^accounts-(?[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i; const AUTO_SNAPSHOT_TIMESTAMP_PATTERN = @@ -235,6 +242,8 @@ interface LoadedBackupCandidate { storedVersion: unknown; schemaErrors: string[]; rawAccounts?: AccountLike[]; + backupKind?: "account-snapshot" | "named-backup" | null; + snapshotReason?: AccountSnapshotReason | null; error?: string; errorCode?: string; } @@ -260,6 +269,8 @@ function createUnloadedBackupCandidate(): LoadedBackupCandidate { storedVersion: null, schemaErrors: [], rawAccounts: [], + backupKind: null, + snapshotReason: null, }; } @@ -2010,8 +2021,10 @@ export async function listNamedBackups(): Promise { } export async function listAccountSnapshots(): Promise { - const backups = await listNamedBackups(); - return backups.filter((backup) => isAccountSnapshotName(backup.name)); + const scanResult = await scanNamedBackups(); + return scanResult.backups + .filter((entry) => parseAutoSnapshot(entry.backup, entry.candidate) !== null) + .map((entry) => entry.backup); } export async function listRotatingBackups(): Promise { @@ -2198,7 +2211,9 @@ export async function getActionableNamedBackupRestores( export async function createNamedBackup( name: string, options: { + backupKind?: "account-snapshot" | "named-backup"; force?: boolean; + snapshotReason?: AccountSnapshotReason; storage?: AccountStorageV3; storagePath?: string; } = {}, @@ -2219,7 +2234,9 @@ async function writeNamedBackupFromStorage( name: string, storage: AccountStorageV3, options: { + backupKind?: "account-snapshot" | "named-backup"; force?: boolean; + snapshotReason?: AccountSnapshotReason; storagePath?: string; } = {}, ): Promise { @@ -2283,12 +2300,44 @@ function buildAccountSnapshotName( } export function isAccountSnapshotName(name: string): boolean { - return AUTO_SNAPSHOT_NAME_PATTERN.test(name); + return getAccountSnapshotReason(name) !== null; +} + +function isAccountSnapshotReason( + reason: string, +): reason is AccountSnapshotReason { + return ACCOUNT_SNAPSHOT_REASONS.has(reason as AccountSnapshotReason); } -function getAccountSnapshotReason(name: string): string | null { +function getAccountSnapshotReason(name: string): AccountSnapshotReason | null { const match = name.match(AUTO_SNAPSHOT_NAME_PATTERN); - return match?.groups?.reason?.toLowerCase() ?? null; + const reason = match?.groups?.reason?.toLowerCase() ?? null; + return reason && isAccountSnapshotReason(reason) ? reason : null; +} + +function extractStoredSnapshotReason( + data: unknown, +): AccountSnapshotReason | null { + if (!isRecord(data)) { + return null; + } + const snapshotReason = data.snapshotReason; + return typeof snapshotReason === "string" && + isAccountSnapshotReason(snapshotReason) + ? snapshotReason + : null; +} + +function extractStoredBackupKind( + data: unknown, +): "account-snapshot" | "named-backup" | null { + if (!isRecord(data)) { + return null; + } + const backupKind = data.backupKind; + return backupKind === "account-snapshot" || backupKind === "named-backup" + ? backupKind + : null; } type AutoSnapshotDetails = { @@ -2300,8 +2349,15 @@ type AutoSnapshotDetails = { function parseAutoSnapshot( backup: NamedBackupMetadata, + candidate?: LoadedBackupCandidate, ): AutoSnapshotDetails | null { - const reason = getAccountSnapshotReason(backup.name); + const reason = candidate === undefined + ? getAccountSnapshotReason(backup.name) + : candidate.backupKind === "account-snapshot" + ? candidate.snapshotReason ?? null + : candidate.backupKind === "named-backup" + ? null + : getAccountSnapshotReason(backup.name); if (!reason) { return null; } @@ -2326,7 +2382,7 @@ function parseAutoSnapshot( } async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< - Set + Set | null > { try { const syncHistoryModule = await import("./sync-history.js"); @@ -2352,9 +2408,10 @@ async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< return new Set([snapshotName]); } } catch (error) { - log.debug("Failed to load rollback snapshot names for retention", { - error: String(error), + log.warn("Failed to load rollback snapshot names for retention", { + error: error instanceof Error ? error.message : String(error), }); + return null; } return new Set(); } @@ -2373,23 +2430,41 @@ export interface AutoSnapshotPruneResult { export async function pruneAutoGeneratedSnapshots( options: AutoSnapshotPruneOptions = {}, ): Promise { - const backups = options.backups ?? (await listNamedBackups()); + const scanResult = options.backups ? null : await scanNamedBackups(); + const backups = + options.backups ?? + scanResult?.backups.map((entry) => entry.backup) ?? + []; const keepLatestPerReason = Math.max( 1, options.keepLatestPerReason ?? ACCOUNT_SNAPSHOT_RETENTION_PER_REASON, ); const preserveNames = new Set(options.preserveNames ?? []); - for (const rollbackName of await getLatestManualCodexCliRollbackSnapshotNames()) { - preserveNames.add(rollbackName); - } - const autoSnapshots = backups - .map((backup) => parseAutoSnapshot(backup)) + .map((backup) => + parseAutoSnapshot( + backup, + scanResult?.backups.find((entry) => entry.backup.path === backup.path) + ?.candidate, + ), + ) .filter((snapshot): snapshot is AutoSnapshotDetails => snapshot !== null); if (autoSnapshots.length === 0) { return { pruned: [], kept: [] }; } + const rollbackSnapshotNames = + await getLatestManualCodexCliRollbackSnapshotNames(); + if (rollbackSnapshotNames === null) { + return { + pruned: [], + kept: autoSnapshots.map((snapshot) => snapshot.backup), + }; + } + for (const rollbackName of rollbackSnapshotNames) { + preserveNames.add(rollbackName); + } + const keepSet = new Set(preserveNames); const snapshotsByReason = new Map(); for (const snapshot of autoSnapshots) { @@ -2489,6 +2564,7 @@ export async function snapshotAccountStorage( try { snapshot = await createBackup(backupName, { force, + snapshotReason: reason, storage: currentStorage, storagePath: resolvedStoragePath, }); @@ -2855,12 +2931,14 @@ async function loadAccountsFromPath(path: string): Promise<{ storedVersion: unknown; schemaErrors: string[]; rawAccounts: AccountLike[]; + snapshotReason: AccountSnapshotReason | null; }> { const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as unknown; return { ...parseAndNormalizeStorage(data), rawAccounts: extractRawAccounts(data), + snapshotReason: extractStoredSnapshotReason(data), }; } @@ -2975,9 +3053,15 @@ async function importNormalizedAccounts( async function loadBackupCandidate(path: string): Promise { try { - return await retryTransientFilesystemOperation(() => - loadAccountsFromPath(path), - ); + const data = JSON.parse( + await retryTransientFilesystemOperation(() => fs.readFile(path, "utf-8")), + ) as unknown; + return { + ...parseAndNormalizeStorage(data), + rawAccounts: extractRawAccounts(data), + backupKind: extractStoredBackupKind(data), + snapshotReason: extractStoredSnapshotReason(data), + }; } catch (error) { const errorCode = typeof (error as NodeJS.ErrnoException).code === "string" @@ -2988,6 +3072,8 @@ async function loadBackupCandidate(path: string): Promise storedVersion: undefined, schemaErrors: [], rawAccounts: [], + backupKind: null, + snapshotReason: null, error: String(error), errorCode, }; diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 77fc3386..5d3b074f 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -17,6 +17,7 @@ const RETRYABLE_REMOVE_CODES = new Set([ "EACCES", ]); const RETRYABLE_RENAME_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); +const RETRYABLE_APPEND_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -310,6 +311,31 @@ async function renameHistoryFileWithRetry( } } +async function appendHistoryFileWithRetry( + targetPath: string, + content: string, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.appendFile(targetPath, content, { + encoding: "utf8", + mode: 0o600, + }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + !code || + !RETRYABLE_APPEND_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await waitForHistoryRetry(attempt); + } + } +} + async function rewriteLatestEntry( latest: SyncHistoryEntry | null, paths: SyncHistoryPaths, @@ -367,10 +393,10 @@ export async function appendSyncHistoryEntry( const paths = getSyncHistoryPaths(); lastAppendPaths = paths; await ensureHistoryDir(paths.directory); - await fs.appendFile(paths.historyPath, `${serializeEntry(entry)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + await appendHistoryFileWithRetry( + paths.historyPath, + `${serializeEntry(entry)}\n`, + ); const prunedHistory = await trimHistoryFileIfNeeded(paths); await rewriteLatestEntry(prunedHistory.latest ?? entry, paths); lastAppendError = null; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 74463d45..6231b25c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5091,6 +5091,87 @@ describe("codex manager cli commands", () => { expect(checkpoint?.details).toBe("/mock/backups/rollback.json"); }); + it("reports storage journal and backup diagnostics in doctor json output", async () => { + const workDir = await fs.mkdtemp(join(tmpdir(), "codex-doctor-backups-")); + const storagePath = join(workDir, "openai-codex-accounts.json"); + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, accounts: [] }), + "utf8", + ); + getStoragePathMock.mockReturnValue(storagePath); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValueOnce({ + status: "unavailable", + reason: "No rollback checkpoint available", + snapshot: null, + }); + listRotatingBackupsMock.mockResolvedValueOnce([ + { + name: "broken-rotating-backup", + path: join(workDir, "broken-rotating-backup.json"), + valid: false, + slot: 1, + }, + ]); + listAccountSnapshotsMock.mockResolvedValueOnce([ + { + name: "broken-snapshot", + path: join(workDir, "broken-snapshot.json"), + valid: false, + version: 3, + accountCount: 1, + createdAt: null, + updatedAt: null, + sizeBytes: null, + schemaErrors: [], + loadError: "invalid", + }, + ]); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(1); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ + key: string; + severity: string; + message?: string; + details?: string; + }>; + }; + expect(payload.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "storage-journal", + severity: "ok", + message: "Write-ahead journal cleanly absent after the last save", + details: `${storagePath}.wal`, + }), + expect.objectContaining({ + key: "rotating-backups", + severity: "error", + }), + expect.objectContaining({ + key: "snapshot-backups", + severity: "error", + }), + ]), + ); + } finally { + await removeWithRetry(workDir, { recursive: true, force: true }); + } + }); + it("reports recovery-chain as warn when only the storage file exists", async () => { const storageDir = await fs.mkdtemp(join(tmpdir(), "codex-doctor-storage-")); const storagePath = join(storageDir, "openai-codex-accounts.json"); @@ -5112,9 +5193,13 @@ describe("codex manager cli commands", () => { const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { checks: Array<{ key: string; severity: string; details?: string }>; }; + const journal = payload.checks.find((check) => check.key === "storage-journal"); const recoveryChain = payload.checks.find( (check) => check.key === "recovery-chain", ); + expect(journal).toBeDefined(); + expect(journal?.severity).toBe("ok"); + expect(journal?.details).toBe(`${storagePath}.wal`); expect(recoveryChain).toBeDefined(); expect(recoveryChain?.severity).toBe("warn"); expect(recoveryChain?.details).toContain("storage=true"); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 1210daef..3fd68829 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { promises as fs, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { + createNamedBackup, + listAccountSnapshots, getNamedBackupsDirectoryPath, loadAccounts, getBackupMetadata, @@ -21,6 +23,7 @@ import { appendSyncHistoryEntry, configureSyncHistoryForTests, } from "../lib/sync-history.js"; +import * as syncHistoryModule from "../lib/sync-history.js"; function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { if (value && typeof value === "object" && "restoreEligible" in value) { @@ -835,6 +838,47 @@ describe("storage recovery paths", () => { expect(existsSync(manualPath)).toBe(true); }); + it("does not prune manual backups that only look like auto snapshots", async () => { + const backupsDir = getNamedBackupsDirectoryPath(); + await fs.mkdir(backupsDir, { recursive: true }); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-a" }], + }); + + const storage = await loadAccounts(); + if (!storage) { + throw new Error("expected storage"); + } + + const manualBackup = await createNamedBackup( + "accounts-delete-account-snapshot-2026-03-10_00-00-00_000", + { storage }, + ); + const snapshotTimes = [ + Date.UTC(2026, 2, 11, 0, 0, 0, 0), + Date.UTC(2026, 2, 12, 0, 0, 0, 0), + Date.UTC(2026, 2, 13, 0, 0, 0, 0), + ]; + + for (const now of snapshotTimes) { + await snapshotAccountStorage({ + reason: "delete-account", + now, + }); + } + + const snapshotNames = (await listAccountSnapshots()).map((entry) => entry.name); + expect(snapshotNames.sort()).toEqual([ + "accounts-delete-account-snapshot-2026-03-13_00-00-00_000", + "accounts-delete-account-snapshot-2026-03-12_00-00-00_000", + "accounts-delete-account-snapshot-2026-03-11_00-00-00_000", + ].sort()); + expect(snapshotNames).not.toContain(manualBackup.name); + expect(existsSync(manualBackup.path)).toBe(true); + }); + it("retains rollback-referenced snapshots during snapshot retention", async () => { const now = Date.UTC(2026, 2, 16, 0, 0, 0, 0); await saveAccounts({ @@ -982,6 +1026,127 @@ describe("storage recovery paths", () => { expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); }); + it("skips snapshot pruning when rollback history cannot be read", async () => { + const now = Date.UTC(2026, 2, 16, 2, 0, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-read-error-refresh", + accountId: "retention-read-error-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const pinnedSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(pinnedSnapshot).not.toBeNull(); + if (!pinnedSnapshot) { + throw new Error("expected pinned snapshot"); + } + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: storagePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: pinnedSnapshot.name, + path: pinnedSnapshot.path, + }, + }, + }); + + for (let index = 1; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + const currentSnapshots = await listAccountSnapshots(); + const retainedSnapshot = currentSnapshots.find( + (entry) => entry.name !== pinnedSnapshot.name, + ); + expect(retainedSnapshot).toBeDefined(); + if (!retainedSnapshot) { + throw new Error("expected retained snapshot"); + } + + const readHistorySpy = vi + .spyOn(syncHistoryModule, "readSyncHistory") + .mockRejectedValueOnce( + Object.assign(new Error("locked"), { code: "EPERM" }), + ); + + const result = await pruneAutoGeneratedSnapshots(); + + expect(result.pruned).toEqual([]); + expect(result.kept.map((entry) => entry.name)).toContain(pinnedSnapshot.name); + expect(result.kept.map((entry) => entry.name)).toContain(retainedSnapshot.name); + expect(existsSync(pinnedSnapshot.path)).toBe(true); + expect(existsSync(retainedSnapshot.path)).toBe(true); + readHistorySpy.mockRestore(); + }); + + it("keeps auto snapshots when rollback history cannot be read", async () => { + const backupsDir = getNamedBackupsDirectoryPath(); + await fs.mkdir(backupsDir, { recursive: true }); + + const snapshotNames = [ + "accounts-delete-account-snapshot-2026-03-10_00-00-00_000", + "accounts-delete-account-snapshot-2026-03-11_00-00-00_000", + "accounts-delete-account-snapshot-2026-03-12_00-00-00_000", + "accounts-delete-account-snapshot-2026-03-13_00-00-00_000", + ]; + const payload = JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-a" }], + }); + for (const name of snapshotNames) { + await fs.writeFile(join(backupsDir, `${name}.json`), payload, "utf-8"); + } + + const readSyncHistorySpy = vi + .spyOn(syncHistoryModule, "readSyncHistory") + .mockRejectedValueOnce( + Object.assign(new Error("busy"), { code: "EPERM" }), + ); + + try { + const result = await pruneAutoGeneratedSnapshots(); + expect(result.pruned).toEqual([]); + expect(result.kept.map((entry) => entry.name).sort()).toEqual( + snapshotNames.slice().sort(), + ); + for (const name of snapshotNames) { + expect(existsSync(join(backupsDir, `${name}.json`))).toBe(true); + } + } finally { + readSyncHistorySpy.mockRestore(); + } + }); + it("uses the production per-reason snapshot retention default", async () => { const payload = { version: 3, diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 423b8cdd..90d1a248 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -394,6 +394,32 @@ describe("sync history", () => { expect(readLatestSyncHistorySync()?.recordedAt).toBe(3); }); + it("retries transient append failures before committing sync history", async () => { + const originalAppendFile = fs.appendFile.bind(fs); + let failedOnce = false; + vi.spyOn(fs, "appendFile").mockImplementation( + async (...args: Parameters) => { + if (!failedOnce) { + failedOnce = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalAppendFile(...args); + }, + ); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 4, + run: createCodexRun(4, "/source-4"), + }); + + expect(failedOnce).toBe(true); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(4); + expect(await readSyncHistory()).toHaveLength(1); + }); + it("retries transient latest-file removal when pruning empty history", async () => { const latestPath = getSyncHistoryPaths().latestPath; await fs.writeFile( From 7e28a1c43e9de6a6c789ad4c12318ddf18ad24e4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 18:14:23 +0800 Subject: [PATCH 62/76] fix(doctor): guard backup probes from aborting --- lib/codex-manager.ts | 164 +++++++++++++++++++++------------ test/codex-manager-cli.test.ts | 24 ++--- 2 files changed, 115 insertions(+), 73 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index db8162e2..392bcd5a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3536,55 +3536,89 @@ async function runDoctor(args: string[]): Promise { details: walPath, }); - const [rotatingBackups, snapshotBackups] = await Promise.all([ + const [rotatingBackupsResult, snapshotBackupsResult] = await Promise.allSettled([ listRotatingBackups(), listAccountSnapshots(), ]); + const rotatingBackups = + rotatingBackupsResult.status === "fulfilled" + ? rotatingBackupsResult.value + : []; + const snapshotBackups = + snapshotBackupsResult.status === "fulfilled" + ? snapshotBackupsResult.value + : []; + if (rotatingBackupsResult.status === "rejected") { + addCheck({ + key: "rotating-backups", + severity: "error", + message: "Unable to scan rotating backups", + details: + rotatingBackupsResult.reason instanceof Error + ? rotatingBackupsResult.reason.message + : String(rotatingBackupsResult.reason), + }); + } const validRotatingBackups = rotatingBackups.filter((backup) => backup.valid); const invalidRotatingBackups = rotatingBackups.filter( (backup) => !backup.valid, ); - addCheck({ - key: "rotating-backups", - severity: - validRotatingBackups.length > 0 - ? "ok" - : rotatingBackups.length > 0 - ? "error" - : "warn", - message: - validRotatingBackups.length > 0 - ? `${validRotatingBackups.length} rotating backup(s) available` - : rotatingBackups.length > 0 - ? "Rotating backups are unreadable" - : "No rotating backups found yet", - details: - invalidRotatingBackups.length > 0 - ? `${invalidRotatingBackups.length} invalid backup(s); recreate by saving accounts` - : dirname(storagePath), - }); + if (rotatingBackupsResult.status === "fulfilled") { + addCheck({ + key: "rotating-backups", + severity: + validRotatingBackups.length > 0 + ? "ok" + : rotatingBackups.length > 0 + ? "error" + : "warn", + message: + validRotatingBackups.length > 0 + ? `${validRotatingBackups.length} rotating backup(s) available` + : rotatingBackups.length > 0 + ? "Rotating backups are unreadable" + : "No rotating backups found yet", + details: + invalidRotatingBackups.length > 0 + ? `${invalidRotatingBackups.length} invalid backup(s); recreate by saving accounts` + : dirname(storagePath), + }); + } + if (snapshotBackupsResult.status === "rejected") { + addCheck({ + key: "snapshot-backups", + severity: "error", + message: "Unable to scan recovery snapshots", + details: + snapshotBackupsResult.reason instanceof Error + ? snapshotBackupsResult.reason.message + : String(snapshotBackupsResult.reason), + }); + } const validSnapshots = snapshotBackups.filter((snapshot) => snapshot.valid); const invalidSnapshots = snapshotBackups.filter((snapshot) => !snapshot.valid); - addCheck({ - key: "snapshot-backups", - severity: - validSnapshots.length > 0 - ? "ok" - : snapshotBackups.length > 0 - ? "error" - : "warn", - message: - validSnapshots.length > 0 - ? `${validSnapshots.length} recovery snapshot(s) available` - : snapshotBackups.length > 0 - ? "Snapshot backups are unreadable" - : "No recovery snapshots found", - details: - invalidSnapshots.length > 0 - ? `${invalidSnapshots.length} invalid snapshot(s); create a fresh snapshot before destructive actions` - : getNamedBackupsDirectoryPath(), - }); + if (snapshotBackupsResult.status === "fulfilled") { + addCheck({ + key: "snapshot-backups", + severity: + validSnapshots.length > 0 + ? "ok" + : snapshotBackups.length > 0 + ? "error" + : "warn", + message: + validSnapshots.length > 0 + ? `${validSnapshots.length} recovery snapshot(s) available` + : snapshotBackups.length > 0 + ? "Snapshot backups are unreadable" + : "No recovery snapshots found", + details: + invalidSnapshots.length > 0 + ? `${invalidSnapshots.length} invalid snapshot(s); create a fresh snapshot before destructive actions` + : getNamedBackupsDirectoryPath(), + }); + } addCheck({ key: "recovery-chain", @@ -3739,26 +3773,38 @@ async function runDoctor(args: string[]): Promise { }); } - const actionableNamedBackupRestores = await getActionableNamedBackupRestores({ - currentStorage: storage, - }); - const actionableBackupCount = actionableNamedBackupRestores.assessments.length; - addCheck({ - key: "named-backup-restores", - severity: actionableBackupCount > 0 ? "ok" : "warn", - message: - actionableBackupCount > 0 - ? `Found ${actionableBackupCount} actionable named backup restore${actionableBackupCount === 1 ? "" : "s"}` - : "No actionable named backup restores available", - details: [ - `total backups: ${actionableNamedBackupRestores.totalBackups}`, - actionableBackupCount > 0 - ? undefined - : `Action: Add or copy a named backup into ${getNamedBackupsDirectoryPath()} before attempting recovery.`, - ] - .filter(Boolean) - .join(" | "), - }); + try { + const actionableNamedBackupRestores = await getActionableNamedBackupRestores({ + currentStorage: storage, + }); + const actionableBackupCount = actionableNamedBackupRestores.assessments.length; + addCheck({ + key: "named-backup-restores", + severity: actionableBackupCount > 0 ? "ok" : "warn", + message: + actionableBackupCount > 0 + ? `Found ${actionableBackupCount} actionable named backup restore${actionableBackupCount === 1 ? "" : "s"}` + : "No actionable named backup restores available", + details: [ + `total backups: ${actionableNamedBackupRestores.totalBackups}`, + actionableBackupCount > 0 + ? undefined + : `Action: Add or copy a named backup into ${getNamedBackupsDirectoryPath()} before attempting recovery.`, + ] + .filter(Boolean) + .join(" | "), + }); + } catch (error) { + addCheck({ + key: "named-backup-restores", + severity: "error", + message: "Unable to scan named backup restores", + details: + error instanceof Error + ? error.message + : String(error), + }); + } let fixChanged = false; let fixActions: DoctorFixAction[] = []; if (options.fix && storage && storage.accounts.length > 0) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6231b25c..c719ec22 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5119,20 +5119,12 @@ describe("codex manager cli commands", () => { slot: 1, }, ]); - listAccountSnapshotsMock.mockResolvedValueOnce([ - { - name: "broken-snapshot", - path: join(workDir, "broken-snapshot.json"), - valid: false, - version: 3, - accountCount: 1, - createdAt: null, - updatedAt: null, - sizeBytes: null, - schemaErrors: [], - loadError: "invalid", - }, - ]); + listAccountSnapshotsMock.mockRejectedValueOnce( + Object.assign(new Error("locked"), { code: "EPERM" }), + ); + getActionableNamedBackupRestoresMock.mockRejectedValueOnce( + new Error("locked"), + ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { @@ -5165,6 +5157,10 @@ describe("codex manager cli commands", () => { key: "snapshot-backups", severity: "error", }), + expect.objectContaining({ + key: "named-backup-restores", + severity: "error", + }), ]), ); } finally { From 432dcf287d1b4238bf82b91af3fd5a069aafa5c9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 18:45:59 +0800 Subject: [PATCH 63/76] fix(storage): fail on stale reset marker cleanup --- lib/storage.ts | 6 +----- test/storage.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 068838c6..044f7a11 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3656,11 +3656,7 @@ async function saveAccountsUnlocked( } await renameFileWithRetry(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } + await unlinkWithRetry(resetMarkerPath); lastAccountsSaveTimestamp = Date.now(); try { await fs.unlink(walPath); diff --git a/test/storage.test.ts b/test/storage.test.ts index f0f995cc..20051c09 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -5176,6 +5176,45 @@ describe("storage", () => { statSpy.mockRestore(); }); + it("throws when a stale reset marker cannot be cleared after a successful save", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + const resetMarkerPath = `${storagePath}.reset-intent`; + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + await fs.mkdir(dirname(storagePath), { recursive: true }); + await fs.writeFile(resetMarkerPath, "reset", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + let markerUnlinkAttempts = 0; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (String(targetPath) === resetMarkerPath) { + markerUnlinkAttempts += 1; + const err = new Error("marker locked") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + return realUnlink(targetPath); + }); + + try { + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(markerUnlinkAttempts).toBe(5); + expect(existsSync(resetMarkerPath)).toBe(true); + expect(existsSync(storagePath)).toBe(true); + } finally { + unlinkSpy.mockRestore(); + } + }); + it("retries backup copyFile on transient EBUSY and succeeds", async () => { const now = Date.now(); const storage = { From be56a6dca3092c0d86de912124f37407770cac5b Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:08:03 +0800 Subject: [PATCH 64/76] fix(storage): honor retention and recovery guardrails --- lib/codex-cli/sync.ts | 1 - lib/codex-manager.ts | 8 +-- lib/storage.ts | 57 +++++++++++----- lib/sync-history.ts | 25 ++++--- test/codex-cli-sync.test.ts | 2 + test/codex-manager-cli.test.ts | 36 ++++++++++ test/storage.test.ts | 119 +++++++++++++++++++++++++++++++++ test/sync-history.test.ts | 55 +++++++++++++++ 8 files changed, 272 insertions(+), 31 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 314f4dd9..f49a3efa 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -350,7 +350,6 @@ export function commitPendingCodexCliSyncRun( publishCodexCliSyncRun( { ...pendingRun.run, - runAt: Date.now(), rollbackSnapshot: normalizeRollbackSnapshot( pendingRun.run.rollbackSnapshot, ), diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 392bcd5a..de7ec913 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3623,15 +3623,11 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "recovery-chain", severity: - walExists || - validRotatingBackups.length > 0 || - validSnapshots.length > 0 + validRotatingBackups.length > 0 || validSnapshots.length > 0 ? "ok" : "warn", message: - walExists || - validRotatingBackups.length > 0 || - validSnapshots.length > 0 + validRotatingBackups.length > 0 || validSnapshots.length > 0 ? "Recovery artifacts present" : "No recovery artifacts found; create a snapshot or backup before destructive actions", details: `storage=${storageExists}, wal=${walExists}, rotating=${validRotatingBackups.length}, snapshots=${validSnapshots.length}`, diff --git a/lib/storage.ts b/lib/storage.ts index 044f7a11..ff7c52e2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1893,8 +1893,10 @@ export async function getRestoreAssessment(): Promise { }; } -async function scanNamedBackups(): Promise { - const backupRoot = getNamedBackupRoot(getStoragePath()); +async function scanNamedBackups( + storagePath: string = getStoragePath(), +): Promise { + const backupRoot = getNamedBackupRoot(storagePath); try { const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), @@ -2094,8 +2096,10 @@ async function retryTransientFilesystemOperation( throw new Error("Retry loop exhausted unexpectedly"); } -export function getNamedBackupsDirectoryPath(): string { - return getNamedBackupRoot(getStoragePath()); +export function getNamedBackupsDirectoryPath( + storagePath: string = getStoragePath(), +): string { + return getNamedBackupRoot(storagePath); } async function scanNamedBackupsForActionableRestores(): Promise { @@ -2381,9 +2385,26 @@ function parseAutoSnapshot( }; } -async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< - Set | null -> { +function matchesSnapshotRetentionTargetPath( + targetPath: string | null | undefined, + storagePath: string, +): boolean { + if (typeof targetPath !== "string") { + return false; + } + const normalizedTargetPath = targetPath.trim(); + const normalizedStoragePath = storagePath.trim(); + if (!normalizedTargetPath || !normalizedStoragePath) { + return false; + } + return process.platform === "win32" + ? normalizedTargetPath.toLowerCase() === normalizedStoragePath.toLowerCase() + : normalizedTargetPath === normalizedStoragePath; +} + +async function getLatestManualCodexCliRollbackSnapshotNames( + storagePath: string = getStoragePath(), +): Promise | null> { try { const syncHistoryModule = await import("./sync-history.js"); if (typeof syncHistoryModule.readSyncHistory !== "function") { @@ -2401,6 +2422,9 @@ async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< if (run?.trigger !== "manual" || run.outcome !== "changed") { continue; } + if (!matchesSnapshotRetentionTargetPath(run.targetPath, storagePath)) { + continue; + } const snapshotName = run.rollbackSnapshot?.name?.trim(); if (!snapshotName) { continue; @@ -2420,6 +2444,7 @@ export interface AutoSnapshotPruneOptions { backups?: NamedBackupMetadata[]; preserveNames?: Iterable; keepLatestPerReason?: number; + storagePath?: string; } export interface AutoSnapshotPruneResult { @@ -2430,7 +2455,8 @@ export interface AutoSnapshotPruneResult { export async function pruneAutoGeneratedSnapshots( options: AutoSnapshotPruneOptions = {}, ): Promise { - const scanResult = options.backups ? null : await scanNamedBackups(); + const storagePath = options.storagePath ?? getStoragePath(); + const scanResult = options.backups ? null : await scanNamedBackups(storagePath); const backups = options.backups ?? scanResult?.backups.map((entry) => entry.backup) ?? @@ -2454,7 +2480,7 @@ export async function pruneAutoGeneratedSnapshots( } const rollbackSnapshotNames = - await getLatestManualCodexCliRollbackSnapshotNames(); + await getLatestManualCodexCliRollbackSnapshotNames(storagePath); if (rollbackSnapshotNames === null) { return { pruned: [], @@ -2506,9 +2532,12 @@ export async function pruneAutoGeneratedSnapshots( return { pruned, kept }; } -async function enforceSnapshotRetention(): Promise { +async function enforceSnapshotRetention( + storagePath: string = getStoragePath(), +): Promise { await pruneAutoGeneratedSnapshots({ keepLatestPerReason: ACCOUNT_SNAPSHOT_RETENTION_PER_REASON, + storagePath, }); } @@ -2581,7 +2610,7 @@ export async function snapshotAccountStorage( } try { - await enforceSnapshotRetention(); + await enforceSnapshotRetention(storagePath); } catch (error) { log.warn("Failed to enforce account snapshot retention", { reason, @@ -4089,11 +4118,7 @@ async function saveFlaggedAccountsUnlocked( const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await renameFileWithRetry(tempPath, path); - try { - await fs.unlink(markerPath); - } catch { - // Best effort cleanup. - } + await unlinkWithRetry(markerPath); } catch (error) { try { await fs.unlink(tempPath); diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 5d3b074f..1707e52f 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -175,7 +175,7 @@ async function readHistoryTail( } let position = stats.size; - let remainder = ""; + let remainder = Buffer.alloc(0); const chunkSize = 8 * 1024; const matchesNewestFirst: SyncHistoryEntry[] = []; @@ -184,12 +184,20 @@ async function readHistoryTail( const length = position - start; const buffer = Buffer.alloc(length); const { bytesRead } = await handle.read(buffer, 0, length, start); - const combined = buffer.toString("utf8", 0, bytesRead) + remainder; - const lines = combined.split("\n"); - remainder = lines.shift() ?? ""; - - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]?.trim(); + const chunk = buffer.subarray(0, bytesRead); + const combined = + remainder.length > 0 ? Buffer.concat([chunk, remainder]) : chunk; + let lineEnd = combined.length; + + for (let index = combined.length - 1; index >= 0; index -= 1) { + if (combined[index] !== 0x0a) { + continue; + } + const line = combined + .subarray(index + 1, lineEnd) + .toString("utf8") + .trim(); + lineEnd = index; if (!line) continue; const entry = parseEntry(line); if (!entry) continue; @@ -200,10 +208,11 @@ async function readHistoryTail( } } + remainder = combined.subarray(0, lineEnd); position = start; } - const leadingLine = remainder.trim(); + const leadingLine = remainder.toString("utf8").trim(); if (matchesNewestFirst.length < limit && leadingLine) { const entry = parseEntry(leadingLine); if (entry && (!kind || entry.kind === kind)) { diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 18c07654..87ea604f 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1325,11 +1325,13 @@ describe("codex-cli sync", () => { expect(result.pendingRun).not.toBeNull(); expect(result.storage?.accounts).toHaveLength(2); expect(getLastCodexCliSyncRun()).toBeNull(); + result.pendingRun!.run.runAt = 123456789; commitPendingCodexCliSyncRun(result.pendingRun); const lastRun = getLastCodexCliSyncRun(); expect(lastRun?.outcome).toBe("changed"); + expect(lastRun?.runAt).toBe(123456789); expect(lastRun?.sourcePath).toBe(accountsPath); expect(lastRun?.summary.addedAccountCount).toBe(1); expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c719ec22..a27040b4 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5205,6 +5205,42 @@ describe("codex manager cli commands", () => { } }); + it("reports recovery-chain as warn when only a wal file exists", async () => { + const storageDir = await fs.mkdtemp(join(tmpdir(), "codex-doctor-storage-")); + const storagePath = join(storageDir, "openai-codex-accounts.json"); + await fs.writeFile(`${storagePath}.wal`, JSON.stringify({ version: 3, accounts: [] }), "utf8"); + getStoragePathMock.mockReturnValueOnce(storagePath); + loadAccountsMock.mockResolvedValueOnce(null); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ + key: string; + severity: string; + message?: string; + details?: string; + }>; + }; + const recoveryChain = payload.checks.find( + (check) => check.key === "recovery-chain", + ); + expect(recoveryChain).toBeDefined(); + expect(recoveryChain?.severity).toBe("warn"); + expect(recoveryChain?.message).toBe( + "No recovery artifacts found; create a snapshot or backup before destructive actions", + ); + expect(recoveryChain?.details).toContain("storage=false"); + expect(recoveryChain?.details).toContain("wal=true"); + } finally { + await removeWithRetry(storageDir, { recursive: true, force: true }); + } + }); + it("reports actionable named backup restores in doctor json output", async () => { loadAccountsMock.mockResolvedValueOnce({ version: 3, diff --git a/test/storage.test.ts b/test/storage.test.ts index 20051c09..95def0ab 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1086,6 +1086,54 @@ describe("storage", () => { ); }); + it("fails flagged saves when the stale reset marker cannot be cleared", async () => { + const now = Date.now(); + const flaggedPath = getFlaggedAccountsPath(); + const markerPath = `${flaggedPath}.reset-intent`; + + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(markerPath, "reset", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + let markerUnlinkAttempts = 0; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (String(targetPath) === markerPath) { + markerUnlinkAttempts += 1; + const error = new Error( + "flagged marker locked", + ) as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect( + saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-flagged", + email: "flagged@example.com", + refreshToken: "refresh-flagged-next", + addedAt: now, + lastUsed: now, + flaggedAt: now, + }, + ], + }), + ).rejects.toThrow("flagged marker locked"); + expect(markerUnlinkAttempts).toBe(5); + expect(existsSync(markerPath)).toBe(true); + expect(existsSync(flaggedPath)).toBe(true); + } finally { + unlinkSpy.mockRestore(); + } + }); + it("should enforce MAX_ACCOUNTS during import", async () => { const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ accountId: `acct${i}`, @@ -2556,6 +2604,77 @@ describe("storage", () => { ]); }); + it("enforces snapshot retention against the snapshot storage path", async () => { + const customStoragePath = join( + testWorkDir, + "alternate", + "openai-codex-accounts.json", + ); + const customStorage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + accountId: "custom", + refreshToken: "ref-custom", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + const defaultStorage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + accountId: "default", + refreshToken: "ref-default", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + + await saveAccounts(defaultStorage); + + for (const millisecond of [1, 2, 3, 4]) { + const suffix = String(millisecond).padStart(3, "0"); + const name = `accounts-reset-local-state-snapshot-2024-01-02_03-04-05_${suffix}`; + await createNamedBackup(name, { + force: true, + snapshotReason: "reset-local-state", + storage: defaultStorage, + storagePath: testStoragePath, + }); + await createNamedBackup(name, { + force: true, + snapshotReason: "reset-local-state", + storage: customStorage, + storagePath: customStoragePath, + }); + } + + await snapshotAccountStorage({ + reason: "reset-local-state", + now: Date.UTC(2024, 0, 2, 3, 4, 5, 5), + storage: customStorage, + storagePath: customStoragePath, + }); + + const defaultSnapshots = ( + await fs.readdir(getNamedBackupsDirectoryPath(testStoragePath)) + ).filter((name) => name.startsWith("accounts-reset-local-state-snapshot-")); + const customSnapshots = ( + await fs.readdir(getNamedBackupsDirectoryPath(customStoragePath)) + ).filter((name) => name.startsWith("accounts-reset-local-state-snapshot-")); + + expect(defaultSnapshots).toHaveLength(4); + expect(customSnapshots).toHaveLength(3); + expect(customSnapshots).not.toContain( + "accounts-reset-local-state-snapshot-2024-01-02_03-04-05_001.json", + ); + }); + it("passes the first loaded storage snapshot to createBackup when no storage is provided", async () => { await saveAccounts({ version: 3, diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 90d1a248..f9424e1b 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -148,6 +148,61 @@ describe("sync history", () => { expect(readFileSpy).not.toHaveBeenCalled(); }); + it("reads tail entries when a multibyte character crosses the chunk boundary", async () => { + const historyPath = getSyncHistoryPaths().historyPath; + const emoji = "🙂"; + const paddedMessage = `${"x".repeat(64)}${emoji} split`; + let historyContent = ""; + + for (let padding = 0; padding < 9000; padding += 1) { + const olderEntry = JSON.stringify({ + kind: "codex-cli-sync", + recordedAt: 1, + run: { + ...createCodexRun(1, "/older"), + message: paddedMessage, + }, + }); + const newerEntry = JSON.stringify({ + kind: "codex-cli-sync", + recordedAt: 2, + run: createCodexRun(2, `/newer-${"y".repeat(padding)}`), + }); + const candidate = `${olderEntry}\n${newerEntry}\n`; + const boundary = Buffer.byteLength(candidate) - 8 * 1024; + if (boundary <= 0) { + continue; + } + const emojiStart = Buffer.byteLength(olderEntry.split(emoji)[0] ?? ""); + const emojiEnd = emojiStart + Buffer.byteLength(emoji); + if (boundary > emojiStart && boundary < emojiEnd) { + historyContent = candidate; + break; + } + } + + expect(historyContent).not.toBe(""); + await fs.writeFile(historyPath, historyContent, "utf8"); + + const history = await readSyncHistory({ kind: "codex-cli-sync", limit: 2 }); + + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ + kind: "codex-cli-sync", + recordedAt: 1, + run: expect.objectContaining({ + message: paddedMessage, + }), + }); + expect(history[1]).toMatchObject({ + kind: "codex-cli-sync", + recordedAt: 2, + run: expect.objectContaining({ + targetPath: expect.stringContaining("/newer-"), + }), + }); + }); + it("recovers the last codex-cli sync run from history when the latest snapshot is missing", async () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", From 8e6bc3366e66209e524e0084bee508a73a4b0448 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:57:55 +0800 Subject: [PATCH 65/76] fix(auth): guard restore backup reentry --- lib/codex-manager.ts | 7 +++ test/codex-manager-cli.test.ts | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index de7ec913..5e64fab5 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4298,6 +4298,11 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "restore-backup") { + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; try { const pendingQuotaRefresh = pendingMenuQuotaRefresh; if (pendingQuotaRefresh) { @@ -4310,6 +4315,8 @@ async function runAuthLogin(): Promise { console.error( `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, ); + } finally { + destructiveActionInFlight = false; } continue; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a27040b4..08565e57 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -8612,6 +8612,85 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("skips restore-backup while another destructive action is already running", async () => { + const now = Date.now(); + const skipMessage = + "Another destructive action is already running. Wait for it to finish."; + const secondMenuAttempted = createDeferred(); + const skipLogged = createDeferred(); + const logSpy = vi.spyOn(console, "log").mockImplementation((message?: unknown) => { + if (message === skipMessage) { + skipLogged.resolve(); + } + }); + const firstResetStarted = createDeferred(); + const allowFirstResetToFinish = createDeferred(); + let menuPromptCall = 0; + + loadAccountsMock.mockImplementation(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + })); + promptLoginModeMock.mockImplementation(async () => { + menuPromptCall += 1; + if (menuPromptCall === 2) { + secondMenuAttempted.resolve(); + } + if (menuPromptCall === 1) { + return { mode: "reset" }; + } + if (menuPromptCall === 2) { + return { mode: "restore-backup" }; + } + return { mode: "cancel" }; + }); + resetLocalStateMock.mockImplementationOnce(async () => { + firstResetStarted.resolve(); + await allowFirstResetToFinish.promise; + return { + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const firstRunPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstResetStarted.promise; + + const secondRunPromise = runCodexMultiAuthCli(["auth", "login"]); + await secondMenuAttempted.promise; + await skipLogged.promise; + + expect(listNamedBackupsMock).not.toHaveBeenCalled(); + expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + + allowFirstResetToFinish.resolve(); + + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRunPromise, + secondRunPromise, + ]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + logSpy.mockRestore(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue( From 12c7c6ae0126e49e1ae81576a1480167cc23ecfc Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 21:15:54 +0800 Subject: [PATCH 66/76] fix(sync): harden history reload and rollback selection --- lib/sync-history.ts | 31 +++++++++++- test/sync-history.test.ts | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 1707e52f..5225880c 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -18,6 +18,7 @@ const RETRYABLE_REMOVE_CODES = new Set([ ]); const RETRYABLE_RENAME_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); const RETRYABLE_APPEND_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN"]); +const RETRYABLE_READ_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN"]); type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -162,6 +163,32 @@ async function loadHistoryEntriesFromDisk( return parseHistoryContent(content); } +async function loadHistoryEntriesFromDiskWithRetry( + paths: SyncHistoryPaths, +): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await loadHistoryEntriesFromDisk(paths); + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException).code; + if ( + !code || + !RETRYABLE_READ_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await waitForHistoryRetry(attempt); + } + } + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to load sync history entries."); +} + async function readHistoryTail( historyPath: string, options: { limit: number; kind?: SyncHistoryKind }, @@ -379,7 +406,7 @@ async function writeHistoryFileAtomically( } async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise { - const entries = await loadHistoryEntriesFromDisk(paths); + const entries = await loadHistoryEntriesFromDiskWithRetry(paths); const result = pruneSyncHistoryEntries(entries, MAX_HISTORY_ENTRIES); if (result.removed === 0) { return result; @@ -474,7 +501,7 @@ export async function pruneSyncHistory( return trackPendingHistoryWrite(withHistoryLock(async () => { const paths = getSyncHistoryPaths(); await ensureHistoryDir(paths.directory); - const entries = await loadHistoryEntriesFromDisk(paths); + const entries = await loadHistoryEntriesFromDiskWithRetry(paths); const result = pruneSyncHistoryEntries(entries, maxEntries); if (result.entries.length === 0) { await removeHistoryFileWithRetry(paths.historyPath); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index f9424e1b..4b4d87ca 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -10,6 +10,7 @@ import { import type { LiveAccountSyncSnapshot } from "../lib/live-account-sync.js"; import { __resetSyncHistoryForTests, + __getLastSyncHistoryErrorForTests, appendSyncHistoryEntry, configureSyncHistoryForTests, getSyncHistoryPaths, @@ -389,6 +390,106 @@ describe("sync history", () => { expect(readLatestSyncHistorySync()?.recordedAt).toBe(3); }); + it("retries transient read failures before refreshing the latest sync history entry", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), + }); + + const originalReadFile = fs.readFile.bind(fs); + let failedOnce = false; + const readSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args: Parameters) => { + const [targetPath] = args; + if ( + !failedOnce && + typeof targetPath === "string" && + targetPath.endsWith("sync-history.ndjson") + ) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }); + + try { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 2, + run: createCodexRun(2, "/source-2"), + }); + + expect(failedOnce).toBe(true); + expect(__getLastSyncHistoryErrorForTests()).toBeNull(); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(2); + expect((await readSyncHistory()).map((entry) => entry.recordedAt)).toEqual([ + 1, + 2, + ]); + } finally { + readSpy.mockRestore(); + } + }); + + it("retries transient read failures before pruning sync history", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2, "/watch-1"), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), + }); + + const originalReadFile = fs.readFile.bind(fs); + let failedOnce = false; + const readSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args: Parameters) => { + const [targetPath] = args; + if ( + !failedOnce && + typeof targetPath === "string" && + targetPath.endsWith("sync-history.ndjson") + ) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReadFile(...args); + }); + + try { + const result = await pruneSyncHistory({ maxEntries: 1 }); + + expect(result.kept).toBe(2); + expect(failedOnce).toBe(true); + expect(__getLastSyncHistoryErrorForTests()).toBeNull(); + expect(readLatestSyncHistorySync()?.recordedAt).toBe(3); + expect((await readSyncHistory()).map((entry) => entry.recordedAt)).toEqual([ + 2, + 3, + ]); + } finally { + readSpy.mockRestore(); + } + }); + it("waits for in-flight prune rewrites before serving reads", async () => { await appendSyncHistoryEntry({ kind: "codex-cli-sync", From 1902ff229dd4b9d6c9b60ae9c6b8d07b4b126043 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:17:49 +0800 Subject: [PATCH 67/76] fix(storage): pin transaction storage path --- lib/storage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index ff7c52e2..eae27bc0 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3029,7 +3029,6 @@ async function importNormalizedAccounts( await snapshotAccountStorage({ reason: snapshotReason, storage: existing, - storagePath: getStoragePath(), }); } const existingAccounts = existing?.accounts ?? []; From 67b607a0eb29931c79d76ab34403602f0cb20575 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 21:39:59 +0800 Subject: [PATCH 68/76] fix(sync): retry rollback and sync-history reads --- lib/codex-cli/sync.ts | 34 ++++++++++++++++++++- lib/sync-history.ts | 31 +++++++++++++++++-- test/sync-history.test.ts | 64 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index f49a3efa..abaf87ba 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -36,6 +36,13 @@ import { const log = createLogger("codex-cli-sync"); const RETRYABLE_SELECTION_TIMESTAMP_CODES = new Set(["EBUSY", "EPERM"]); export const SELECTION_TIMESTAMP_READ_MAX_ATTEMPTS = 4; +const RETRYABLE_ROLLBACK_READ_CODES = new Set([ + "EBUSY", + "EPERM", + "EACCES", + "EAGAIN", +]); +const ROLLBACK_READ_MAX_ATTEMPTS = 5; const RETRYABLE_ROLLBACK_SAVE_CODES = new Set(["EBUSY", "EAGAIN"]); const ROLLBACK_SAVE_MAX_ATTEMPTS = 5; const ROLLBACK_HISTORY_SCAN_LIMIT = 200; @@ -499,7 +506,32 @@ async function loadRollbackSnapshot( } try { - const raw = await fs.readFile(snapshot.path, "utf-8"); + let raw = ""; + for (let attempt = 0; attempt < ROLLBACK_READ_MAX_ATTEMPTS; attempt += 1) { + try { + raw = await fs.readFile(snapshot.path, "utf-8"); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + code === "ENOENT" || + !code || + !RETRYABLE_ROLLBACK_READ_CODES.has(code) || + attempt + 1 >= ROLLBACK_READ_MAX_ATTEMPTS + ) { + throw error; + } + log.warn( + "Retrying rollback checkpoint read after transient filesystem error", + { + attempt: attempt + 1, + maxAttempts: ROLLBACK_READ_MAX_ATTEMPTS, + error: getRedactedFilesystemErrorLabel(error), + }, + ); + await sleep(10 * 2 ** attempt); + } + } const parsed = JSON.parse(raw) as unknown; const normalized = normalizeAccountStorage(parsed); if (!normalized) { diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 5225880c..0c4248d0 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -189,12 +189,39 @@ async function loadHistoryEntriesFromDiskWithRetry( throw new Error("Failed to load sync history entries."); } +async function openHistoryFileWithRetry( + historyPath: string, +): Promise>> { + let lastError: unknown = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await fs.open(historyPath, "r"); + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException).code; + if ( + code === "ENOENT" || + !code || + !RETRYABLE_READ_CODES.has(code) || + attempt === 4 + ) { + throw error; + } + await waitForHistoryRetry(attempt); + } + } + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to open sync history file."); +} + async function readHistoryTail( historyPath: string, options: { limit: number; kind?: SyncHistoryKind }, ): Promise { const { kind, limit } = options; - const handle = await fs.open(historyPath, "r"); + const handle = await openHistoryFileWithRetry(historyPath); try { const stats = await handle.stat(); if (stats.size === 0) { @@ -461,7 +488,7 @@ export async function readSyncHistory( limit, }); } - const parsed = await loadHistoryEntriesFromDisk(paths); + const parsed = await loadHistoryEntriesFromDiskWithRetry(paths); const filtered = kind ? parsed.filter((entry) => entry.kind === kind) : parsed; diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index 4b4d87ca..f62907b7 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -576,6 +576,70 @@ describe("sync history", () => { expect(await readSyncHistory()).toHaveLength(1); }); + it("retries transient full-history read failures before succeeding", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 4, + run: createCodexRun(4, "/source-4"), + }); + + const historyPath = getSyncHistoryPaths().historyPath; + const originalReadFile = fs.readFile.bind(fs); + let failedOnce = false; + vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if ( + !failedOnce && + typeof targetPath === "string" && + targetPath === historyPath + ) { + failedOnce = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalReadFile(...args); + }); + + const history = await readSyncHistory(); + + expect(failedOnce).toBe(true); + expect(history).toHaveLength(1); + expect(history[0]?.recordedAt).toBe(4); + }); + + it("retries transient tail-open failures before reading recent history", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 5, + run: createCodexRun(5, "/source-5"), + }); + + const historyPath = getSyncHistoryPaths().historyPath; + const originalOpen = fs.open.bind(fs); + let failedOnce = false; + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const [targetPath] = args; + if ( + !failedOnce && + typeof targetPath === "string" && + targetPath === historyPath + ) { + failedOnce = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalOpen(...args); + }); + + const history = await readSyncHistory({ limit: 1 }); + + expect(failedOnce).toBe(true); + expect(history).toHaveLength(1); + expect(history[0]?.recordedAt).toBe(5); + }); + it("retries transient latest-file removal when pruning empty history", async () => { const latestPath = getSyncHistoryPaths().latestPath; await fs.writeFile( From ff2d7ef9d58eff6dff5cd03a81f9d4c16def4f1e Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 22:06:40 +0800 Subject: [PATCH 69/76] fix(sync): preserve live rollback checkpoints --- lib/codex-cli/sync.ts | 5 +- lib/storage.ts | 4 ++ test/codex-cli-sync.test.ts | 2 + test/storage-recovery-paths.test.ts | 99 +++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index abaf87ba..95b416d3 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -375,10 +375,7 @@ export function commitCodexCliSyncRunFailure( } publishCodexCliSyncRun( buildSyncRunError( - { - ...pendingRun.run, - runAt: Date.now(), - }, + pendingRun.run, error, ), pendingRun.revision, diff --git a/lib/storage.ts b/lib/storage.ts index eae27bc0..1d838d17 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2429,6 +2429,10 @@ async function getLatestManualCodexCliRollbackSnapshotNames( if (!snapshotName) { continue; } + const snapshotPath = run.rollbackSnapshot?.path?.trim(); + if (snapshotPath && !existsSync(snapshotPath)) { + continue; + } return new Set([snapshotName]); } } catch (error) { diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 87ea604f..5231c016 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -2646,6 +2646,7 @@ describe("codex-cli sync", () => { const result = await applyCodexCliSyncToStorage(current); expect(result.pendingRun).not.toBeNull(); + result.pendingRun!.run.runAt = 123456789; commitCodexCliSyncRunFailure(result.pendingRun, new Error("save busy")); @@ -2653,6 +2654,7 @@ describe("codex-cli sync", () => { expect(lastRun?.outcome).toBe("error"); expect(lastRun?.message).toBe("save busy"); expect(lastRun?.summary.addedAccountCount).toBe(1); + expect(lastRun?.runAt).toBe(123456789); }); it("keeps the newer pending sync outcome when an older commit finishes later", async () => { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 3fd68829..fc1e0774 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -1026,6 +1026,105 @@ describe("storage recovery paths", () => { expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); }); + it("falls back to the newest live rollback snapshot when a newer recorded checkpoint file is missing", async () => { + const now = Date.UTC(2026, 2, 16, 1, 30, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-live-refresh", + accountId: "retention-live-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const olderLiveSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(olderLiveSnapshot).not.toBeNull(); + if (!olderLiveSnapshot) { + throw new Error("expected older live snapshot"); + } + + const newerMissingSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + 1_000, + }); + expect(newerMissingSnapshot).not.toBeNull(); + if (!newerMissingSnapshot) { + throw new Error("expected newer snapshot"); + } + + const summary = { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: storagePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: olderLiveSnapshot.name, + path: olderLiveSnapshot.path, + }, + }, + }); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1_001, + run: { + outcome: "changed", + runAt: now + 1_001, + sourcePath: storagePath, + targetPath: storagePath, + summary, + trigger: "manual", + rollbackSnapshot: { + name: newerMissingSnapshot.name, + path: newerMissingSnapshot.path, + }, + }, + }); + + await fs.rm(newerMissingSnapshot.path, { force: true }); + expect(existsSync(newerMissingSnapshot.path)).toBe(false); + + for (let index = 2; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + const result = await pruneAutoGeneratedSnapshots(); + + expect(result.pruned.map((entry) => entry.name)).not.toContain( + olderLiveSnapshot.name, + ); + expect(result.kept.map((entry) => entry.name)).toContain( + olderLiveSnapshot.name, + ); + expect(existsSync(olderLiveSnapshot.path)).toBe(true); + }); + it("skips snapshot pruning when rollback history cannot be read", async () => { const now = Date.UTC(2026, 2, 16, 2, 0, 0, 0); await saveAccounts({ From 0cf01fc7090e027def0a6339421030bb38830516 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 22:38:29 +0800 Subject: [PATCH 70/76] fix(storage): fail closed on snapshot capture errors --- lib/storage.ts | 1 + test/storage-recovery-paths.test.ts | 53 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/storage.ts b/lib/storage.ts index 1d838d17..1fa20c0b 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3905,6 +3905,7 @@ export async function snapshotAndClearAccounts( failurePolicy: "error", storage: currentStorage, storagePath, + failurePolicy: "error", }); return clearAccountsUnlocked(storagePath); }); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index fc1e0774..b53d4070 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -15,6 +15,7 @@ import { setStorageBackupEnabled, setStoragePathDirect, snapshotAccountStorage, + snapshotAndClearAccounts, clearAccounts, getRestoreAssessment, } from "../lib/storage.js"; @@ -61,6 +62,58 @@ describe("storage recovery paths", () => { await removeWithRetry(workDir, { recursive: true, force: true }); }); + it("aborts snapshotAndClearAccounts when snapshot capture fails", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "acc-keep", + accountIdSource: "token", + email: "keep@example.com", + refreshToken: "refresh-keep", + accessToken: "access-keep", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (path, data, options) => { + if (String(path).includes("accounts-reset-local-state-snapshot-")) { + const error = new Error("snapshot write denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return originalWriteFile(path as never, data as never, options as never); + }); + + try { + await expect( + snapshotAndClearAccounts("reset-local-state"), + ).rejects.toMatchObject({ code: "EACCES" }); + + const persisted = await loadAccounts(); + expect(persisted?.accounts).toHaveLength(1); + expect(persisted?.accounts[0]?.refreshToken).toBe("refresh-keep"); + + const backupsDir = getNamedBackupsDirectoryPath(); + const backupEntries = existsSync(backupsDir) + ? await fs.readdir(backupsDir) + : []; + expect( + backupEntries.some((name) => + name.startsWith("accounts-reset-local-state-snapshot-"), + ), + ).toBe(false); + } finally { + writeFileSpy.mockRestore(); + } + }); + it("recovers from WAL journal when primary storage is unreadable", async () => { await fs.writeFile(storagePath, "{invalid-json", "utf-8"); From add37236100623f85fc9b0abf8661b358f245bda Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 22:49:03 +0800 Subject: [PATCH 71/76] test(sync): tighten rollback and history coverage --- test/codex-cli-sync.test.ts | 101 ++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 5231c016..00af0613 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -54,6 +54,23 @@ async function removeWithRetry( } } +async function waitForAsyncExpectation( + expectation: () => Promise, + attempts: number = 20, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await expectation(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw lastError; +} + describe("codex-cli sync", () => { let tempDir: string; let accountsPath: string; @@ -841,10 +858,32 @@ describe("codex-cli sync", () => { ); vi.spyOn(storageModule, "getLastAccountsSaveTimestamp").mockReturnValue(0); - vi.spyOn(writerModule, "getLastCodexCliSelectionWriteTimestamp").mockReturnValue( - 0, - ); - vi.spyOn(storageModule, "getStoragePath").mockReturnValue("\0busy-target"); + vi.spyOn( + writerModule, + "getLastCodexCliSelectionWriteTimestamp", + ).mockReturnValue(0); + vi.spyOn(storageModule, "getStoragePath").mockReturnValue(targetStoragePath); + const sourceTime = new Date("2026-03-13T00:00:00.000Z"); + const targetTime = new Date("2026-03-13T00:00:00.500Z"); + await utimes(accountsPath, sourceTime, sourceTime); + await writeFile(targetStoragePath, "{\"version\":3}", "utf-8"); + await utimes(targetStoragePath, targetTime, targetTime); + const statError = new Error("ebusy target") as NodeJS.ErrnoException; + statError.code = "EBUSY"; + const nodeFs = await import("node:fs"); + const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); + let targetStatCalls = 0; + const statSpy = vi + .spyOn(nodeFs.promises, "stat") + .mockImplementation(async (...args: Parameters) => { + if (args[0] === targetStoragePath) { + targetStatCalls += 1; + if (targetStatCalls === 1) { + throw statError; + } + } + return originalStat(...args); + }); const current: AccountStorageV3 = { version: 3, @@ -872,12 +911,17 @@ describe("codex-cli sync", () => { activeIndexByFamily: { codex: 1 }, }; - const preview = await previewCodexCliSync(current, { - forceRefresh: true, - }); + try { + const preview = await previewCodexCliSync(current, { + forceRefresh: true, + }); - expect(preview.status).toBe("noop"); - expect(preview.summary.selectionChanged).toBe(false); + expect(preview.status).toBe("noop"); + expect(preview.summary.selectionChanged).toBe(false); + expect(targetStatCalls).toBe(2); + } finally { + statSpy.mockRestore(); + } }); it.each(["EBUSY", "EPERM"] as const)( @@ -2247,6 +2291,9 @@ describe("codex-cli sync", () => { error: "EPERM", }), ); + const serializedWarnCall = JSON.stringify(warnSpy.mock.calls[0]); + expect(serializedWarnCall).not.toContain(tempDir); + expect(serializedWarnCall).not.toContain("openai-codex-accounts.json"); saveSpy.mockRestore(); } finally { @@ -2655,6 +2702,17 @@ describe("codex-cli sync", () => { expect(lastRun?.message).toBe("save busy"); expect(lastRun?.summary.addedAccountCount).toBe(1); expect(lastRun?.runAt).toBe(123456789); + await waitForAsyncExpectation(async () => { + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const lastHistory = history.at(-1); + expect(lastHistory?.kind).toBe("codex-cli-sync"); + if (lastHistory?.kind === "codex-cli-sync") { + expect(lastHistory.run.outcome).toBe("error"); + expect(lastHistory.run.message).toBe("save busy"); + expect(lastHistory.run.summary.addedAccountCount).toBe(1); + expect(lastHistory.run.runAt).toBe(123456789); + } + }); }); it("keeps the newer pending sync outcome when an older commit finishes later", async () => { @@ -2722,6 +2780,23 @@ describe("codex-cli sync", () => { message: "later run failed", }), ); + await waitForAsyncExpectation(async () => { + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const lastHistory = history.at(-1); + expect(lastHistory?.kind).toBe("codex-cli-sync"); + if (lastHistory?.kind === "codex-cli-sync") { + expect(lastHistory.run).toEqual( + expect.objectContaining({ + outcome: "changed", + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: expect.objectContaining({ + addedAccountCount: 1, + }), + }), + ); + } + }); }); it("ignores a duplicate sync-run publish for the same revision", async () => { @@ -2777,6 +2852,14 @@ describe("codex-cli sync", () => { expect(getLastCodexCliSyncRun()).toEqual(committedRun); expect(getLastCodexCliSyncRun()?.outcome).toBe("changed"); + await waitForAsyncExpectation(async () => { + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const lastHistory = history.at(-1); + expect(lastHistory?.kind).toBe("codex-cli-sync"); + if (lastHistory?.kind === "codex-cli-sync") { + expect(lastHistory.run).toEqual(committedRun); + } + }); }); it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { From 90b6df73a0eaed68c4de92d856981e50bfbedd56 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:47:28 +0800 Subject: [PATCH 72/76] fix(auth): restack retention doctor branch on rollback stack --- lib/storage.ts | 118 ++------------------------------- test/codex-cli-sync.test.ts | 6 +- test/codex-manager-cli.test.ts | 8 ++- test/storage.test.ts | 4 +- test/sync-history.test.ts | 6 +- 5 files changed, 17 insertions(+), 125 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 1fa20c0b..17cc6f95 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2245,6 +2245,8 @@ async function writeNamedBackupFromStorage( } = {}, ): Promise { const storagePath = options.storagePath ?? getStoragePath(); + const backupKind = + options.backupKind ?? (options.snapshotReason ? "account-snapshot" : "named-backup"); const backupPath = resolveNamedBackupPath(name, storagePath); if (!options.force && existsSync(backupPath)) { throw new Error(`File already exists: ${backupPath}`); @@ -2257,6 +2259,8 @@ async function writeNamedBackupFromStorage( accounts: storage.accounts, activeIndex: storage.activeIndex, activeIndexByFamily: storage.activeIndexByFamily, + backupKind, + snapshotReason: options.snapshotReason ?? null, }, null, 2, @@ -3029,12 +3033,6 @@ async function importNormalizedAccounts( total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { - if (snapshotReason) { - await snapshotAccountStorage({ - reason: snapshotReason, - storage: existing, - }); - } const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; @@ -3205,113 +3203,6 @@ async function resolveNamedBackupRestorePath(name: string): Promise { } } -async function loadBackupCandidate(path: string): Promise { - try { - return await retryTransientFilesystemOperation(() => - loadAccountsFromPath(path), - ); - } catch (error) { - return { - normalized: null, - storedVersion: undefined, - schemaErrors: [], - error: String(error), - }; - } -} - -function equalsNamedBackupEntry(left: string, right: string): boolean { - return process.platform === "win32" - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function stripNamedBackupJsonExtension(name: string): string { - return name.toLowerCase().endsWith(".json") - ? name.slice(0, -".json".length) - : name; -} - -async function findExistingNamedBackupPath( - name: string, -): Promise { - const requested = (name ?? "").trim(); - if (!requested) { - return undefined; - } - - const backupRoot = getNamedBackupRoot(getStoragePath()); - const requestedWithExtension = requested.toLowerCase().endsWith(".json") - ? requested - : `${requested}.json`; - const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); - - try { - const entries = await retryTransientFilesystemOperation(() => - fs.readdir(backupRoot, { withFileTypes: true }), - ); - for (const entry of entries) { - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const entryBaseName = stripNamedBackupJsonExtension(entry.name); - const matchesRequestedEntry = - equalsNamedBackupEntry(entry.name, requested) || - equalsNamedBackupEntry(entry.name, requestedWithExtension) || - equalsNamedBackupEntry(entryBaseName, requestedBaseName); - if (!matchesRequestedEntry) { - continue; - } - if (entry.isSymbolicLink() || !entry.isFile()) { - throw new Error( - `Named backup "${entryBaseName}" is not a regular backup file`, - ); - } - return resolvePath(join(backupRoot, entry.name)); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return undefined; - } - log.warn("Failed to read named backup directory", { - path: backupRoot, - error: String(error), - }); - throw error; - } - - return undefined; -} - -async function resolveNamedBackupRestorePath(name: string): Promise { - const existingPath = await findExistingNamedBackupPath(name); - if (existingPath) { - return existingPath; - } - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); - const requestedWithExtension = requested.toLowerCase().endsWith(".json") - ? requested - : `${requested}.json`; - try { - return buildNamedBackupPath(name); - } catch (error) { - const baseName = requestedWithExtension.toLowerCase().endsWith(".json") - ? requestedWithExtension.slice(0, -".json".length) - : requestedWithExtension; - if ( - requested.length > 0 && - basename(requestedWithExtension) === requestedWithExtension && - !requestedWithExtension.includes("..") && - !/^[A-Za-z0-9_-]+$/.test(baseName) - ) { - throw new Error( - `Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`, - ); - } - throw error; - } -} - async function loadAccountsFromJournal( path: string, ): Promise { @@ -3902,7 +3793,6 @@ export async function snapshotAndClearAccounts( const currentStorage = await loadAccountsInternal(saveAccountsUnlocked); await snapshotAccountStorage({ reason, - failurePolicy: "error", storage: currentStorage, storagePath, failurePolicy: "error", diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 00af0613..a3b28183 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -2787,12 +2787,10 @@ describe("codex-cli sync", () => { if (lastHistory?.kind === "codex-cli-sync") { expect(lastHistory.run).toEqual( expect.objectContaining({ - outcome: "changed", + outcome: "error", sourcePath: accountsPath, targetPath: targetStoragePath, - summary: expect.objectContaining({ - addedAccountCount: 1, - }), + message: "later run failed", }), ); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 08565e57..839ea937 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1635,7 +1635,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(1); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { + assessment, + }); }); it("runs restore preview before applying a replace-only named backup", async () => { @@ -1795,7 +1797,9 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledWith( "Restore named-backup? Import 0 new accounts for 1 total. Replacing 1 current account.", ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup", { + assessment, + }); }); it("restores healthy flagged accounts into active storage", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 95def0ab..dc47e403 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1694,7 +1694,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), - ).rejects.toThrow(/ENOENT: no such file or directory/); + ).rejects.toThrow(/Import file not found/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); @@ -1722,7 +1722,7 @@ describe("storage", () => { await expect( restoreNamedBackup("invalid-after-assessment"), - ).rejects.toThrow(/is not valid JSON/); + ).rejects.toThrow(/Invalid JSON in import file/); expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index f62907b7..6b3b0abf 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -254,10 +254,10 @@ describe("sync history", () => { const secondAppendGate = new Promise((resolve) => { releaseSecondAppend = resolve; }); - const originalAppendFile = nodeFs.appendFile; + const originalAppendFile = fs.appendFile.bind(fs); let appendCallCount = 0; - vi.spyOn(nodeFs, "appendFile").mockImplementation( - async (...args: Parameters) => { + vi.spyOn(fs, "appendFile").mockImplementation( + async (...args: Parameters) => { appendCallCount += 1; if (appendCallCount === 1) { resolveFirstStarted(); From 61d807ab82aafbf78a457a80ed8643e3c8a33068 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 01:47:51 +0800 Subject: [PATCH 73/76] fix(auth): remove duplicated restore menu entries after restack --- lib/codex-manager.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 5e64fab5..bba84f2c 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -112,8 +112,6 @@ const log = createLogger("codex-manager"); let destructiveActionInFlight = false; -let destructiveActionInFlight = false; - function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -5251,7 +5249,6 @@ async function showBackupBrowserDetails( [ { label: "Preview Restore", value: "preview-restore", color: "green" }, { label: "Back", value: "back" }, - { label: "Preview Restore", value: "preview-restore", color: "green" }, ], { message: "Backup Actions", From f07d8708f238aa69188e5bc2b58fafdb5fa076e3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 05:59:00 +0800 Subject: [PATCH 74/76] fix(storage): pin snapshot retention to resolved path --- lib/destructive-actions.ts | 1 - lib/storage.ts | 8 +-- test/storage-recovery-paths.test.ts | 96 ++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 1b7b97d4..0d5a308b 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -7,7 +7,6 @@ import { clearFlaggedAccounts, findMatchingAccountIndex, type FlaggedAccountStorageV1, - getStoragePath, loadFlaggedAccounts, snapshotAccountStorage, snapshotAndClearAccounts, diff --git a/lib/storage.ts b/lib/storage.ts index 17cc6f95..33ce7d9f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1187,10 +1187,6 @@ export function getFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); } -function getFlaggedAccountsPathForStoragePath(storagePath: string): string { - return join(dirname(storagePath), FLAGGED_ACCOUNTS_FILE_NAME); -} - function getLegacyFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME); } @@ -2618,7 +2614,7 @@ export async function snapshotAccountStorage( } try { - await enforceSnapshotRetention(storagePath); + await enforceSnapshotRetention(resolvedStoragePath); } catch (error) { log.warn("Failed to enforce account snapshot retention", { reason, @@ -2919,7 +2915,7 @@ function assessNamedBackupRestoreCandidate( export async function restoreNamedBackup( name: string, - options: { assessment?: BackupRestoreAssessment } = {}, + _options: { assessment?: BackupRestoreAssessment } = {}, ): Promise<{ imported: number; total: number; skipped: number }> { const backupPath = await resolveNamedBackupRestorePath(name); const candidate = await loadImportableBackupCandidate(backupPath); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index b53d4070..183a20d5 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -18,6 +18,7 @@ import { snapshotAndClearAccounts, clearAccounts, getRestoreAssessment, + withAccountAndFlaggedStorageTransaction, } from "../lib/storage.js"; import { __resetSyncHistoryForTests, @@ -989,11 +990,22 @@ describe("storage recovery paths", () => { }); } - const backupsDir = getNamedBackupsDirectoryPath(); - const secondSnapshotName = + const keptNames = (await listAccountSnapshots()).map((entry) => entry.name).sort(); + const expectedKeptNames = [ + firstSnapshot.name, + "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-02_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-03_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-04_000", + ].sort(); + const expectedPrunedName = "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-01_000"; - expect(existsSync(firstSnapshot.path)).toBe(true); - expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); + const backupsDir = getNamedBackupsDirectoryPath(); + + expect(keptNames).toEqual(expectedKeptNames); + for (const name of expectedKeptNames) { + expect(existsSync(join(backupsDir, `${name}.json`))).toBe(true); + } + expect(existsSync(join(backupsDir, `${expectedPrunedName}.json`))).toBe(false); }); it("retains rollback-referenced snapshots when a newer manual sync has no checkpoint", async () => { @@ -1071,12 +1083,78 @@ describe("storage recovery paths", () => { const result = await pruneAutoGeneratedSnapshots(); const backupsDir = getNamedBackupsDirectoryPath(); - const secondSnapshotName = - "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-01_000"; + const expectedKeptNames = [ + firstSnapshot.name, + "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-02_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-03_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-04_000", + ].sort(); + const expectedAlreadyPrunedNames = [ + "accounts-codex-cli-sync-snapshot-2026-03-16_01-00-01_000", + ]; + + expect(result.kept.map((entry) => entry.name).sort()).toEqual(expectedKeptNames); + expect(result.pruned).toEqual([]); + for (const name of expectedKeptNames) { + expect(existsSync(join(backupsDir, `${name}.json`))).toBe(true); + } + for (const name of expectedAlreadyPrunedNames) { + expect(existsSync(join(backupsDir, `${name}.json`))).toBe(false); + } + }); + + it("enforces snapshot retention against the transaction-pinned storage path", async () => { + const primaryDir = join(workDir, "primary"); + const alternateDir = join(workDir, "alternate"); + const primaryStoragePath = join(primaryDir, "openai-codex-accounts.json"); + const alternateStoragePath = join(alternateDir, "openai-codex-accounts.json"); + await fs.mkdir(primaryDir, { recursive: true }); + await fs.mkdir(alternateDir, { recursive: true }); + setStoragePathDirect(primaryStoragePath); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "tx-retention-refresh", + accountId: "tx-retention-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const baseTime = Date.UTC(2026, 2, 16, 3, 0, 0, 0); + for (let index = 0; index < 3; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: baseTime + index * 1_000, + }); + } + + const primaryBackupsDir = getNamedBackupsDirectoryPath(); + await withAccountAndFlaggedStorageTransaction(async (current) => { + setStoragePathDirect(alternateStoragePath); + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: baseTime + 3_000, + storage: current, + }); + }); - expect(result.kept.map((entry) => entry.name)).toContain(firstSnapshot.name); - expect(existsSync(firstSnapshot.path)).toBe(true); - expect(existsSync(join(backupsDir, `${secondSnapshotName}.json`))).toBe(false); + const primarySnapshotNames = (await fs.readdir(primaryBackupsDir)) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => entry.slice(0, -".json".length)) + .sort(); + const expectedPrimaryNames = [ + "accounts-codex-cli-sync-snapshot-2026-03-16_03-00-01_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_03-00-02_000", + "accounts-codex-cli-sync-snapshot-2026-03-16_03-00-03_000", + ].sort(); + + expect(primarySnapshotNames).toEqual(expectedPrimaryNames); + expect(existsSync(primaryStoragePath)).toBe(true); + expect(existsSync(alternateStoragePath)).toBe(false); }); it("falls back to the newest live rollback snapshot when a newer recorded checkpoint file is missing", async () => { From 7f9a0d9d5ba5ce1ef9bac14e70d20244b80f3333 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 06:13:38 +0800 Subject: [PATCH 75/76] test(sync): cover rollback checkpoint read retries --- lib/codex-cli/sync.ts | 19 ++++++++- test/codex-cli-sync.test.ts | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 95b416d3..74e72bf3 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -48,6 +48,9 @@ const ROLLBACK_SAVE_MAX_ATTEMPTS = 5; const ROLLBACK_HISTORY_SCAN_LIMIT = 200; let inFlightCodexCliSyncRollbackPromise: Promise | null = null; +let rollbackSnapshotReaderForTests: + | ((path: string) => Promise) + | null = null; function createEmptyStorage(): AccountStorageV3 { return { @@ -389,6 +392,20 @@ export function __resetLastCodexCliSyncRunForTests(): void { activePendingCodexCliSyncRunRevisions.clear(); lastCodexCliSyncHistoryLoadAttempted = false; inFlightCodexCliSyncRollbackPromise = null; + rollbackSnapshotReaderForTests = null; +} + +export function __setRollbackSnapshotReaderForTests( + reader: ((path: string) => Promise) | null, +): void { + rollbackSnapshotReaderForTests = reader; +} + +async function readRollbackSnapshotFile(path: string): Promise { + if (rollbackSnapshotReaderForTests) { + return rollbackSnapshotReaderForTests(path); + } + return fs.readFile(path, "utf-8"); } async function captureRollbackSnapshot( @@ -506,7 +523,7 @@ async function loadRollbackSnapshot( let raw = ""; for (let attempt = 0; attempt < ROLLBACK_READ_MAX_ATTEMPTS; attempt += 1) { try { - raw = await fs.readFile(snapshot.path, "utf-8"); + raw = await readRollbackSnapshotFile(snapshot.path); break; } catch (error) { const code = (error as NodeJS.ErrnoException).code; diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index a3b28183..a7f9faec 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -9,6 +9,7 @@ import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; import { __resetLastCodexCliSyncRunForTests, + __setRollbackSnapshotReaderForTests, applyCodexCliSyncToStorage, commitCodexCliSyncRunFailure, commitPendingCodexCliSyncRun, @@ -1889,6 +1890,83 @@ describe("codex-cli sync", () => { ); }); + it("retries transient rollback checkpoint read failures before succeeding", async () => { + const snapshotPath = join(tempDir, "rollback-read-retry-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify( + { + version: 3, + accounts: [ + { + accountId: "acc_old", + accountIdSource: "token", + email: "old@example.com", + refreshToken: "refresh-old", + accessToken: "access-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + } satisfies AccountStorageV3, + null, + 2, + ), + "utf-8", + ); + + const recordedRun: CodexCliSyncRun = { + outcome: "changed", + runAt: 10, + sourcePath: accountsPath, + targetPath: targetStoragePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: "accounts-codex-cli-sync-snapshot-read-retry", + path: snapshotPath, + }, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: recordedRun.runAt, + run: recordedRun, + }); + + let transientReadInjected = false; + try { + __setRollbackSnapshotReaderForTests(async (path) => { + if (!transientReadInjected && path === snapshotPath) { + transientReadInjected = true; + throw Object.assign(new Error("checkpoint busy"), { + code: "EPERM", + }); + } + return readFile(path, "utf-8"); + }); + + const plan = await getLatestCodexCliSyncRollbackPlan(); + expect(plan.status).toBe("ready"); + expect(plan.snapshot).toEqual(recordedRun.rollbackSnapshot); + if (!transientReadInjected) { + throw new Error("expected transient rollback checkpoint read failure"); + } + } finally { + __setRollbackSnapshotReaderForTests(null); + } + }); + it.each([ ["snapshot name", /name/i], ["snapshot path", /path/i], From a0bec836e044ea174c6f4227978181a840138aa6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 06:25:53 +0800 Subject: [PATCH 76/76] fix(storage): normalize rollback retention paths --- lib/storage.ts | 26 ++++-- test/storage-recovery-paths.test.ts | 128 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 33ce7d9f..147a981a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2389,17 +2389,31 @@ function matchesSnapshotRetentionTargetPath( targetPath: string | null | undefined, storagePath: string, ): boolean { + const normalizeRetentionTargetPath = (value: string): string => { + const normalizedValue = value.trim(); + if (!normalizedValue) { + return ""; + } + const looksWindowsLike = + process.platform === "win32" || + normalizedValue.includes("\\") || + /^[a-z]:[\\/]/i.test(normalizedValue); + const normalizedSeparators = looksWindowsLike + ? normalizedValue.replace(/\\/g, "/") + : normalizedValue; + return looksWindowsLike + ? normalizedSeparators.toLowerCase() + : normalizedSeparators; + }; if (typeof targetPath !== "string") { return false; } - const normalizedTargetPath = targetPath.trim(); - const normalizedStoragePath = storagePath.trim(); + const normalizedTargetPath = normalizeRetentionTargetPath(targetPath); + const normalizedStoragePath = normalizeRetentionTargetPath(storagePath); if (!normalizedTargetPath || !normalizedStoragePath) { return false; } - return process.platform === "win32" - ? normalizedTargetPath.toLowerCase() === normalizedStoragePath.toLowerCase() - : normalizedTargetPath === normalizedStoragePath; + return normalizedTargetPath === normalizedStoragePath; } async function getLatestManualCodexCliRollbackSnapshotNames( @@ -2430,7 +2444,7 @@ async function getLatestManualCodexCliRollbackSnapshotNames( continue; } const snapshotPath = run.rollbackSnapshot?.path?.trim(); - if (snapshotPath && !existsSync(snapshotPath)) { + if (!snapshotPath || !existsSync(snapshotPath)) { continue; } return new Set([snapshotName]); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 183a20d5..c83630f5 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -1008,6 +1008,72 @@ describe("storage recovery paths", () => { expect(existsSync(join(backupsDir, `${expectedPrunedName}.json`))).toBe(false); }); + it("retains rollback-referenced snapshots when history stores the target path with mixed separators", async () => { + const now = Date.UTC(2026, 2, 16, 0, 30, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-separator-refresh", + accountId: "retention-separator-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const firstSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(firstSnapshot).not.toBeNull(); + if (!firstSnapshot) { + throw new Error("expected first snapshot"); + } + + const recordedTargetPath = storagePath.includes("\\") + ? storagePath.replace(/\\/g, "/") + : storagePath.replace(/\//g, "\\"); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: recordedTargetPath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: firstSnapshot.name, + path: firstSnapshot.path, + }, + }, + }); + + for (let index = 1; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + const keptNames = (await listAccountSnapshots()).map((entry) => entry.name).sort(); + expect(keptNames).toContain(firstSnapshot.name); + expect(existsSync(firstSnapshot.path)).toBe(true); + }); + it("retains rollback-referenced snapshots when a newer manual sync has no checkpoint", async () => { const now = Date.UTC(2026, 2, 16, 1, 0, 0, 0); await saveAccounts({ @@ -1103,6 +1169,68 @@ describe("storage recovery paths", () => { } }); + it("does not preserve rollback snapshot names that have no live snapshot path", async () => { + const now = Date.UTC(2026, 2, 16, 1, 15, 0, 0); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "retention-empty-path-refresh", + accountId: "retention-empty-path-account", + addedAt: now, + lastUsed: now, + }, + ], + }); + + const firstSnapshot = await snapshotAccountStorage({ + reason: "codex-cli-sync", + now, + }); + expect(firstSnapshot).not.toBeNull(); + if (!firstSnapshot) { + throw new Error("expected first snapshot"); + } + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: now + 1, + run: { + outcome: "changed", + runAt: now + 1, + sourcePath: storagePath, + targetPath: storagePath, + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { + name: firstSnapshot.name, + path: " ", + }, + }, + }); + + for (let index = 1; index <= 4; index += 1) { + await snapshotAccountStorage({ + reason: "codex-cli-sync", + now: now + index * 1_000, + }); + } + + const result = await pruneAutoGeneratedSnapshots(); + expect(result.kept.map((entry) => entry.name)).not.toContain(firstSnapshot.name); + expect(existsSync(firstSnapshot.path)).toBe(false); + }); + it("enforces snapshot retention against the transaction-pinned storage path", async () => { const primaryDir = join(workDir, "primary"); const alternateDir = join(workDir, "alternate");