From e9fe149d0cd0e196a6c5a707065038c0c1ef84b7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 13:57:51 +0800 Subject: [PATCH 01/35] feat(auth): add backup restore manager --- docs/reference/storage-paths.md | 2 + lib/cli.ts | 3 + lib/codex-manager.ts | 109 ++ lib/storage.ts | 2009 +++++++++++++++++++------------ lib/ui/auth-menu.ts | 13 + lib/ui/copy.ts | 2 + test/storage.test.ts | 90 ++ 7 files changed, 1443 insertions(+), 785 deletions(-) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index bae76b84..64965725 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` | @@ -43,6 +44,7 @@ Ownership note: When project-scoped behavior is enabled: - `~/.codex/multi-auth/projects//openai-codex-accounts.json` +- `~/.codex/multi-auth/projects//backups/.json` `` is derived as: diff --git a/lib/cli.ts b/lib/cli.ts index 59b66f77..c6522f46 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 { @@ -279,6 +280,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 d967ffb8..52b09ec7 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -73,17 +73,22 @@ import { queuedRefresh } from "./refresh-queue.js"; import { type AccountMetadataV3, type AccountStorageV3, + assessNamedBackupRestore, type FlaggedAccountMetadataV1, type FlaggedAccountStorageV1, + getNamedBackupsDirectoryPath, getStoragePath, + listNamedBackups, loadAccounts, loadFlaggedAccounts, + restoreNamedBackup, saveAccounts, saveFlaggedAccounts, setStoragePath, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.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"; @@ -130,6 +135,17 @@ function formatReasonLabel(reason: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function formatRelativeDateShort( + timestamp: number | null | undefined, +): string | null { + if (!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; @@ -4069,6 +4085,95 @@ async function handleManageAction( } } +type BackupMenuAction = + | { + type: "restore"; + assessment: Awaited>; + } + | { type: "back" }; + +async function runBackupRestoreManager( + displaySettings: DashboardDisplaySettings, +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); + const backups = await listNamedBackups(); + if (backups.length === 0) { + console.log(`No named backups found. Place backup files in ${backupDir}.`); + return; + } + + const currentStorage = await loadAccounts(); + const assessments = await Promise.all( + backups.map((backup) => + assessNamedBackupRestore(backup.name, { currentStorage }), + ), + ); + + const items: MenuItem[] = assessments.map((assessment) => { + const status = + assessment.valid && !assessment.wouldExceedLimit + ? "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.valid || assessment.wouldExceedLimit, + }; + }); + + 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; + } + + const assessment = selection.assessment; + if (!assessment.valid || assessment.wouldExceedLimit) { + console.log(assessment.error ?? "Backup is not eligible for restore."); + return; + } + + const confirmMessage = `Restore backup "${assessment.backup.name}"? This will merge ${assessment.backup.accountCount ?? 0} account(s) into ${assessment.currentAccountCount} current (${assessment.mergedAccountCount ?? assessment.currentAccountCount} after dedupe).`; + const confirmed = await confirm(confirmMessage); + if (!confirmed) return; + + const result = await restoreNamedBackup(assessment.backup.name); + console.log( + `Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + ); +} + async function runAuthLogin(): Promise { setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; @@ -4192,6 +4297,10 @@ async function runAuthLogin(): Promise { ); continue; } + if (menuResult.mode === "restore-backup") { + await runBackupRestoreManager(displaySettings); + continue; + } if (menuResult.mode === "fresh" && menuResult.deleteAll) { await runActionPanel( DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426..344f97b8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,29 +1,36 @@ -import { promises as fs, existsSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; import { createHash } from "node:crypto"; +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 { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { + type AccountMetadataV1, + type AccountMetadataV3, + type AccountStorageV1, + type AccountStorageV3, + type CooldownReason, + migrateV1ToV3, + type RateLimitStateV3, +} from "./storage/migrations.js"; +import { + findProjectRoot, getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, - findProjectRoot, resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js"; -import { - migrateV1ToV3, - type CooldownReason, - type RateLimitStateV3, - type AccountMetadataV1, - type AccountStorageV1, - type AccountMetadataV3, - type AccountStorageV3, -} from "./storage/migrations.js"; -export type { CooldownReason, RateLimitStateV3, AccountMetadataV1, AccountStorageV1, AccountMetadataV3, AccountStorageV3 }; +export type { + CooldownReason, + RateLimitStateV3, + AccountMetadataV1, + AccountStorageV1, + AccountMetadataV3, + AccountStorageV3, +}; const log = createLogger("storage"); const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json"; @@ -34,6 +41,8 @@ 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 NAMED_BACKUP_DIRECTORY = "backups"; +const NAMED_BACKUP_EXTENSION = ".json"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -53,72 +62,84 @@ export interface FlaggedAccountStorageV1 { * Custom error class for storage operations with platform-aware hints. */ export class StorageError extends Error { - readonly code: string; - readonly path: string; - readonly hint: string; - - constructor(message: string, code: string, path: string, hint: string, cause?: Error) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; - this.path = path; - this.hint = hint; - } + readonly code: string; + readonly path: string; + readonly hint: string; + + constructor( + message: string, + code: string, + path: string, + hint: string, + cause?: Error, + ) { + super(message, { cause }); + this.name = "StorageError"; + this.code = code; + this.path = path; + this.hint = hint; + } } /** * Generate platform-aware troubleshooting hint based on error code. */ export function formatStorageErrorHint(error: unknown, path: string): string { - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const isWindows = process.platform === "win32"; - - switch (code) { - case "EACCES": - case "EPERM": - return isWindows - ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.` - : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; - case "EBUSY": - return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; - case "ENOSPC": - return `Disk is full. Free up space and try again. Path: ${path}`; - case "EEMPTY": - return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; - default: - return isWindows - ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` - : `Failed to write to ${path}. Check folder permissions and disk space.`; - } + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const isWindows = process.platform === "win32"; + + switch (code) { + case "EACCES": + case "EPERM": + return isWindows + ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.` + : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; + case "EBUSY": + return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; + case "ENOSPC": + return `Disk is full. Free up space and try again. Path: ${path}`; + case "EEMPTY": + return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; + default: + return isWindows + ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` + : `Failed to write to ${path}. Check folder permissions and disk space.`; + } } let storageMutex: Promise = Promise.resolve(); function withStorageLock(fn: () => Promise): Promise { - const previousMutex = storageMutex; - let releaseLock: () => void; - storageMutex = new Promise((resolve) => { - releaseLock = resolve; - }); - return previousMutex.then(fn).finally(() => releaseLock()); + const previousMutex = storageMutex; + let releaseLock: () => void; + storageMutex = new Promise((resolve) => { + releaseLock = resolve; + }); + return previousMutex.then(fn).finally(() => releaseLock()); } type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { - accountId?: string; - email?: string; - refreshToken: string; - addedAt?: number; - lastUsed?: number; + accountId?: string; + email?: string; + refreshToken: string; + addedAt?: number; + lastUsed?: number; }; function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { - const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; + const email = + typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; const refreshToken = - typeof account.refreshToken === "string" ? account.refreshToken.trim().toLowerCase() : ""; - const accountId = typeof account.accountId === "string" ? account.accountId.trim().toLowerCase() : ""; + typeof account.refreshToken === "string" + ? account.refreshToken.trim().toLowerCase() + : ""; + const accountId = + typeof account.accountId === "string" + ? account.accountId.trim().toLowerCase() + : ""; if (!/^account\d+@example\.com$/.test(email)) { return false; } @@ -134,39 +155,51 @@ function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { return /^acc(_|-)?\d+$/.test(accountId); } -function looksLikeSyntheticFixtureStorage(storage: AccountStorageV3 | null): boolean { +function looksLikeSyntheticFixtureStorage( + storage: AccountStorageV3 | null, +): boolean { if (!storage || storage.accounts.length === 0) return false; - return storage.accounts.every((account) => looksLikeSyntheticFixtureAccount(account)); + return storage.accounts.every((account) => + looksLikeSyntheticFixtureAccount(account), + ); } async function ensureGitignore(storagePath: string): Promise { - if (!currentStoragePath) return; - - const configDir = dirname(storagePath); - const inferredProjectRoot = dirname(configDir); - const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter( - (root): root is string => typeof root === "string" && root.length > 0, - ); - const projectRoot = candidateRoots.find((root) => existsSync(join(root, ".git"))); - if (!projectRoot) return; - const gitignorePath = join(projectRoot, ".gitignore"); - - try { - let content = ""; - if (existsSync(gitignorePath)) { - content = await fs.readFile(gitignorePath, "utf-8"); - const lines = content.split("\n").map((l) => l.trim()); - if (lines.includes(".codex") || lines.includes(".codex/") || lines.includes("/.codex") || lines.includes("/.codex/")) { - return; - } - } - - const newContent = content.endsWith("\n") || content === "" ? content : content + "\n"; - await fs.writeFile(gitignorePath, newContent + ".codex/\n", "utf-8"); - log.debug("Added .codex to .gitignore", { path: gitignorePath }); - } catch (error) { - log.warn("Failed to update .gitignore", { error: String(error) }); - } + if (!currentStoragePath) return; + + const configDir = dirname(storagePath); + const inferredProjectRoot = dirname(configDir); + const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter( + (root): root is string => typeof root === "string" && root.length > 0, + ); + const projectRoot = candidateRoots.find((root) => + existsSync(join(root, ".git")), + ); + if (!projectRoot) return; + const gitignorePath = join(projectRoot, ".gitignore"); + + try { + let content = ""; + if (existsSync(gitignorePath)) { + content = await fs.readFile(gitignorePath, "utf-8"); + const lines = content.split("\n").map((l) => l.trim()); + if ( + lines.includes(".codex") || + lines.includes(".codex/") || + lines.includes("/.codex") || + lines.includes("/.codex/") + ) { + return; + } + } + + const newContent = + content.endsWith("\n") || content === "" ? content : content + "\n"; + await fs.writeFile(gitignorePath, newContent + ".codex/\n", "utf-8"); + log.debug("Added .codex to .gitignore", { path: gitignorePath }); + } catch (error) { + log.warn("Failed to update .gitignore", { error: String(error) }); + } } let currentStoragePath: string | null = null; @@ -197,7 +230,9 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] { return candidates; } -async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): Promise { +async function getAccountsBackupRecoveryCandidatesWithDiscovery( + path: string, +): Promise { const knownCandidates = getAccountsBackupRecoveryCandidates(path); const discoveredCandidates = new Set(); const candidatePrefix = `${basename(path)}.`; @@ -265,7 +300,10 @@ async function copyFileWithRetry( } } -async function renameFileWithRetry(sourcePath: string, destinationPath: string): Promise { +async function renameFileWithRetry( + sourcePath: string, + destinationPath: string, +): Promise { for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { try { await fs.rename(sourcePath, destinationPath); @@ -280,7 +318,10 @@ async function renameFileWithRetry(sourcePath: string, destinationPath: string): } const jitterMs = Math.floor(Math.random() * BACKUP_COPY_BASE_DELAY_MS); await new Promise((resolve) => - setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs), + setTimeout( + resolve, + BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs, + ), ); } } @@ -301,7 +342,9 @@ async function createRotatingAccountsBackup(path: string): Promise { continue; } const stagedPath = buildStagedPath(currentPath, `slot-${i}`); - await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true }); + await copyFileWithRetry(previousPath, stagedPath, { + allowMissingSource: true, + }); if (existsSync(stagedPath)) { stagedWrites.push({ targetPath: currentPath, stagedPath }); } @@ -314,7 +357,10 @@ async function createRotatingAccountsBackup(path: string): Promise { const latestStagedPath = buildStagedPath(latestBackupPath, "latest"); await copyFileWithRetry(path, latestStagedPath); if (existsSync(latestStagedPath)) { - stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath }); + stagedWrites.push({ + targetPath: latestBackupPath, + stagedPath: latestStagedPath, + }); } for (const stagedWrite of stagedWrites) { @@ -334,9 +380,15 @@ async function createRotatingAccountsBackup(path: string): Promise { } } -function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string): boolean { +function isRotatingBackupTempArtifact( + storagePath: string, + candidatePath: string, +): boolean { const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`; - if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) { + if ( + !candidatePath.startsWith(backupPrefix) || + !candidatePath.endsWith(".tmp") + ) { return false; } @@ -354,10 +406,14 @@ function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string return true; } -async function cleanupStaleRotatingBackupArtifacts(path: string): Promise { +async function cleanupStaleRotatingBackupArtifacts( + path: string, +): Promise { const directoryPath = dirname(path); try { - const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true }); + const directoryEntries = await fs.readdir(directoryPath, { + withFileTypes: true, + }); const staleArtifacts = directoryEntries .filter((entry) => entry.isFile()) .map((entry) => join(directoryPath, entry.name)) @@ -391,6 +447,45 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +function normalizeBackupName(rawName: string): string { + const trimmed = rawName.trim().replace(/\.(json|bak)$/i, ""); + const collapsedWhitespace = trimmed.replace(/\s+/g, "-"); + const sanitized = collapsedWhitespace + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120); + if (!sanitized) { + throw new Error(`Invalid backup name: ${rawName}`); + } + return sanitized; +} + +function getNamedBackupsDirectory(): string { + return join(dirname(getStoragePath()), NAMED_BACKUP_DIRECTORY); +} + +async function ensureNamedBackupsDirectory(): Promise { + const directory = getNamedBackupsDirectory(); + await fs.mkdir(directory, { recursive: true }); + return directory; +} + +function resolveNamedBackupPath(name: string): string { + const normalizedName = normalizeBackupName(name); + return join( + getNamedBackupsDirectory(), + `${normalizedName}${NAMED_BACKUP_EXTENSION}`, + ); +} + +function deriveBackupNameFromFile(fileName: string): string { + if (fileName.endsWith(NAMED_BACKUP_EXTENSION)) { + return fileName.slice(0, -NAMED_BACKUP_EXTENSION.length); + } + return normalizeBackupName(fileName); +} + type AccountsJournalEntry = { version: 1; createdAt: number; @@ -399,44 +494,76 @@ type AccountsJournalEntry = { content: string; }; +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; + skipped: number | null; + wouldExceedLimit: boolean; + valid: boolean; + error?: string; +} + export function getLastAccountsSaveTimestamp(): number { return lastAccountsSaveTimestamp; } export function setStoragePath(projectPath: string | null): void { - if (!projectPath) { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; - return; - } - - const projectRoot = findProjectRoot(projectPath); - if (projectRoot) { - currentProjectRoot = projectRoot; - const identityRoot = resolveProjectStorageIdentityRoot(projectRoot); - currentStoragePath = join(getProjectGlobalConfigDir(identityRoot), ACCOUNTS_FILE_NAME); - currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME); - const previousWorktreeScopedPath = join( - getProjectGlobalConfigDir(projectRoot), - ACCOUNTS_FILE_NAME, - ); - currentLegacyWorktreeStoragePath = - previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null; - } else { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; - } + if (!projectPath) { + currentStoragePath = null; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; + return; + } + + const projectRoot = findProjectRoot(projectPath); + if (projectRoot) { + currentProjectRoot = projectRoot; + const identityRoot = resolveProjectStorageIdentityRoot(projectRoot); + currentStoragePath = join( + getProjectGlobalConfigDir(identityRoot), + ACCOUNTS_FILE_NAME, + ); + currentLegacyProjectStoragePath = join( + getProjectConfigDir(projectRoot), + ACCOUNTS_FILE_NAME, + ); + const previousWorktreeScopedPath = join( + getProjectGlobalConfigDir(projectRoot), + ACCOUNTS_FILE_NAME, + ); + currentLegacyWorktreeStoragePath = + previousWorktreeScopedPath !== currentStoragePath + ? previousWorktreeScopedPath + : null; + } else { + currentStoragePath = null; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; + } } export function setStoragePathDirect(path: string | null): void { - currentStoragePath = path; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; + currentStoragePath = path; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; } /** @@ -444,10 +571,10 @@ export function setStoragePathDirect(path: string | null): void { * @returns Absolute path to the accounts.json file */ export function getStoragePath(): string { - if (currentStoragePath) { - return currentStoragePath; - } - return join(getConfigDir(), ACCOUNTS_FILE_NAME); + if (currentStoragePath) { + return currentStoragePath; + } + return join(getConfigDir(), ACCOUNTS_FILE_NAME); } export function getFlaggedAccountsPath(): string { @@ -459,176 +586,196 @@ function getLegacyFlaggedAccountsPath(): string { } async function migrateLegacyProjectStorageIfNeeded( - persist: (storage: AccountStorageV3) => Promise = saveAccounts, + persist: (storage: AccountStorageV3) => Promise = saveAccounts, ): Promise { - if (!currentStoragePath) { - return null; - } - - const candidatePaths = [currentLegacyWorktreeStoragePath, currentLegacyProjectStoragePath] - .filter( - (path): path is string => typeof path === "string" && path.length > 0 && path !== currentStoragePath, - ) - .filter((path, index, all) => all.indexOf(path) === index); - - if (candidatePaths.length === 0) { - return null; - } - - const existingCandidatePaths = candidatePaths.filter((legacyPath) => existsSync(legacyPath)); - if (existingCandidatePaths.length === 0) { - return null; - } - - let targetStorage = await loadNormalizedStorageFromPath(currentStoragePath, "current account storage"); - let migrated = false; - - for (const legacyPath of existingCandidatePaths) { - const legacyStorage = await loadNormalizedStorageFromPath(legacyPath, "legacy account storage"); - if (!legacyStorage) { - continue; - } - - const mergedStorage = mergeStorageForMigration(targetStorage, legacyStorage); - const fallbackStorage = targetStorage ?? legacyStorage; - - try { - await persist(mergedStorage); - targetStorage = mergedStorage; - migrated = true; - } catch (error) { - targetStorage = fallbackStorage; - log.warn("Failed to persist migrated account storage", { - from: legacyPath, - to: currentStoragePath, - error: String(error), - }); - continue; - } - - try { - await fs.unlink(legacyPath); - log.info("Removed legacy account storage file after migration", { - path: legacyPath, - }); - } catch (unlinkError) { - const code = (unlinkError as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to remove legacy account storage file after migration", { - path: legacyPath, - error: String(unlinkError), - }); - } - } - - log.info("Migrated legacy project account storage", { - from: legacyPath, - to: currentStoragePath, - accounts: mergedStorage.accounts.length, - }); - } - - if (migrated) { - return targetStorage; - } - if (targetStorage && !existsSync(currentStoragePath)) { - return targetStorage; - } - return null; + if (!currentStoragePath) { + return null; + } + + const candidatePaths = [ + currentLegacyWorktreeStoragePath, + currentLegacyProjectStoragePath, + ] + .filter( + (path): path is string => + typeof path === "string" && + path.length > 0 && + path !== currentStoragePath, + ) + .filter((path, index, all) => all.indexOf(path) === index); + + if (candidatePaths.length === 0) { + return null; + } + + const existingCandidatePaths = candidatePaths.filter((legacyPath) => + existsSync(legacyPath), + ); + if (existingCandidatePaths.length === 0) { + return null; + } + + let targetStorage = await loadNormalizedStorageFromPath( + currentStoragePath, + "current account storage", + ); + let migrated = false; + + for (const legacyPath of existingCandidatePaths) { + const legacyStorage = await loadNormalizedStorageFromPath( + legacyPath, + "legacy account storage", + ); + if (!legacyStorage) { + continue; + } + + const mergedStorage = mergeStorageForMigration( + targetStorage, + legacyStorage, + ); + const fallbackStorage = targetStorage ?? legacyStorage; + + try { + await persist(mergedStorage); + targetStorage = mergedStorage; + migrated = true; + } catch (error) { + targetStorage = fallbackStorage; + log.warn("Failed to persist migrated account storage", { + from: legacyPath, + to: currentStoragePath, + error: String(error), + }); + continue; + } + + try { + await fs.unlink(legacyPath); + log.info("Removed legacy account storage file after migration", { + path: legacyPath, + }); + } catch (unlinkError) { + const code = (unlinkError as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn( + "Failed to remove legacy account storage file after migration", + { + path: legacyPath, + error: String(unlinkError), + }, + ); + } + } + + log.info("Migrated legacy project account storage", { + from: legacyPath, + to: currentStoragePath, + accounts: mergedStorage.accounts.length, + }); + } + + if (migrated) { + return targetStorage; + } + if (targetStorage && !existsSync(currentStoragePath)) { + return targetStorage; + } + return null; } async function loadNormalizedStorageFromPath( - path: string, - label: string, + path: string, + label: string, ): Promise { - try { - const { normalized, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn(`${label} schema validation warnings`, { - path, - errors: schemaErrors.slice(0, 5), - }); - } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn(`Failed to load ${label}`, { - path, - error: String(error), - }); - } - return null; - } + try { + const { normalized, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn(`${label} schema validation warnings`, { + path, + errors: schemaErrors.slice(0, 5), + }); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn(`Failed to load ${label}`, { + path, + error: String(error), + }); + } + return null; + } } function mergeStorageForMigration( - current: AccountStorageV3 | null, - incoming: AccountStorageV3, + current: AccountStorageV3 | null, + incoming: AccountStorageV3, ): AccountStorageV3 { - if (!current) { - return incoming; - } - - const merged = normalizeAccountStorage({ - version: 3, - activeIndex: current.activeIndex, - activeIndexByFamily: current.activeIndexByFamily, - accounts: [...current.accounts, ...incoming.accounts], - }); - if (!merged) { - return current; - } - return merged; + if (!current) { + return incoming; + } + + const merged = normalizeAccountStorage({ + version: 3, + activeIndex: current.activeIndex, + activeIndexByFamily: current.activeIndexByFamily, + accounts: [...current.accounts, ...incoming.accounts], + }); + if (!merged) { + return current; + } + return merged; } function selectNewestAccount( - current: T | undefined, - candidate: T, + current: T | undefined, + candidate: T, ): T { - if (!current) return candidate; - const currentLastUsed = current.lastUsed || 0; - const candidateLastUsed = candidate.lastUsed || 0; - if (candidateLastUsed > currentLastUsed) return candidate; - if (candidateLastUsed < currentLastUsed) return current; - const currentAddedAt = current.addedAt || 0; - const candidateAddedAt = candidate.addedAt || 0; - return candidateAddedAt >= currentAddedAt ? candidate : current; + if (!current) return candidate; + const currentLastUsed = current.lastUsed || 0; + const candidateLastUsed = candidate.lastUsed || 0; + if (candidateLastUsed > currentLastUsed) return candidate; + if (candidateLastUsed < currentLastUsed) return current; + const currentAddedAt = current.addedAt || 0; + const candidateAddedAt = candidate.addedAt || 0; + return candidateAddedAt >= currentAddedAt ? candidate : current; } function deduplicateAccountsByKey(accounts: T[]): T[] { - const keyToIndex = new Map(); - const indicesToKeep = new Set(); - - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - const key = account.accountId || account.refreshToken; - if (!key) continue; - - const existingIndex = keyToIndex.get(key); - if (existingIndex === undefined) { - keyToIndex.set(key, i); - continue; - } - - const existing = accounts[existingIndex]; - const newest = selectNewestAccount(existing, account); - keyToIndex.set(key, newest === account ? i : existingIndex); - } - - for (const idx of keyToIndex.values()) { - indicesToKeep.add(idx); - } - - const result: T[] = []; - for (let i = 0; i < accounts.length; i += 1) { - if (indicesToKeep.has(i)) { - const account = accounts[i]; - if (account) result.push(account); - } - } - return result; + const keyToIndex = new Map(); + const indicesToKeep = new Set(); + + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + const key = account.accountId || account.refreshToken; + if (!key) continue; + + const existingIndex = keyToIndex.get(key); + if (existingIndex === undefined) { + keyToIndex.set(key, i); + continue; + } + + const existing = accounts[existingIndex]; + const newest = selectNewestAccount(existing, account); + keyToIndex.set(key, newest === account ? i : existingIndex); + } + + for (const idx of keyToIndex.values()) { + indicesToKeep.add(idx); + } + + const result: T[] = []; + for (let i = 0; i < accounts.length; i += 1) { + if (indicesToKeep.has(i)) { + const account = accounts[i]; + if (account) result.push(account); + } + } + return result; } /** @@ -637,10 +784,15 @@ function deduplicateAccountsByKey(accounts: T[]): T[] { * @param accounts - Array of accounts to deduplicate * @returns New array with duplicates removed */ -export function deduplicateAccounts( - accounts: T[], -): T[] { - return deduplicateAccountsByKey(accounts); +export function deduplicateAccounts< + T extends { + accountId?: string; + refreshToken: string; + lastUsed?: number; + addedAt?: number; + }, +>(accounts: T[]): T[] { + return deduplicateAccountsByKey(accounts); } /** @@ -649,98 +801,105 @@ export function deduplicateAccounts( - accounts: T[], -): T[] { - - const emailToNewestIndex = new Map(); - const indicesToKeep = new Set(); - - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - - const email = normalizeEmailKey(account.email); - if (!email) { - indicesToKeep.add(i); - continue; - } - - const existingIndex = emailToNewestIndex.get(email); - if (existingIndex === undefined) { - emailToNewestIndex.set(email, i); - continue; - } - - const existing = accounts[existingIndex]; - // istanbul ignore next -- defensive code: existingIndex always refers to valid account - if (!existing) { - emailToNewestIndex.set(email, i); - continue; - } - - const existingLastUsed = existing.lastUsed || 0; - const candidateLastUsed = account.lastUsed || 0; - const existingAddedAt = existing.addedAt || 0; - const candidateAddedAt = account.addedAt || 0; - - const isNewer = - candidateLastUsed > existingLastUsed || - (candidateLastUsed === existingLastUsed && candidateAddedAt > existingAddedAt); - - if (isNewer) { - emailToNewestIndex.set(email, i); - } - } - - for (const idx of emailToNewestIndex.values()) { - indicesToKeep.add(idx); - } - - const result: T[] = []; - for (let i = 0; i < accounts.length; i += 1) { - if (indicesToKeep.has(i)) { - const account = accounts[i]; - if (account) result.push(account); - } - } - return result; +export function normalizeEmailKey( + email: string | undefined, +): string | undefined { + if (!email) return undefined; + const trimmed = email.trim(); + if (!trimmed) return undefined; + return trimmed.toLowerCase(); +} + +export function deduplicateAccountsByEmail< + T extends { email?: string; lastUsed?: number; addedAt?: number }, +>(accounts: T[]): T[] { + const emailToNewestIndex = new Map(); + const indicesToKeep = new Set(); + + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + + const email = normalizeEmailKey(account.email); + if (!email) { + indicesToKeep.add(i); + continue; + } + + const existingIndex = emailToNewestIndex.get(email); + if (existingIndex === undefined) { + emailToNewestIndex.set(email, i); + continue; + } + + const existing = accounts[existingIndex]; + // istanbul ignore next -- defensive code: existingIndex always refers to valid account + if (!existing) { + emailToNewestIndex.set(email, i); + continue; + } + + const existingLastUsed = existing.lastUsed || 0; + const candidateLastUsed = account.lastUsed || 0; + const existingAddedAt = existing.addedAt || 0; + const candidateAddedAt = account.addedAt || 0; + + const isNewer = + candidateLastUsed > existingLastUsed || + (candidateLastUsed === existingLastUsed && + candidateAddedAt > existingAddedAt); + + if (isNewer) { + emailToNewestIndex.set(email, i); + } + } + + for (const idx of emailToNewestIndex.values()) { + indicesToKeep.add(idx); + } + + const result: T[] = []; + for (let i = 0; i < accounts.length; i += 1) { + if (indicesToKeep.has(i)) { + const account = accounts[i]; + if (account) result.push(account); + } + } + return result; } function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); + return !!value && typeof value === "object" && !Array.isArray(value); } function clampIndex(index: number, length: number): number { - if (length <= 0) return 0; - return Math.max(0, Math.min(index, length - 1)); + if (length <= 0) return 0; + return Math.max(0, Math.min(index, length - 1)); } -function toAccountKey(account: Pick): string { - return account.accountId || account.refreshToken; +function toAccountKey( + account: Pick, +): string { + return account.accountId || account.refreshToken; } -function extractActiveKey(accounts: unknown[], activeIndex: number): string | undefined { - const candidate = accounts[activeIndex]; - if (!isRecord(candidate)) return undefined; +function extractActiveKey( + accounts: unknown[], + activeIndex: number, +): string | undefined { + const candidate = accounts[activeIndex]; + if (!isRecord(candidate)) return undefined; - const accountId = - typeof candidate.accountId === "string" && candidate.accountId.trim() - ? candidate.accountId - : undefined; - const refreshToken = - typeof candidate.refreshToken === "string" && candidate.refreshToken.trim() - ? candidate.refreshToken - : undefined; + const accountId = + typeof candidate.accountId === "string" && candidate.accountId.trim() + ? candidate.accountId + : undefined; + const refreshToken = + typeof candidate.refreshToken === "string" && candidate.refreshToken.trim() + ? candidate.refreshToken + : undefined; - return accountId || refreshToken; + return accountId || refreshToken; } /** @@ -749,95 +908,99 @@ function extractActiveKey(accounts: unknown[], activeIndex: number): string | un * @param data - Raw storage data (unknown format) * @returns Normalized AccountStorageV3 or null if invalid */ -export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null { - if (!isRecord(data)) { - log.warn("Invalid storage format, ignoring"); - return null; - } - - if (data.version !== 1 && data.version !== 3) { - log.warn("Unknown storage version, ignoring", { - version: (data as { version?: unknown }).version, - }); - return null; - } - - const rawAccounts = data.accounts; - if (!Array.isArray(rawAccounts)) { - log.warn("Invalid storage format, ignoring"); - return null; - } - - const activeIndexValue = - typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) - ? data.activeIndex - : 0; - - const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length); - const activeKey = extractActiveKey(rawAccounts, rawActiveIndex); - - const fromVersion = data.version as AnyAccountStorage["version"]; - const baseStorage: AccountStorageV3 = - fromVersion === 1 - ? migrateV1ToV3(data as unknown as AccountStorageV1) - : (data as unknown as AccountStorageV3); - - const validAccounts = rawAccounts.filter( - (account): account is AccountMetadataV3 => - isRecord(account) && typeof account.refreshToken === "string" && !!account.refreshToken.trim(), - ); - - const deduplicatedAccounts = deduplicateAccountsByEmail( - deduplicateAccountsByKey(validAccounts), - ); - - const activeIndex = (() => { - if (deduplicatedAccounts.length === 0) return 0; - - if (activeKey) { - const mappedIndex = deduplicatedAccounts.findIndex( - (account) => toAccountKey(account) === activeKey, - ); - if (mappedIndex >= 0) return mappedIndex; - } - - return clampIndex(rawActiveIndex, deduplicatedAccounts.length); - })(); - - const activeIndexByFamily: Partial> = {}; - const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily) - ? (baseStorage.activeIndexByFamily as Record) - : {}; - - for (const family of MODEL_FAMILIES) { - const rawIndexValue = rawFamilyIndices[family]; - const rawIndex = - typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) - ? rawIndexValue - : rawActiveIndex; - - const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length); - const familyKey = extractActiveKey(rawAccounts, clampedRawIndex); - - let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length); - if (familyKey && deduplicatedAccounts.length > 0) { - const idx = deduplicatedAccounts.findIndex( - (account) => toAccountKey(account) === familyKey, - ); - if (idx >= 0) { - mappedIndex = idx; - } - } - - activeIndexByFamily[family] = mappedIndex; - } - - return { - version: 3, - accounts: deduplicatedAccounts, - activeIndex, - activeIndexByFamily, - }; +export function normalizeAccountStorage( + data: unknown, +): AccountStorageV3 | null { + if (!isRecord(data)) { + log.warn("Invalid storage format, ignoring"); + return null; + } + + if (data.version !== 1 && data.version !== 3) { + log.warn("Unknown storage version, ignoring", { + version: (data as { version?: unknown }).version, + }); + return null; + } + + const rawAccounts = data.accounts; + if (!Array.isArray(rawAccounts)) { + log.warn("Invalid storage format, ignoring"); + return null; + } + + const activeIndexValue = + typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) + ? data.activeIndex + : 0; + + const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length); + const activeKey = extractActiveKey(rawAccounts, rawActiveIndex); + + const fromVersion = data.version as AnyAccountStorage["version"]; + const baseStorage: AccountStorageV3 = + fromVersion === 1 + ? migrateV1ToV3(data as unknown as AccountStorageV1) + : (data as unknown as AccountStorageV3); + + const validAccounts = rawAccounts.filter( + (account): account is AccountMetadataV3 => + isRecord(account) && + typeof account.refreshToken === "string" && + !!account.refreshToken.trim(), + ); + + const deduplicatedAccounts = deduplicateAccountsByEmail( + deduplicateAccountsByKey(validAccounts), + ); + + const activeIndex = (() => { + if (deduplicatedAccounts.length === 0) return 0; + + if (activeKey) { + const mappedIndex = deduplicatedAccounts.findIndex( + (account) => toAccountKey(account) === activeKey, + ); + if (mappedIndex >= 0) return mappedIndex; + } + + return clampIndex(rawActiveIndex, deduplicatedAccounts.length); + })(); + + const activeIndexByFamily: Partial> = {}; + const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily) + ? (baseStorage.activeIndexByFamily as Record) + : {}; + + for (const family of MODEL_FAMILIES) { + const rawIndexValue = rawFamilyIndices[family]; + const rawIndex = + typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) + ? rawIndexValue + : rawActiveIndex; + + const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length); + const familyKey = extractActiveKey(rawAccounts, clampedRawIndex); + + let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length); + if (familyKey && deduplicatedAccounts.length > 0) { + const idx = deduplicatedAccounts.findIndex( + (account) => toAccountKey(account) === familyKey, + ); + if (idx >= 0) { + mappedIndex = idx; + } + } + + activeIndexByFamily[family] = mappedIndex; + } + + return { + version: 3, + accounts: deduplicatedAccounts, + activeIndex, + activeIndexByFamily, + }; } /** @@ -846,7 +1009,7 @@ export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null * @returns AccountStorageV3 if file exists and is valid, null otherwise */ export async function loadAccounts(): Promise { - return loadAccountsInternal(saveAccounts); + return loadAccountsInternal(saveAccounts); } function parseAndNormalizeStorage(data: unknown): { @@ -856,7 +1019,9 @@ function parseAndNormalizeStorage(data: unknown): { } { const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data); const normalized = normalizeAccountStorage(data); - const storedVersion = isRecord(data) ? (data as { version?: unknown }).version : undefined; + const storedVersion = isRecord(data) + ? (data as { version?: unknown }).version + : undefined; return { normalized, storedVersion, schemaErrors }; } @@ -870,7 +1035,27 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } -async function loadAccountsFromJournal(path: string): Promise { +async function loadBackupCandidate(path: string): Promise<{ + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}> { + try { + return await loadAccountsFromPath(path); + } catch (error) { + return { + normalized: null, + storedVersion: undefined, + schemaErrors: [], + error: String(error), + }; + } +} + +async function loadAccountsFromJournal( + path: string, +): Promise { const walPath = getAccountsWalPath(path); try { const raw = await fs.readFile(walPath, "utf-8"); @@ -878,7 +1063,8 @@ async function loadAccountsFromJournal(path: string): Promise; if (entry.version !== 1) return null; - if (typeof entry.content !== "string" || typeof entry.checksum !== "string") return null; + if (typeof entry.content !== "string" || typeof entry.checksum !== "string") + return null; const computed = computeSha256(entry.content); if (computed !== entry.checksum) { log.warn("Account journal checksum mismatch", { path: walPath }); @@ -892,14 +1078,17 @@ async function loadAccountsFromJournal(path: string): Promise Promise) | null, + persistMigration: ((storage: AccountStorageV3) => Promise) | null, ): Promise { const path = getStoragePath(); await cleanupStaleRotatingBackupArtifacts(path); @@ -907,249 +1096,429 @@ async function loadAccountsInternal( ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; - try { - const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); - } - if (normalized && storedVersion !== normalized.version) { - log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); - if (persistMigration) { - try { - await persistMigration(normalized); - } catch (saveError) { - log.warn("Failed to persist migrated storage", { error: String(saveError) }); - } - } - } - - const primaryLooksSynthetic = looksLikeSyntheticFixtureStorage(normalized); - if (storageBackupEnabled && normalized && primaryLooksSynthetic) { - const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const backupPath of backupCandidates) { - if (backupPath === path) continue; - try { - const backup = await loadAccountsFromPath(backupPath); - if (!backup.normalized) continue; - if (looksLikeSyntheticFixtureStorage(backup.normalized)) continue; - if (backup.normalized.accounts.length <= 0) continue; - log.warn("Detected synthetic primary account storage; promoting backup", { - path, - backupPath, - primaryAccounts: normalized.accounts.length, - backupAccounts: backup.normalized.accounts.length, - }); - if (persistMigration) { - try { - await persistMigration(backup.normalized); - } catch (persistError) { - log.warn("Failed to persist promoted backup storage", { - path, - error: String(persistError), - }); - } - } - return backup.normalized; - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load candidate backup for synthetic-primary promotion", { - path: backupPath, - error: String(backupError), + try { + const { normalized, storedVersion, schemaErrors } = + await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { + errors: schemaErrors.slice(0, 5), + }); + } + if (normalized && storedVersion !== normalized.version) { + log.info("Migrating account storage to v3", { + from: storedVersion, + to: normalized.version, + }); + if (persistMigration) { + try { + await persistMigration(normalized); + } catch (saveError) { + log.warn("Failed to persist migrated storage", { + error: String(saveError), }); } } } - } - - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" && migratedLegacyStorage) { - return migratedLegacyStorage; - } - - const recoveredFromWal = await loadAccountsFromJournal(path); - if (recoveredFromWal) { - if (persistMigration) { - try { - await persistMigration(recoveredFromWal); - } catch (persistError) { - log.warn("Failed to persist WAL-recovered storage", { - path, - error: String(persistError), - }); - } - } - return recoveredFromWal; - } - if (storageBackupEnabled) { - const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const backupPath of backupCandidates) { - try { - const backup = await loadAccountsFromPath(backupPath); - if (backup.schemaErrors.length > 0) { - log.warn("Backup account storage schema validation warnings", { - path: backupPath, - errors: backup.schemaErrors.slice(0, 5), - }); - } - if (backup.normalized) { - log.warn("Recovered account storage from backup file", { path, backupPath }); + const primaryLooksSynthetic = looksLikeSyntheticFixtureStorage(normalized); + if (storageBackupEnabled && normalized && primaryLooksSynthetic) { + const backupCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const backupPath of backupCandidates) { + if (backupPath === path) continue; + try { + const backup = await loadAccountsFromPath(backupPath); + if (!backup.normalized) continue; + if (looksLikeSyntheticFixtureStorage(backup.normalized)) continue; + if (backup.normalized.accounts.length <= 0) continue; + log.warn( + "Detected synthetic primary account storage; promoting backup", + { + path, + backupPath, + primaryAccounts: normalized.accounts.length, + backupAccounts: backup.normalized.accounts.length, + }, + ); if (persistMigration) { try { await persistMigration(backup.normalized); } catch (persistError) { - log.warn("Failed to persist recovered backup storage", { + log.warn("Failed to persist promoted backup storage", { path, error: String(persistError), }); } } return backup.normalized; + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn( + "Failed to load candidate backup for synthetic-primary promotion", + { + path: backupPath, + error: String(backupError), + }, + ); + } } - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load backup account storage", { - path: backupPath, - error: String(backupError), + } + } + + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" && migratedLegacyStorage) { + return migratedLegacyStorage; + } + + const recoveredFromWal = await loadAccountsFromJournal(path); + if (recoveredFromWal) { + if (persistMigration) { + try { + await persistMigration(recoveredFromWal); + } catch (persistError) { + log.warn("Failed to persist WAL-recovered storage", { + path, + error: String(persistError), }); } } + return recoveredFromWal; + } + + if (storageBackupEnabled) { + const backupCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const backupPath of backupCandidates) { + try { + const backup = await loadAccountsFromPath(backupPath); + if (backup.schemaErrors.length > 0) { + log.warn("Backup account storage schema validation warnings", { + path: backupPath, + errors: backup.schemaErrors.slice(0, 5), + }); + } + if (backup.normalized) { + log.warn("Recovered account storage from backup file", { + path, + backupPath, + }); + if (persistMigration) { + try { + await persistMigration(backup.normalized); + } catch (persistError) { + log.warn("Failed to persist recovered backup storage", { + path, + error: String(persistError), + }); + } + } + return backup.normalized; + } + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn("Failed to load backup account storage", { + path: backupPath, + error: String(backupError), + }); + } + } + } + } + + if (code !== "ENOENT") { + log.error("Failed to load account storage", { error: String(error) }); + } + return null; + } +} + +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 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) }); } } - if (code !== "ENOENT") { - log.error("Failed to load account storage", { error: String(error) }); - } - return null; - } + 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, + }; +} + +export async function listNamedBackups(): Promise { + const backupDir = getNamedBackupsDirectory(); + try { + const entries = await fs.readdir(backupDir, { withFileTypes: true }); + const backups: NamedBackupMetadata[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(NAMED_BACKUP_EXTENSION)) continue; + + const name = deriveBackupNameFromFile(entry.name); + const path = join(backupDir, entry.name); + const candidate = await loadBackupCandidate(path); + backups.push(await buildNamedBackupMetadata(name, path, { candidate })); + } + + return backups.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to list named backups", { + path: backupDir, + error: String(error), + }); + } + return []; + } +} + +export function getNamedBackupsDirectoryPath(): string { + return getNamedBackupsDirectory(); +} + +export async function createNamedBackup( + name: string, + options: { force?: boolean } = {}, +): Promise { + const normalizedName = normalizeBackupName(name); + const backupPath = resolveNamedBackupPath(normalizedName); + await ensureNamedBackupsDirectory(); + await exportAccounts(backupPath, options.force ?? false); + const candidate = await loadBackupCandidate(backupPath); + return buildNamedBackupMetadata(normalizedName, backupPath, { candidate }); +} + +export async function assessNamedBackupRestore( + name: string, + options: { currentStorage?: AccountStorageV3 | null } = {}, +): Promise { + const normalizedName = normalizeBackupName(name); + const backupPath = resolveNamedBackupPath(normalizedName); + const candidate = await loadBackupCandidate(backupPath); + const backup = await buildNamedBackupMetadata(normalizedName, backupPath, { + candidate, + }); + const currentStorage = options.currentStorage ?? (await loadAccounts()); + const currentAccounts = currentStorage?.accounts ?? []; + + if ( + !candidate.normalized || + !backup.accountCount || + backup.accountCount <= 0 + ) { + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: null, + imported: null, + skipped: null, + wouldExceedLimit: false, + valid: false, + error: backup.loadError ?? "Backup is empty or invalid", + }; + } + + const mergedAccounts = deduplicateAccountsByEmail( + deduplicateAccounts([...currentAccounts, ...candidate.normalized.accounts]), + ); + const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; + const imported = wouldExceedLimit + ? null + : mergedAccounts.length - currentAccounts.length; + const skipped = wouldExceedLimit + ? null + : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + + return { + backup, + currentAccountCount: currentAccounts.length, + mergedAccountCount: mergedAccounts.length, + imported, + skipped, + wouldExceedLimit, + valid: !wouldExceedLimit, + error: wouldExceedLimit + ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` + : undefined, + }; +} + +export async function restoreNamedBackup( + name: string, +): Promise<{ imported: number; total: number; skipped: number }> { + const normalizedName = normalizeBackupName(name); + const backupPath = resolveNamedBackupPath(normalizedName); + return importAccounts(backupPath); } async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { - const path = getStoragePath(); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; - const walPath = getAccountsWalPath(path); + const path = getStoragePath(); + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${path}.${uniqueSuffix}.tmp`; + const walPath = getAccountsWalPath(path); - try { - await fs.mkdir(dirname(path), { recursive: true }); - await ensureGitignore(path); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await ensureGitignore(path); - if (looksLikeSyntheticFixtureStorage(storage)) { - try { - const existing = await loadNormalizedStorageFromPath(path, "existing account storage"); - if (existing && existing.accounts.length > 0 && !looksLikeSyntheticFixtureStorage(existing)) { - throw new StorageError( - "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", - "EINVALID", + if (looksLikeSyntheticFixtureStorage(storage)) { + try { + const existing = await loadNormalizedStorageFromPath( path, - "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", + "existing account storage", ); + if ( + existing && + existing.accounts.length > 0 && + !looksLikeSyntheticFixtureStorage(existing) + ) { + throw new StorageError( + "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", + "EINVALID", + path, + "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", + ); + } + } catch (error) { + if (error instanceof StorageError) { + throw error; + } + // Ignore existing-file probe failures and continue with normal save flow. } - } catch (error) { - if (error instanceof StorageError) { - throw error; + } + + if (storageBackupEnabled && existsSync(path)) { + try { + await createRotatingAccountsBackup(path); + } catch (backupError) { + log.warn("Failed to create account storage backup", { + path, + backupPath: getAccountsBackupPath(path), + error: String(backupError), + }); } - // Ignore existing-file probe failures and continue with normal save flow. } - } - if (storageBackupEnabled && existsSync(path)) { - try { - await createRotatingAccountsBackup(path); - } catch (backupError) { - log.warn("Failed to create account storage backup", { - path, - backupPath: getAccountsBackupPath(path), - error: String(backupError), - }); + const content = JSON.stringify(storage, null, 2); + const journalEntry: AccountsJournalEntry = { + version: 1, + createdAt: Date.now(), + path, + checksum: computeSha256(content), + content, + }; + await fs.writeFile(walPath, JSON.stringify(journalEntry), { + encoding: "utf-8", + mode: 0o600, + }); + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + + const stats = await fs.stat(tempPath); + if (stats.size === 0) { + const emptyError = Object.assign( + new Error("File written but size is 0"), + { code: "EEMPTY" }, + ); + throw emptyError; } - } - const content = JSON.stringify(storage, null, 2); - const journalEntry: AccountsJournalEntry = { - version: 1, - createdAt: Date.now(), - path, - checksum: computeSha256(content), - content, - }; - await fs.writeFile(walPath, JSON.stringify(journalEntry), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); - - const stats = await fs.stat(tempPath); - if (stats.size === 0) { - const emptyError = Object.assign(new Error("File written but size is 0"), { code: "EEMPTY" }); - 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); - lastAccountsSaveTimestamp = Date.now(); + // 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); + 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; + } + } + if (lastError) throw lastError; + } catch (error) { try { - await fs.unlink(walPath); + await fs.unlink(tempPath); } catch { - // Best effort cleanup. + // Ignore cleanup failure. } - 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 * Math.pow(2, attempt))); - continue; - } - throw renameError; - } - } - if (lastError) throw lastError; - } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failure. - } - - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const hint = formatStorageErrorHint(error, path); - - log.error("Failed to save accounts", { - path, - code, - message: err?.message, - hint, - }); - - throw new StorageError( - `Failed to save accounts: ${err?.message || "Unknown error"}`, - code, - path, - hint, - err instanceof Error ? err : undefined - ); - } + + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const hint = formatStorageErrorHint(error, path); + + log.error("Failed to save accounts", { + path, + code, + message: err?.message, + hint, + }); + + throw new StorageError( + `Failed to save accounts: ${err?.message || "Unknown error"}`, + code, + path, + hint, + err instanceof Error ? err : undefined, + ); + } } export async function withAccountStorageTransaction( - handler: ( - current: AccountStorageV3 | null, - persist: (storage: AccountStorageV3) => Promise, - ) => Promise, + handler: ( + current: AccountStorageV3 | null, + persist: (storage: AccountStorageV3) => Promise, + ) => Promise, ): Promise { - return withStorageLock(async () => { - const current = await loadAccountsInternal(saveAccountsUnlocked); - return handler(current, saveAccountsUnlocked); - }); + return withStorageLock(async () => { + const current = await loadAccountsInternal(saveAccountsUnlocked); + return handler(current, saveAccountsUnlocked); + }); } /** @@ -1160,9 +1529,9 @@ export async function withAccountStorageTransaction( * @throws StorageError with platform-aware hints on failure */ export async function saveAccounts(storage: AccountStorageV3): Promise { - return withStorageLock(async () => { - await saveAccountsUnlocked(storage); - }); + return withStorageLock(async () => { + await saveAccountsUnlocked(storage); + }); } /** @@ -1170,30 +1539,34 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Silently ignores if file doesn't exist. */ export async function clearAccounts(): Promise { - return withStorageLock(async () => { - const path = getStoragePath(); - const walPath = getAccountsWalPath(path); - const backupPaths = getAccountsBackupRecoveryCandidates(path); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; - - try { - await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } - }); + return withStorageLock(async () => { + const path = getStoragePath(); + const walPath = getAccountsWalPath(path); + const backupPaths = getAccountsBackupRecoveryCandidates(path); + const clearPath = async (targetPath: string): Promise => { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear account storage artifact", { + path: targetPath, + error: String(error), + }); + } + } + }; + + try { + await Promise.all([ + clearPath(path), + clearPath(walPath), + ...backupPaths.map(clearPath), + ]); + } catch { + // Individual path cleanup is already best-effort with per-artifact logging. + } + }); } function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { @@ -1205,14 +1578,22 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { for (const rawAccount of data.accounts) { if (!isRecord(rawAccount)) continue; const refreshToken = - typeof rawAccount.refreshToken === "string" ? rawAccount.refreshToken.trim() : ""; + typeof rawAccount.refreshToken === "string" + ? rawAccount.refreshToken.trim() + : ""; if (!refreshToken) continue; - const flaggedAt = typeof rawAccount.flaggedAt === "number" ? rawAccount.flaggedAt : Date.now(); + const flaggedAt = + typeof rawAccount.flaggedAt === "number" + ? rawAccount.flaggedAt + : Date.now(); const isAccountIdSource = ( value: unknown, ): value is AccountMetadataV3["accountIdSource"] => - value === "token" || value === "id_token" || value === "org" || value === "manual"; + value === "token" || + value === "id_token" || + value === "org" || + value === "manual"; const isSwitchReason = ( value: unknown, ): value is AccountMetadataV3["lastSwitchReason"] => @@ -1220,12 +1601,18 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { const isCooldownReason = ( value: unknown, ): value is AccountMetadataV3["cooldownReason"] => - value === "auth-failure" || value === "network-error" || value === "rate-limit"; + value === "auth-failure" || + value === "network-error" || + value === "rate-limit"; - let rateLimitResetTimes: AccountMetadataV3["rateLimitResetTimes"] | undefined; + let rateLimitResetTimes: + | AccountMetadataV3["rateLimitResetTimes"] + | undefined; if (isRecord(rawAccount.rateLimitResetTimes)) { const normalizedRateLimits: Record = {}; - for (const [key, value] of Object.entries(rawAccount.rateLimitResetTimes)) { + for (const [key, value] of Object.entries( + rawAccount.rateLimitResetTimes, + )) { if (typeof value === "number") { normalizedRateLimits[key] = value; } @@ -1247,21 +1634,43 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { const normalized: FlaggedAccountMetadataV1 = { refreshToken, - addedAt: typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, - lastUsed: typeof rawAccount.lastUsed === "number" ? rawAccount.lastUsed : flaggedAt, - accountId: typeof rawAccount.accountId === "string" ? rawAccount.accountId : undefined, + addedAt: + typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, + lastUsed: + typeof rawAccount.lastUsed === "number" + ? rawAccount.lastUsed + : flaggedAt, + accountId: + typeof rawAccount.accountId === "string" + ? rawAccount.accountId + : undefined, accountIdSource, - accountLabel: typeof rawAccount.accountLabel === "string" ? rawAccount.accountLabel : undefined, - email: typeof rawAccount.email === "string" ? rawAccount.email : undefined, - enabled: typeof rawAccount.enabled === "boolean" ? rawAccount.enabled : undefined, + accountLabel: + typeof rawAccount.accountLabel === "string" + ? rawAccount.accountLabel + : undefined, + email: + typeof rawAccount.email === "string" ? rawAccount.email : undefined, + enabled: + typeof rawAccount.enabled === "boolean" + ? rawAccount.enabled + : undefined, lastSwitchReason, rateLimitResetTimes, coolingDownUntil: - typeof rawAccount.coolingDownUntil === "number" ? rawAccount.coolingDownUntil : undefined, + typeof rawAccount.coolingDownUntil === "number" + ? rawAccount.coolingDownUntil + : undefined, cooldownReason, flaggedAt, - flaggedReason: typeof rawAccount.flaggedReason === "string" ? rawAccount.flaggedReason : undefined, - lastError: typeof rawAccount.lastError === "string" ? rawAccount.lastError : undefined, + flaggedReason: + typeof rawAccount.flaggedReason === "string" + ? rawAccount.flaggedReason + : undefined, + lastError: + typeof rawAccount.lastError === "string" + ? rawAccount.lastError + : undefined, }; byRefreshToken.set(refreshToken, normalized); } @@ -1283,7 +1692,10 @@ export async function loadFlaggedAccounts(): Promise { } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { - log.error("Failed to load flagged account storage", { path, error: String(error) }); + log.error("Failed to load flagged account storage", { + path, + error: String(error), + }); return empty; } } @@ -1321,7 +1733,9 @@ export async function loadFlaggedAccounts(): Promise { } } -export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { +export async function saveFlaggedAccounts( + storage: FlaggedAccountStorageV1, +): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; @@ -1338,7 +1752,10 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro } catch { // Ignore cleanup failures. } - log.error("Failed to save flagged account storage", { path, error: String(error) }); + log.error("Failed to save flagged account storage", { + path, + error: String(error), + }); throw error; } }); @@ -1351,7 +1768,9 @@ export async function clearFlaggedAccounts(): Promise { } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { error: String(error) }); + log.error("Failed to clear flagged account storage", { + error: String(error), + }); } } }); @@ -1363,23 +1782,31 @@ export async function clearFlaggedAccounts(): Promise { * @param force - If true, overwrite existing file (default: true) * @throws Error if file exists and force is false, or if no accounts to export */ -export async function exportAccounts(filePath: string, force = true): Promise { - const resolvedPath = resolvePath(filePath); - - if (!force && existsSync(resolvedPath)) { - throw new Error(`File already exists: ${resolvedPath}`); - } - - const storage = await withAccountStorageTransaction((current) => Promise.resolve(current)); - if (!storage || storage.accounts.length === 0) { - throw new Error("No accounts to export"); - } - - await fs.mkdir(dirname(resolvedPath), { recursive: true }); - - const content = JSON.stringify(storage, null, 2); - await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 }); - log.info("Exported accounts", { path: resolvedPath, count: storage.accounts.length }); +export async function exportAccounts( + filePath: string, + force = true, +): Promise { + const resolvedPath = resolvePath(filePath); + + if (!force && existsSync(resolvedPath)) { + throw new Error(`File already exists: ${resolvedPath}`); + } + + const storage = await withAccountStorageTransaction((current) => + Promise.resolve(current), + ); + if (!storage || storage.accounts.length === 0) { + throw new Error("No accounts to export"); + } + + await fs.mkdir(dirname(resolvedPath), { recursive: true }); + + const content = JSON.stringify(storage, null, 2); + await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 }); + log.info("Exported accounts", { + path: resolvedPath, + count: storage.accounts.length, + }); } /** @@ -1388,61 +1815,73 @@ export async function exportAccounts(filePath: string, force = true): Promise { - 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 = deduplicateAccountsByEmail(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 = deduplicateAccountsByEmail(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 }; +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 = deduplicateAccountsByEmail(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 = deduplicateAccountsByEmail( + 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 }; } diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index de2546a8..c6e45369 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -65,6 +65,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 } @@ -515,6 +516,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "check": case "deep-check": case "verify-flagged": + case "restore-backup": case "search": case "delete-all": case "cancel": @@ -653,6 +655,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 fe6280e4..8aec4ff3 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -14,6 +14,8 @@ export const UI_COPY = { accounts: "Saved Accounts", loadingLimits: "Fetching account limits...", noSearchMatches: "No accounts match your search", + recovery: "Recovery", + restoreBackup: "Restore From Backup", dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", diff --git a/test/storage.test.ts b/test/storage.test.ts index 18e3e005..811329b1 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -5,15 +5,19 @@ 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 { + assessNamedBackupRestore, clearAccounts, + createNamedBackup, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, formatStorageErrorHint, getStoragePath, importAccounts, + listNamedBackups, loadAccounts, normalizeAccountStorage, + restoreNamedBackup, StorageError, saveAccounts, setStoragePath, @@ -294,6 +298,92 @@ describe("storage", () => { }); }); + describe("named backups", () => { + const testWorkDir = join( + tmpdir(), + "codex-backup-" + 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); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + 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("My Backup 1"); + expect(backup.name).toBe("My-Backup-1"); + const backups = await listNamedBackups(); + expect(backups.length).toBeGreaterThan(0); + expect(backups[0]?.accountCount).toBe(1); + const backupPath = join(testWorkDir, "backups", `${backup.name}.json`); + expect(existsSync(backupPath)).toBe(true); + }); + + 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.valid).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("rejects invalid backup names", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await expect(createNamedBackup(" ")).rejects.toThrow(/Invalid backup name/); + }); + }); + 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 c10e66ba9c70aadafb0ddee4cca339e2c3e67a62 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 15:40:13 +0800 Subject: [PATCH 02/35] test(auth): cover backup restore manager flow --- test/codex-manager-cli.test.ts | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 334a2bff..31d03bb5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -6,6 +6,10 @@ 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 restoreNamedBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -88,6 +92,10 @@ vi.mock("../lib/storage.js", () => ({ saveFlaggedAccounts: saveFlaggedAccountsMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + listNamedBackups: listNamedBackupsMock, + assessNamedBackupRestore: assessNamedBackupRestoreMock, + getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, + restoreNamedBackup: restoreNamedBackupMock, })); vi.mock("../lib/refresh-queue.js", () => ({ @@ -249,6 +257,38 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + listNamedBackupsMock.mockReset(); + assessNamedBackupRestoreMock.mockReset(); + getNamedBackupsDirectoryPathMock.mockReset(); + restoreNamedBackupMock.mockReset(); + listNamedBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue({ + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + 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, + valid: true, + error: undefined, + }); + getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); + restoreNamedBackupMock.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + }); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, @@ -1518,6 +1558,73 @@ describe("codex manager cli commands", () => { ); }); + 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: "/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, + valid: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + const confirmMock = vi.fn().mockResolvedValue(true); + vi.doMock("../lib/ui/confirm.js", () => ({ confirm: confirmMock })); + vi.resetModules(); + 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(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it.each([ { panel: "account-list", mode: "windows-ebusy" }, { panel: "summary-fields", mode: "windows-ebusy" }, From bd8060be3487e1bb3d4f6643df34c33937ca89c3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 10:04:22 +0800 Subject: [PATCH 03/35] fix(auth): harden named backup restore paths --- lib/storage.ts | 25 +++++++++----- test/codex-manager-cli.test.ts | 1 + test/storage.test.ts | 63 ++++++++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 344f97b8..fe4fa566 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -472,11 +472,19 @@ async function ensureNamedBackupsDirectory(): Promise { } function resolveNamedBackupPath(name: string): string { - const normalizedName = normalizeBackupName(name); - return join( - getNamedBackupsDirectory(), - `${normalizedName}${NAMED_BACKUP_EXTENSION}`, + const backupDir = getNamedBackupsDirectory(); + const trimmedName = name.trim(); + const directPath = join( + backupDir, + trimmedName.endsWith(NAMED_BACKUP_EXTENSION) + ? trimmedName + : `${trimmedName}${NAMED_BACKUP_EXTENSION}`, ); + if (existsSync(directPath)) { + return directPath; + } + const normalizedName = normalizeBackupName(trimmedName); + return join(backupDir, `${normalizedName}${NAMED_BACKUP_EXTENSION}`); } function deriveBackupNameFromFile(fileName: string): string { @@ -1328,10 +1336,10 @@ export async function assessNamedBackupRestore( name: string, options: { currentStorage?: AccountStorageV3 | null } = {}, ): Promise { - const normalizedName = normalizeBackupName(name); - const backupPath = resolveNamedBackupPath(normalizedName); + const backupPath = resolveNamedBackupPath(name); + const backupName = deriveBackupNameFromFile(basename(backupPath)); const candidate = await loadBackupCandidate(backupPath); - const backup = await buildNamedBackupMetadata(normalizedName, backupPath, { + const backup = await buildNamedBackupMetadata(backupName, backupPath, { candidate, }); const currentStorage = options.currentStorage ?? (await loadAccounts()); @@ -1382,8 +1390,7 @@ export async function assessNamedBackupRestore( export async function restoreNamedBackup( name: string, ): Promise<{ imported: number; total: number; skipped: number }> { - const normalizedName = normalizeBackupName(name); - const backupPath = resolveNamedBackupPath(normalizedName); + const backupPath = resolveNamedBackupPath(name); return importAccounts(backupPath); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 31d03bb5..171fb7e9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1623,6 +1623,7 @@ describe("codex manager cli commands", () => { }), ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + vi.doUnmock("../lib/ui/confirm.js"); }); it.each([ diff --git a/test/storage.test.ts b/test/storage.test.ts index 811329b1..1fff276e 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -127,7 +127,23 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(testWorkDir, { recursive: true, force: true }); + break; + } 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), + ); + } + } }); it("should export accounts to a file", async () => { @@ -367,6 +383,47 @@ describe("storage", () => { expect(restored?.accounts[0]?.accountId).toBe("primary"); }); + it("restores manually named backups without normalized filenames", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const backupsDir = join(testWorkDir, "backups"); + await fs.mkdir(backupsDir, { recursive: true }); + await fs.writeFile( + join(backupsDir, "Manual Backup.json"), + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "manual", + refreshToken: "ref-manual", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + await clearAccounts(); + const assessment = await assessNamedBackupRestore("Manual Backup"); + expect(assessment.valid).toBe(true); + expect(assessment.backup.name).toBe("Manual Backup"); + const restoreResult = await restoreNamedBackup("Manual Backup"); + expect(restoreResult.total).toBe(1); + }); + it("rejects invalid backup names", async () => { await saveAccounts({ version: 3, @@ -380,7 +437,9 @@ describe("storage", () => { }, ], }); - await expect(createNamedBackup(" ")).rejects.toThrow(/Invalid backup name/); + await expect(createNamedBackup(" ")).rejects.toThrow( + /Invalid backup name/, + ); }); }); From f5f39450dc82f34740e3f79bc3c6f7109fcf8d41 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 01:32:03 +0800 Subject: [PATCH 04/35] fix(storage): cover restore backup edge cases --- lib/storage.ts | 29 +++++++------ test/storage.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 037067ad..3b4817a5 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1581,21 +1581,20 @@ export async function listNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); try { const entries = await fs.readdir(backupRoot, { withFileTypes: true }); - const backups: NamedBackupMetadata[] = []; - for (const entry of entries) { - if (!entry.isFile() || entry.isSymbolicLink()) continue; - if (!entry.name.toLowerCase().endsWith(".json")) continue; - - const path = resolvePath(join(backupRoot, entry.name)); - const candidate = await loadBackupCandidate(path); - backups.push( - await buildNamedBackupMetadata( - entry.name.slice(0, -".json".length), - path, - { candidate }, - ), - ); - } + const backups = await Promise.all( + entries + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")) + .map(async (entry) => { + const path = resolvePath(join(backupRoot, entry.name)); + const candidate = await loadBackupCandidate(path); + return buildNamedBackupMetadata( + entry.name.slice(0, -".json".length), + path, + { candidate }, + ); + }), + ); return backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; diff --git a/test/storage.test.ts b/test/storage.test.ts index 77c323fb..e1af9326 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1176,6 +1176,102 @@ describe("storage", () => { 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.valid).toBe(true); + + await fs.unlink(backup.path); + + await expect( + restoreNamedBackup("deleted-after-assessment"), + ).rejects.toThrow(/Import file not found/); + }); + + 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.valid).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/); + }); + + it("throws when restoring would exceed the account limit after assessment", async () => { + const { ACCOUNT_LIMITS } = await import("../lib/constants.js"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "limit-backup", + refreshToken: "ref-limit-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await createNamedBackup("limit-after-assessment"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("limit-after-assessment"); + expect(assessment.valid).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, + (_, index) => ({ + accountId: `current-${index}`, + refreshToken: `ref-current-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + + await expect( + restoreNamedBackup("limit-after-assessment"), + ).rejects.toThrow(/Import would exceed maximum of/); + }); + it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( "rejects backup names that escape the backups directory: %s", async (input) => { From c659e7255f49c6bf60d9b6881e1e6d46fdcb8c83 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 01:35:27 +0800 Subject: [PATCH 05/35] fix(auth): harden backup restore follow-up --- lib/codex-manager.ts | 15 +++++-- test/storage.test.ts | 105 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 9b4cae83..d905fd2b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4284,10 +4284,17 @@ async function runBackupRestoreManager( const confirmed = await confirm(confirmMessage); if (!confirmed) return; - const result = await restoreNamedBackup(assessment.backup.name); - console.log( - `Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, - ); + try { + const result = await restoreNamedBackup(assessment.backup.name); + console.log( + `Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + ); + } } export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { diff --git a/test/storage.test.ts b/test/storage.test.ts index e1af9326..8c9a4401 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,6 +2,7 @@ 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 { 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"; @@ -1279,6 +1280,110 @@ describe("storage", () => { await expect(restoreNamedBackup(input)).rejects.toThrow(); }, ); + + 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, + }, + ], + }); + await createNamedBackup("deleted-backup"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("deleted-backup"); + expect(assessment.valid).toBe(true); + + await fs.rm(buildNamedBackupPath("deleted-backup"), { force: true }); + + await expect(restoreNamedBackup("deleted-backup")).rejects.toThrow( + /Import file not found/, + ); + }); + + it("throws when a named backup becomes invalid JSON before restore", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "corrupt-backup", + refreshToken: "ref-corrupt-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("corrupt-backup"); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("corrupt-backup"); + expect(assessment.valid).toBe(true); + + await fs.writeFile(buildNamedBackupPath("corrupt-backup"), "{", "utf-8"); + + await expect(restoreNamedBackup("corrupt-backup")).rejects.toThrow( + /Invalid JSON in import file/, + ); + }); + + it("throws when current accounts exceed the limit after assessment", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "limit-backup", + refreshToken: "ref-limit-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("limit-backup"); + + 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 assessment = await assessNamedBackupRestore("limit-backup"); + expect(assessment.valid).toBe(true); + expect(assessment.wouldExceedLimit).toBe(false); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: Array.from( + { length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, + (_, index) => ({ + accountId: `grown-${index}`, + refreshToken: `ref-grown-${index}`, + addedAt: index + 1, + lastUsed: index + 1, + }), + ), + }); + + await expect(restoreNamedBackup("limit-backup")).rejects.toThrow( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, + ); + }); }); describe("filename migration (TDD)", () => { From 11f8c45899d20fde8443883e612c472a23329fdc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 01:37:52 +0800 Subject: [PATCH 06/35] fix(storage): guard backup restore edge cases --- lib/storage.ts | 40 ++++++++-- test/storage.test.ts | 176 ++++++++++++++++++++++++++++++++----------- 2 files changed, 166 insertions(+), 50 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 3b4817a5..4ecda7bd 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1776,7 +1776,29 @@ async function resolveNamedBackupRestorePath(name: string): Promise { if (existingPath) { return existingPath; } - return buildNamedBackupPath(name); + 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( @@ -2594,13 +2616,15 @@ export async function exportAccounts( const transactionState = transactionSnapshotContext.getStore(); const currentStoragePath = getStoragePath(); - const storage = - transactionState?.active && - transactionState.storagePath === currentStoragePath - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + const storage = transactionState?.active + ? 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"); } diff --git a/test/storage.test.ts b/test/storage.test.ts index 8c9a4401..781fb736 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -369,6 +369,34 @@ 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("should import accounts from a file and merge", async () => { // @ts-expect-error const { importAccounts } = await import("../lib/storage.js"); @@ -1231,48 +1259,6 @@ describe("storage", () => { ).rejects.toThrow(/Invalid JSON in import file/); }); - it("throws when restoring would exceed the account limit after assessment", async () => { - const { ACCOUNT_LIMITS } = await import("../lib/constants.js"); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [ - { - accountId: "limit-backup", - refreshToken: "ref-limit-backup", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - - await createNamedBackup("limit-after-assessment"); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore("limit-after-assessment"); - expect(assessment.valid).toBe(true); - expect(assessment.wouldExceedLimit).toBe(false); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, - (_, index) => ({ - accountId: `current-${index}`, - refreshToken: `ref-current-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ), - }); - - await expect( - restoreNamedBackup("limit-after-assessment"), - ).rejects.toThrow(/Import would exceed maximum of/); - }); - it.each(["../openai-codex-accounts", String.raw`..\openai-codex-accounts`])( "rejects backup names that escape the backups directory: %s", async (input) => { @@ -1307,6 +1293,41 @@ describe("storage", () => { ); }); + 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.valid).toBe(true); + + await fs.rm(backupPath, { force: true }); + + await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( + /Import file not found/, + ); + }); + it("throws when a named backup becomes invalid JSON before restore", async () => { await saveAccounts({ version: 3, @@ -1384,6 +1405,77 @@ describe("storage", () => { `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, ); }); + + 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.valid).toBe(true); + expect(assessmentB.valid).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 bf93df9dab4674a61f8596d1bb8ed7ab332056ae Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 02:16:46 +0800 Subject: [PATCH 07/35] fix(auth): close backup restore review gaps --- docs/reference/storage-paths.md | 8 + lib/cli.ts | 8 + lib/codex-manager.ts | 302 ++++++++++++++++---------------- lib/storage.ts | 43 ++++- lib/ui/copy.ts | 4 +- test/cli.test.ts | 14 ++ test/codex-manager-cli.test.ts | 111 ++++++++++++ test/storage.test.ts | 139 +++++++-------- 8 files changed, 391 insertions(+), 238 deletions(-) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index f45e1d96..cf0747de 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -102,6 +102,13 @@ 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. + --- ## oc-chatgpt Target Paths @@ -117,6 +124,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 e2b0b805..67c304db 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -234,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); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d905fd2b..1f6bf161 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3821,173 +3821,167 @@ 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 === "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 === "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 === "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 === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, 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 === "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"}`, - ); - } - 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; } } diff --git a/lib/storage.ts b/lib/storage.ts index 4ecda7bd..d6820a78 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1598,16 +1598,41 @@ export async function listNamedBackups(): Promise { return backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to list named backups", { - path: backupRoot, - error: String(error), - }); + if (code === "ENOENT") { + return []; } - 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" { + return code === "EPERM" || code === "EBUSY" || code === "EAGAIN"; +} + +async function retryTransientFilesystemOperation( + operation: () => Promise, +): Promise { + 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 === 4) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + } + } + + throw new Error("Retry loop exhausted unexpectedly"); +} + export function getNamedBackupsDirectoryPath(): string { return getNamedBackupRoot(getStoragePath()); } @@ -1715,7 +1740,9 @@ async function loadBackupCandidate(path: string): Promise<{ error?: string; }> { try { - return await loadAccountsFromPath(path); + return await retryTransientFilesystemOperation(() => + loadAccountsFromPath(path), + ); } catch (error) { return { normalized: null, @@ -2022,7 +2049,7 @@ async function buildNamedBackupMetadata( ctimeMs?: number; } | null = null; try { - stats = await fs.stat(path); + stats = await retryTransientFilesystemOperation(() => fs.stat(path)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 91bdf60c..10f123e4 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -133,8 +133,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..269a0eba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -704,6 +704,20 @@ 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", + }); + }); + 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 15df9ac3..57a7aa55 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2427,6 +2427,117 @@ describe("codex manager cli commands", () => { 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(); + 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, + valid: 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(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(1); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ currentStorage: null }), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + + 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: "/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, + valid: 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(restoreNamedBackupMock).not.toHaveBeenCalled(); + }); + it("catches restore failures and returns to the login menu", async () => { setInteractiveTTY(true); const now = Date.now(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 781fb736..140b38c3 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1267,30 +1267,17 @@ describe("storage", () => { }, ); - 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, - }, - ], - }); - await createNamedBackup("deleted-backup"); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore("deleted-backup"); - expect(assessment.valid).toBe(true); - - await fs.rm(buildNamedBackupPath("deleted-backup"), { force: true }); + 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"; + readdirSpy.mockRejectedValueOnce(error); - await expect(restoreNamedBackup("deleted-backup")).rejects.toThrow( - /Import file not found/, - ); + try { + await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); + } finally { + readdirSpy.mockRestore(); + } }); it("throws file-not-found when a manually named backup disappears after assessment", async () => { @@ -1328,82 +1315,86 @@ describe("storage", () => { ); }); - it("throws when a named backup becomes invalid JSON before restore", async () => { + it("retries transient backup read errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "corrupt-backup", - refreshToken: "ref-corrupt-backup", + accountId: "retry-read", + refreshToken: "ref-retry-read", addedAt: 1, lastUsed: 1, }, ], }); - await createNamedBackup("corrupt-backup"); - await clearAccounts(); - - const assessment = await assessNamedBackupRestore("corrupt-backup"); - expect(assessment.valid).toBe(true); - - await fs.writeFile(buildNamedBackupPath("corrupt-backup"), "{", "utf-8"); + 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)); + }); - await expect(restoreNamedBackup("corrupt-backup")).rejects.toThrow( - /Invalid JSON in import file/, - ); + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-read", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + readFileSpy.mockRestore(); + } }); - it("throws when current accounts exceed the limit after assessment", async () => { + it("retries transient backup stat errors while listing backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, accounts: [ { - accountId: "limit-backup", - refreshToken: "ref-limit-backup", + accountId: "retry-stat", + refreshToken: "ref-retry-stat", addedAt: 1, lastUsed: 1, }, ], }); - await createNamedBackup("limit-backup"); - - 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 assessment = await assessNamedBackupRestore("limit-backup"); - expect(assessment.valid).toBe(true); - expect(assessment.wouldExceedLimit).toBe(false); - - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: Array.from( - { length: ACCOUNT_LIMITS.MAX_ACCOUNTS }, - (_, index) => ({ - accountId: `grown-${index}`, - refreshToken: `ref-grown-${index}`, - addedAt: index + 1, - lastUsed: index + 1, - }), - ), + 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; + 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)); }); - await expect(restoreNamedBackup("limit-backup")).rejects.toThrow( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts`, - ); + try { + const backups = await listNamedBackups(); + expect(backups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "retry-stat", valid: true }), + ]), + ); + expect(busyFailures).toBe(1); + } finally { + statSpy.mockRestore(); + } }); it("serializes concurrent restores so only one succeeds when the limit is tight", async () => { From d12e6dad3664c519d3c14e26070b1f53ce38fc17 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 02:34:08 +0800 Subject: [PATCH 08/35] fix(auth): refresh restore eligibility before confirm --- lib/codex-manager.ts | 19 ++++--- lib/storage.ts | 6 +-- test/codex-manager-cli.test.ts | 96 ++++++++++++++++++++++++++++++++-- test/storage.test.ts | 14 ++--- 4 files changed, 112 insertions(+), 23 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 1f6bf161..dd269dbf 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4221,7 +4221,7 @@ async function runBackupRestoreManager( const items: MenuItem[] = assessments.map((assessment) => { const status = - assessment.valid && !assessment.wouldExceedLimit + assessment.eligibleForRestore ? "ready" : assessment.wouldExceedLimit ? "limit" @@ -4247,7 +4247,7 @@ async function runBackupRestoreManager( value: { type: "restore", assessment }, color: status === "ready" ? "green" : status === "limit" ? "red" : "yellow", - disabled: !assessment.valid || assessment.wouldExceedLimit, + disabled: !assessment.eligibleForRestore, }; }); @@ -4268,20 +4268,23 @@ async function runBackupRestoreManager( return; } - const assessment = selection.assessment; - if (!assessment.valid || assessment.wouldExceedLimit) { - console.log(assessment.error ?? "Backup is not eligible for restore."); + const latestAssessment = await assessNamedBackupRestore( + selection.assessment.backup.name, + { currentStorage: await loadAccounts() }, + ); + if (!latestAssessment.eligibleForRestore) { + console.log(latestAssessment.error ?? "Backup is not eligible for restore."); return; } - const confirmMessage = `Restore backup "${assessment.backup.name}"? This will merge ${assessment.backup.accountCount ?? 0} account(s) into ${assessment.currentAccountCount} current (${assessment.mergedAccountCount ?? assessment.currentAccountCount} after dedupe).`; + 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; try { - const result = await restoreNamedBackup(assessment.backup.name); + const result = await restoreNamedBackup(latestAssessment.backup.name); console.log( - `Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/lib/storage.ts b/lib/storage.ts index d6820a78..680219cf 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -135,7 +135,7 @@ export interface BackupRestoreAssessment { imported: number | null; skipped: number | null; wouldExceedLimit: boolean; - valid: boolean; + eligibleForRestore: boolean; error?: string; } @@ -1672,7 +1672,7 @@ export async function assessNamedBackupRestore( imported: null, skipped: null, wouldExceedLimit: false, - valid: false, + eligibleForRestore: false, error: backup.loadError ?? "Backup is empty or invalid", }; } @@ -1696,7 +1696,7 @@ export async function assessNamedBackupRestore( imported, skipped, wouldExceedLimit, - valid: !wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit, error: wouldExceedLimit ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : undefined, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 57a7aa55..59b37637 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -527,7 +527,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - valid: true, + eligibleForRestore: true, error: undefined, }); getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); @@ -2400,7 +2400,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - valid: true, + eligibleForRestore: true, error: undefined, }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); @@ -2449,7 +2449,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - valid: true, + eligibleForRestore: true, error: undefined, }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); @@ -2510,7 +2510,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - valid: true, + eligibleForRestore: true, error: undefined, }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); @@ -2576,7 +2576,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, wouldExceedLimit: false, - valid: true, + eligibleForRestore: true, error: undefined, }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); @@ -2610,6 +2610,92 @@ describe("codex manager cli commands", () => { } }); + 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: "/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, + }; + 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("into 3 current (4 after dedupe)"), + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + }); + it("shows experimental settings in the settings hub", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); diff --git a/test/storage.test.ts b/test/storage.test.ts index 140b38c3..fab8d4f4 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1155,7 +1155,7 @@ describe("storage", () => { await clearAccounts(); const assessment = await assessNamedBackupRestore("restore-me"); - expect(assessment.valid).toBe(true); + expect(assessment.eligibleForRestore).toBe(true); expect(assessment.wouldExceedLimit).toBe(false); const restoreResult = await restoreNamedBackup("restore-me"); @@ -1198,7 +1198,7 @@ describe("storage", () => { await clearAccounts(); const assessment = await assessNamedBackupRestore("Manual Backup"); - expect(assessment.valid).toBe(true); + expect(assessment.eligibleForRestore).toBe(true); expect(assessment.backup.name).toBe("Manual Backup"); const restoreResult = await restoreNamedBackup("Manual Backup"); @@ -1223,7 +1223,7 @@ describe("storage", () => { await clearAccounts(); const assessment = await assessNamedBackupRestore("deleted-after-assessment"); - expect(assessment.valid).toBe(true); + expect(assessment.eligibleForRestore).toBe(true); await fs.unlink(backup.path); @@ -1250,7 +1250,7 @@ describe("storage", () => { await clearAccounts(); const assessment = await assessNamedBackupRestore("invalid-after-assessment"); - expect(assessment.valid).toBe(true); + expect(assessment.eligibleForRestore).toBe(true); await fs.writeFile(backup.path, "not valid json {[", "utf-8"); @@ -1306,7 +1306,7 @@ describe("storage", () => { await clearAccounts(); const assessment = await assessNamedBackupRestore("Manual Backup"); - expect(assessment.valid).toBe(true); + expect(assessment.eligibleForRestore).toBe(true); await fs.rm(backupPath, { force: true }); @@ -1442,8 +1442,8 @@ describe("storage", () => { const assessmentA = await assessNamedBackupRestore("backup-a"); const assessmentB = await assessNamedBackupRestore("backup-b"); - expect(assessmentA.valid).toBe(true); - expect(assessmentB.valid).toBe(true); + expect(assessmentA.eligibleForRestore).toBe(true); + expect(assessmentB.eligibleForRestore).toBe(true); const results = await Promise.allSettled([ restoreNamedBackup("backup-a"), From a0544fffd65a3795e7c22ff919b0b11bdfdbdd3a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 15:59:58 +0800 Subject: [PATCH 09/35] fix(storage): honor null restore snapshots --- lib/codex-manager.ts | 2 +- lib/storage.ts | 33 +++++++++--- test/codex-manager-cli.test.ts | 41 +++++++++++++++ test/storage.test.ts | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index dd269dbf..a96cd6da 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -133,7 +133,7 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (!timestamp) return null; + if (timestamp == null) return null; const days = Math.floor((Date.now() - timestamp) / 86_400_000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; diff --git a/lib/storage.ts b/lib/storage.ts index 680219cf..59073196 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 NAMED_BACKUP_LIST_CONCURRENCY = 8; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -1581,11 +1582,22 @@ export async function listNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); try { const entries = await fs.readdir(backupRoot, { withFileTypes: true }); - const backups = await Promise.all( - entries - .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) - .filter((entry) => entry.name.toLowerCase().endsWith(".json")) - .map(async (entry) => { + const backupEntries = entries + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) + .filter((entry) => entry.name.toLowerCase().endsWith(".json")); + const backups: NamedBackupMetadata[] = []; + for ( + let index = 0; + index < backupEntries.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backupEntries.slice( + index, + index + NAMED_BACKUP_LIST_CONCURRENCY, + ); + backups.push( + ...(await Promise.all( + chunk.map(async (entry) => { const path = resolvePath(join(backupRoot, entry.name)); const candidate = await loadBackupCandidate(path); return buildNamedBackupMetadata( @@ -1593,8 +1605,10 @@ export async function listNamedBackups(): Promise { path, { candidate }, ); - }), - ); + }), + )), + ); + } return backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -1661,7 +1675,10 @@ export async function assessNamedBackupRestore( backupPath, { candidate }, ); - const currentStorage = options.currentStorage ?? (await loadAccounts()); + const currentStorage = + options.currentStorage !== undefined + ? options.currentStorage + : await loadAccounts(); const currentAccounts = currentStorage?.accounts ?? []; if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 59b37637..02ddb5c2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2696,6 +2696,47 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + it("shows epoch backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "epoch-backup", + path: "/mock/backups/epoch-backup.json", + 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( + `updated ${new Date(0).toLocaleDateString()}`, + ); + }); + it("shows experimental settings in the settings hub", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); diff --git a/test/storage.test.ts b/test/storage.test.ts index fab8d4f4..12a03f9a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1165,6 +1165,46 @@ describe("storage", () => { 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("restores manually named backups that already exist inside the backups directory", async () => { const backupPath = join( dirname(testStoragePath), @@ -1397,6 +1437,57 @@ describe("storage", () => { } }); + it("limits concurrent backup reads while listing backups", async () => { + const backupPaths: string[] = []; + for (let index = 0; index < 12; 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(12); + expect(peakReads).toBeLessThanOrEqual(8); + } finally { + readFileSpy.mockRestore(); + } + }); + it("serializes concurrent restores so only one succeeds when the limit is tight", async () => { await saveAccounts({ version: 3, From 17d943e6e3d2c4cb03cb04e3e9f6d45c5e441180 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 19:06:04 +0800 Subject: [PATCH 10/35] fix(auth): bound restore assessment concurrency --- lib/codex-manager.ts | 21 +++++++++--- lib/storage.ts | 2 +- test/codex-manager-cli.test.ts | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a96cd6da..384736a5 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -59,6 +59,7 @@ import { assessNamedBackupRestore, getNamedBackupsDirectoryPath, listNamedBackups, + NAMED_BACKUP_LIST_CONCURRENCY, restoreNamedBackup, findMatchingAccountIndex, getStoragePath, @@ -4213,11 +4214,21 @@ async function runBackupRestoreManager( } const currentStorage = await loadAccounts(); - const assessments = await Promise.all( - backups.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }), - ), - ); + const assessments: Awaited>[] = []; + for ( + let index = 0; + index < backups.length; + index += NAMED_BACKUP_LIST_CONCURRENCY + ) { + const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); + assessments.push( + ...(await Promise.all( + chunk.map((backup) => + assessNamedBackupRestore(backup.name, { currentStorage }), + ), + )), + ); + } const items: MenuItem[] = assessments.map((assessment) => { const status = diff --git a/lib/storage.ts b/lib/storage.ts index 59073196..082c413a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,7 +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 NAMED_BACKUP_LIST_CONCURRENCY = 8; +export const NAMED_BACKUP_LIST_CONCURRENCY = 8; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 02ddb5c2..1c45128c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2610,6 +2610,67 @@ describe("codex manager cli commands", () => { } }); + it("limits concurrent backup assessments in the restore menu", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const backups = Array.from({ length: 9 }, (_value, index) => ({ + name: `named-backup-${index + 1}`, + path: `/mock/backups/named-backup-${index + 1}.json`, + 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>> = []; + const finalBackupName = backups[backups.length - 1]?.name; + listNamedBackupsMock.mockResolvedValue(backups); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const gate = createDeferred(); + pending.push(gate); + if (pending.length === 8 || name === finalBackupName) { + const release = pending; + pending = []; + queueMicrotask(() => { + for (const deferred of release) { + deferred.resolve(); + } + }); + } + 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(8); + }); + it("reassesses a backup before confirmation so the merge summary stays current", async () => { setInteractiveTTY(true); const now = Date.now(); From d832af8a0f13512f113e492daa7141812598dbeb Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 19:08:26 +0800 Subject: [PATCH 11/35] fix(storage): surface backup scan failures --- lib/storage.ts | 12 +++++++----- test/storage.test.ts | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 082c413a..86bd74e1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1804,12 +1804,14 @@ async function findExistingNamedBackupPath( } } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to read named backup directory", { - path: backupRoot, - error: String(error), - }); + if (code === "ENOENT") { + return undefined; } + log.warn("Failed to read named backup directory", { + path: backupRoot, + error: String(error), + }); + throw error; } return undefined; diff --git a/test/storage.test.ts b/test/storage.test.ts index 12a03f9a..af1101b6 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1320,6 +1320,21 @@ describe("storage", () => { } }); + 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"; + readdirSpy.mockRejectedValueOnce(error); + + try { + await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ + code: "EPERM", + }); + } finally { + readdirSpy.mockRestore(); + } + }); + it("throws file-not-found when a manually named backup disappears after assessment", async () => { const backupPath = join( dirname(testStoragePath), @@ -1348,7 +1363,7 @@ describe("storage", () => { const assessment = await assessNamedBackupRestore("Manual Backup"); expect(assessment.eligibleForRestore).toBe(true); - await fs.rm(backupPath, { force: true }); + await removeWithRetry(backupPath, { force: true }); await expect(restoreNamedBackup("Manual Backup")).rejects.toThrow( /Import file not found/, From a21b0171f7f6b983b39809ef78a50840326fed0b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 19:22:26 +0800 Subject: [PATCH 12/35] test(auth): harden restore concurrency regression --- test/codex-manager-cli.test.ts | 37 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1c45128c..a99fed26 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2613,6 +2613,10 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); + const { NAMED_BACKUP_LIST_CONCURRENCY } = + await vi.importActual( + "../lib/storage.js", + ); const backups = Array.from({ length: 9 }, (_value, index) => ({ name: `named-backup-${index + 1}`, path: `/mock/backups/named-backup-${index + 1}.json`, @@ -2629,22 +2633,31 @@ describe("codex manager cli commands", () => { let inFlight = 0; let maxInFlight = 0; let pending: Array>> = []; - const finalBackupName = backups[backups.length - 1]?.name; + 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); - if (pending.length === 8 || name === finalBackupName) { - const release = pending; - pending = []; - queueMicrotask(() => { - for (const deferred of release) { - deferred.resolve(); - } - }); - } + releasePending(); await gate.promise; inFlight -= 1; return { @@ -2668,7 +2681,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); - expect(maxInFlight).toBeLessThanOrEqual(8); + expect(maxInFlight).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, + ); }); it("reassesses a backup before confirmation so the merge summary stays current", async () => { From f5a3e0095c6116346ca7f10539dda1e3a9e6fade Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 20:30:07 +0800 Subject: [PATCH 13/35] fix(auth): harden backup restore discovery --- lib/codex-manager.ts | 26 ++++++--- lib/storage.ts | 8 ++- test/codex-manager-cli.test.ts | 67 ++++++++++++++++++++++++ test/storage.test.ts | 96 ++++++++++++++++++++++++++++++++-- 4 files changed, 186 insertions(+), 11 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 384736a5..362c1af8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4221,13 +4221,27 @@ async function runBackupRestoreManager( index += NAMED_BACKUP_LIST_CONCURRENCY ) { const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); - assessments.push( - ...(await Promise.all( - chunk.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }), - ), - )), + 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" + }`, + ); + } } const items: MenuItem[] = assessments.map((assessment) => { diff --git a/lib/storage.ts b/lib/storage.ts index 86bd74e1..d3a1ec73 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1581,7 +1581,9 @@ export async function getRestoreAssessment(): Promise { export async function listNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); try { - const entries = await fs.readdir(backupRoot, { withFileTypes: true }); + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); const backupEntries = entries .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) .filter((entry) => entry.name.toLowerCase().endsWith(".json")); @@ -1790,7 +1792,9 @@ async function findExistingNamedBackupPath( : `${requested}.json`; try { - const entries = await fs.readdir(backupRoot, { withFileTypes: true }); + const entries = await retryTransientFilesystemOperation(() => + fs.readdir(backupRoot, { withFileTypes: true }), + ); for (const entry of entries) { if (!entry.isFile() || entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith(".json")) continue; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a99fed26..9b848d3f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2596,6 +2596,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); const restoreFailureCalls = [ ...errorSpy.mock.calls, @@ -2610,6 +2611,72 @@ describe("codex manager cli commands", () => { } }); + 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: "/mock/backups/healthy-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([ + { + ...healthyAssessment.backup, + name: "broken-backup", + path: "/mock/backups/broken-backup.json", + }, + 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(restoreNamedBackupMock).toHaveBeenCalledWith("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); diff --git a/test/storage.test.ts b/test/storage.test.ts index af1101b6..5268fdc2 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -19,6 +19,7 @@ import { findMatchingAccountIndex, formatStorageErrorHint, getFlaggedAccountsPath, + NAMED_BACKUP_LIST_CONCURRENCY, getStoragePath, importAccounts, listNamedBackups, @@ -1311,7 +1312,7 @@ describe("storage", () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; - readdirSpy.mockRejectedValueOnce(error); + readdirSpy.mockRejectedValue(error); try { await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" }); @@ -1324,7 +1325,7 @@ describe("storage", () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; error.code = "EPERM"; - readdirSpy.mockRejectedValueOnce(error); + readdirSpy.mockRejectedValue(error); try { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ @@ -1335,6 +1336,93 @@ describe("storage", () => { } }); + it("retries transient backup directory errors while listing backups", async () => { + 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(); + } + }); + + it("retries transient 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), @@ -1497,7 +1585,9 @@ describe("storage", () => { try { const backups = await listNamedBackups(); expect(backups).toHaveLength(12); - expect(peakReads).toBeLessThanOrEqual(8); + expect(peakReads).toBeLessThanOrEqual( + NAMED_BACKUP_LIST_CONCURRENCY, + ); } finally { readFileSpy.mockRestore(); } From 65ee48ce4ffd7145769c5dbe535e90a166c27f1d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 20:34:51 +0800 Subject: [PATCH 14/35] fix(auth): harden backup restore edge cases --- lib/codex-manager.ts | 13 ++++++++++- lib/storage.ts | 25 +++++++++++++------- test/codex-manager-cli.test.ts | 32 ++++++++++++++++++++++++++ test/storage.test.ts | 42 +++++++++++++++++++++++++++++++++- 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 362c1af8..e17c201f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4207,7 +4207,18 @@ async function runBackupRestoreManager( displaySettings: DashboardDisplaySettings, ): Promise { const backupDir = getNamedBackupsDirectoryPath(); - const backups = await listNamedBackups(); + let backups; + try { + backups = await listNamedBackups(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `Could not read backup directory: ${ + collapseWhitespace(message) || "unknown error" + }`, + ); + return; + } if (backups.length === 0) { console.log(`No named backups found. Place backup files in ${backupDir}.`); return; diff --git a/lib/storage.ts b/lib/storage.ts index d3a1ec73..9060b86a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1600,13 +1600,13 @@ export async function listNamedBackups(): Promise { backups.push( ...(await Promise.all( chunk.map(async (entry) => { - const path = resolvePath(join(backupRoot, entry.name)); - const candidate = await loadBackupCandidate(path); - return buildNamedBackupMetadata( - entry.name.slice(0, -".json".length), - path, - { candidate }, - ); + const path = resolvePath(join(backupRoot, entry.name)); + const candidate = await loadBackupCandidate(path); + return buildNamedBackupMetadata( + entry.name.slice(0, -".json".length), + path, + { candidate }, + ); }), )), ); @@ -1778,6 +1778,12 @@ function equalsNamedBackupEntry(left: string, right: string): boolean { : left === right; } +function stripNamedBackupJsonExtension(name: string): string { + return name.toLowerCase().endsWith(".json") + ? name.slice(0, -".json".length) + : name; +} + async function findExistingNamedBackupPath( name: string, ): Promise { @@ -1790,6 +1796,7 @@ async function findExistingNamedBackupPath( const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; + const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); try { const entries = await retryTransientFilesystemOperation(() => @@ -1798,9 +1805,11 @@ async function findExistingNamedBackupPath( for (const entry of entries) { if (!entry.isFile() || entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith(".json")) continue; + const entryBaseName = stripNamedBackupJsonExtension(entry.name); if ( !equalsNamedBackupEntry(entry.name, requested) && - !equalsNamedBackupEntry(entry.name, requestedWithExtension) + !equalsNamedBackupEntry(entry.name, requestedWithExtension) && + !equalsNamedBackupEntry(entryBaseName, requestedBaseName) ) { continue; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 9b848d3f..f863619a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2611,6 +2611,38 @@ describe("codex manager cli commands", () => { } }); + 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(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Could not read backup directory: EPERM: operation not permitted", + ), + ); + } finally { + errorSpy.mockRestore(); + } + }); + it("keeps healthy backups selectable when one assessment fails", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); diff --git a/test/storage.test.ts b/test/storage.test.ts index 5268fdc2..fcbc410f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1246,6 +1246,46 @@ describe("storage", () => { 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, @@ -1266,7 +1306,7 @@ describe("storage", () => { const assessment = await assessNamedBackupRestore("deleted-after-assessment"); expect(assessment.eligibleForRestore).toBe(true); - await fs.unlink(backup.path); + await removeWithRetry(backup.path, { force: true }); await expect( restoreNamedBackup("deleted-after-assessment"), From a12a74864c30ee3ace9b3db072c1a135b3582631 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 21:01:14 +0800 Subject: [PATCH 15/35] fix(storage): reject invalid named backup entries --- lib/storage.ts | 12 ++++++-- test/storage.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 9060b86a..1b17de47 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1803,16 +1803,22 @@ async function findExistingNamedBackupPath( fs.readdir(backupRoot, { withFileTypes: true }), ); for (const entry of entries) { - if (!entry.isFile() || entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith(".json")) continue; const entryBaseName = stripNamedBackupJsonExtension(entry.name); - if ( + const matchesRequestedEntry = !equalsNamedBackupEntry(entry.name, requested) && !equalsNamedBackupEntry(entry.name, requestedWithExtension) && !equalsNamedBackupEntry(entryBaseName, requestedBaseName) - ) { + ? false + : true; + 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) { diff --git a/test/storage.test.ts b/test/storage.test.ts index fcbc410f..d0984d68 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1311,6 +1311,7 @@ describe("storage", () => { await expect( restoreNamedBackup("deleted-after-assessment"), ).rejects.toThrow(/Import file not found/); + expect((await loadAccounts())?.accounts ?? []).toHaveLength(0); }); it("throws when a named backup becomes invalid JSON after assessment", async () => { @@ -1338,16 +1339,77 @@ describe("storage", () => { 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(); - await expect(restoreNamedBackup(input)).rejects.toThrow(); + await expect(assessNamedBackupRestore(input)).rejects.toThrow( + /invalid|not allowed|escape|traversal|not found|path separators/i, + ); + await expect(restoreNamedBackup(input)).rejects.toThrow( + /invalid|not allowed|escape|traversal|not found|path separators/i, + ); }, ); + 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: () => true, + 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", async () => { const readdirSpy = vi.spyOn(fs, "readdir"); const error = new Error("backup directory locked") as NodeJS.ErrnoException; From ee7a4029acdf51792016682f2e8d43b2b931d11d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 22:16:58 +0800 Subject: [PATCH 16/35] fix(auth): close restore review follow-ups --- lib/codex-manager.ts | 21 ++++++--- lib/storage.ts | 8 ++-- test/codex-manager-cli.test.ts | 84 ++++++++++++++++++++++++++++++++++ test/storage.test.ts | 4 +- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index e17c201f..dffac409 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -134,7 +134,7 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (timestamp == null) 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"; @@ -4207,7 +4207,7 @@ async function runBackupRestoreManager( displaySettings: DashboardDisplaySettings, ): Promise { const backupDir = getNamedBackupsDirectoryPath(); - let backups; + let backups: Awaited>; try { backups = await listNamedBackups(); } catch (error) { @@ -4304,10 +4304,19 @@ async function runBackupRestoreManager( return; } - const latestAssessment = await assessNamedBackupRestore( - selection.assessment.backup.name, - { currentStorage: await loadAccounts() }, - ); + 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; + } if (!latestAssessment.eligibleForRestore) { console.log(latestAssessment.error ?? "Backup is not eligible for restore."); return; diff --git a/lib/storage.ts b/lib/storage.ts index 1b17de47..980ad996 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1806,11 +1806,9 @@ async function findExistingNamedBackupPath( 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) - ? false - : true; + equalsNamedBackupEntry(entry.name, requested) || + equalsNamedBackupEntry(entry.name, requestedWithExtension) || + equalsNamedBackupEntry(entryBaseName, requestedBaseName); if (!matchesRequestedEntry) { continue; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index f863619a..6e5e868f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2871,6 +2871,90 @@ describe("codex manager cli commands", () => { expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + 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: "/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([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(() => {}); + + 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(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Restore failed: backup busy"), + ); + }); + it("shows epoch backup timestamps in restore hints", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); diff --git a/test/storage.test.ts b/test/storage.test.ts index d0984d68..ffca98e5 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1346,10 +1346,10 @@ describe("storage", () => { "rejects backup names that escape the backups directory: %s", async (input) => { await expect(assessNamedBackupRestore(input)).rejects.toThrow( - /invalid|not allowed|escape|traversal|not found|path separators/i, + /must not contain path separators/i, ); await expect(restoreNamedBackup(input)).rejects.toThrow( - /invalid|not allowed|escape|traversal|not found|path separators/i, + /must not contain path separators/i, ); }, ); From c6ce2b796623c623491ebc41773b7f6248383f95 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 23:13:15 +0800 Subject: [PATCH 17/35] fix(auth): close remaining restore review gaps --- lib/codex-manager.ts | 11 +++ lib/storage.ts | 51 +++++++------ test/codex-manager-cli.test.ts | 130 ++++++++++++++++++++++++++++++++- test/storage.test.ts | 71 +++++++++++++++++- 4 files changed, 234 insertions(+), 29 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index dffac409..0946aa82 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3868,6 +3868,17 @@ async function runAuthLogin(): Promise { console.log("Cancelled."); return 0; } + const modeTouchesQuotaCache = + menuResult.mode === "check" || + menuResult.mode === "deep-check" || + menuResult.mode === "forecast" || + menuResult.mode === "fix"; + if (modeTouchesQuotaCache) { + 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 }); diff --git a/lib/storage.ts b/lib/storage.ts index 980ad996..26691f7e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { existsSync, promises as fs } from "node:fs"; +import { existsSync, promises as fs, type Dirent } from "node:fs"; import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; @@ -1628,7 +1628,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( @@ -1642,7 +1645,11 @@ async function retryTransientFilesystemOperation( if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { throw error; } - await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + const baseDelayMs = 10 * 2 ** attempt; + const jitterMs = Math.floor(Math.random() * 10); + await new Promise((resolve) => + setTimeout(resolve, baseDelayMs + jitterMs), + ); } } @@ -1797,28 +1804,12 @@ async function findExistingNamedBackupPath( ? requested : `${requested}.json`; const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension); + let entries: Dirent[]; try { - const entries = await retryTransientFilesystemOperation(() => + 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") { @@ -1831,6 +1822,24 @@ 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; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6e5e868f..154ad73c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2535,6 +2535,7 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledOnce(); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); }); @@ -2716,7 +2717,8 @@ describe("codex manager cli commands", () => { await vi.importActual( "../lib/storage.js", ); - const backups = Array.from({ length: 9 }, (_value, index) => ({ + const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 3; + const backups = Array.from({ length: totalBackups }, (_value, index) => ({ name: `named-backup-${index + 1}`, path: `/mock/backups/named-backup-${index + 1}.json`, createdAt: null, @@ -2991,9 +2993,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain( - `updated ${new Date(0).toLocaleDateString()}`, - ); + expect(backupItems?.[0]?.hint).toContain("updated "); }); it("shows experimental settings in the settings hub", async () => { @@ -4133,6 +4133,128 @@ 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("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 ffca98e5..5c647d8b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1410,7 +1410,10 @@ describe("storage", () => { } }); - it("rethrows unreadable backup directory errors while listing backups", async () => { + 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"; @@ -1418,12 +1421,17 @@ 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", async () => { + 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"; @@ -1433,8 +1441,10 @@ describe("storage", () => { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ code: "EPERM", }); + expect(readdirSpy).toHaveBeenCalledTimes(1); } finally { readdirSpy.mockRestore(); + platformSpy.mockRestore(); } }); @@ -1483,6 +1493,58 @@ describe("storage", () => { } }); + 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 transient backup directory errors while restoring backups", async () => { await saveAccounts({ version: 3, @@ -1644,7 +1706,8 @@ describe("storage", () => { it("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; - for (let index = 0; index < 12; index += 1) { + const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; + for (let index = 0; index < totalBackups; index += 1) { await saveAccounts({ version: 3, activeIndex: 0, @@ -1686,7 +1749,7 @@ describe("storage", () => { try { const backups = await listNamedBackups(); - expect(backups).toHaveLength(12); + expect(backups).toHaveLength(totalBackups); expect(peakReads).toBeLessThanOrEqual( NAMED_BACKUP_LIST_CONCURRENCY, ); From 348ac7fbd00342e2fc08b07fde55d84e13cf5f2c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:01:06 +0800 Subject: [PATCH 18/35] fix(auth): tighten restore eligibility edges --- lib/codex-manager.ts | 5 +- lib/storage.ts | 27 +++++++-- test/codex-manager-cli.test.ts | 102 ++++++++++++++++++++++++++++++++- test/storage.test.ts | 88 ++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 0946aa82..cbe54e0c 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -134,7 +134,7 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (timestamp === null || timestamp === undefined) return null; + if (!timestamp) return null; const days = Math.floor((Date.now() - timestamp) / 86_400_000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; @@ -4333,7 +4333,8 @@ async function runBackupRestoreManager( return; } - 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 netNewAccounts = latestAssessment.imported ?? 0; + const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will add ${netNewAccounts} new account(s) (${latestAssessment.backup.accountCount ?? 0} in backup, ${latestAssessment.currentAccountCount} current -> ${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; const confirmed = await confirm(confirmMessage); if (!confirmed) return; diff --git a/lib/storage.ts b/lib/storage.ts index 26691f7e..d889e579 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1627,8 +1627,8 @@ export async function listNamedBackups(): Promise { function isRetryableFilesystemErrorCode( code: string | undefined, -): code is "EPERM" | "EBUSY" | "EAGAIN" { - if (code === "EBUSY" || code === "EAGAIN") { +): code is "EPERM" | "EBUSY" | "EAGAIN" | "ENOTEMPTY" { + if (code === "EBUSY" || code === "EAGAIN" || code === "ENOTEMPTY") { return true; } return code === "EPERM" && process.platform === "win32"; @@ -1714,6 +1714,7 @@ export async function assessNamedBackupRestore( const skipped = wouldExceedLimit ? null : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); + const nothingToImport = !wouldExceedLimit && imported === 0; return { backup, @@ -1722,16 +1723,24 @@ export async function assessNamedBackupRestore( imported, skipped, wouldExceedLimit, - eligibleForRestore: !wouldExceedLimit, + eligibleForRestore: !wouldExceedLimit && !nothingToImport, error: wouldExceedLimit ? `Restore would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` - : undefined, + : nothingToImport + ? "All accounts in this backup already exist" + : undefined, }; } export async function restoreNamedBackup( name: string, ): Promise<{ imported: number; total: number; skipped: number }> { + const assessment = await assessNamedBackupRestore(name); + if (!assessment.eligibleForRestore) { + throw new Error( + assessment.error ?? "Backup is not eligible for restore.", + ); + } const backupPath = await resolveNamedBackupRestorePath(name); return importAccounts(backupPath); } @@ -1770,11 +1779,19 @@ 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: String(error), + error: errorMessage, }; } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 154ad73c..01ecb966 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2868,11 +2868,108 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("into 3 current (4 after dedupe)"), + expect.stringContaining("add 1 new account(s)"), ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); }); + 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: "/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, + }; + 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(restoreNamedBackupMock).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(); @@ -2993,7 +3090,8 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const backupItems = selectMock.mock.calls[0]?.[0]; - expect(backupItems?.[0]?.hint).toContain("updated "); + expect(backupItems?.[0]?.hint).toContain("1 account"); + expect(backupItems?.[0]?.hint).not.toContain("updated "); }); it("shows experimental settings in the settings hub", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 5c647d8b..ce0a3cae 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -958,6 +958,7 @@ describe("storage", () => { it("should fail export when no accounts exist", async () => { const { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); + await clearAccounts(); await expect(exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); @@ -1206,6 +1207,45 @@ describe("storage", () => { 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("restores manually named backups that already exist inside the backups directory", async () => { const backupPath = join( dirname(testStoragePath), @@ -1493,6 +1533,54 @@ describe("storage", () => { } }); + it("retries transient ENOTEMPTY 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 = "ENOTEMPTY"; + 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") From 5d85159df2787962855088b726d78d662fd26578 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:14:45 +0800 Subject: [PATCH 19/35] fix(storage): reuse assessed backup paths --- lib/storage.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index d889e579..f720c709 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1741,8 +1741,7 @@ export async function restoreNamedBackup( assessment.error ?? "Backup is not eligible for restore.", ); } - const backupPath = await resolveNamedBackupRestorePath(name); - return importAccounts(backupPath); + return importAccounts(assessment.backup.path); } function parseAndNormalizeStorage(data: unknown): { @@ -1873,9 +1872,7 @@ async function resolveNamedBackupRestorePath(name: string): Promise { try { return buildNamedBackupPath(name); } catch (error) { - const baseName = requestedWithExtension.toLowerCase().endsWith(".json") - ? requestedWithExtension.slice(0, -".json".length) - : requestedWithExtension; + const baseName = requestedWithExtension.slice(0, -".json".length); if ( requested.length > 0 && basename(requestedWithExtension) === requestedWithExtension && From 1208639e8d1aa0a0ceab8292416b90d77c35039f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:38:34 +0800 Subject: [PATCH 20/35] fix(auth): align restore confirm flow --- lib/codex-manager.ts | 19 +++++++++++++---- lib/storage.ts | 7 +++++-- lib/ui/copy.ts | 15 ++++++++++++++ test/codex-manager-cli.test.ts | 38 +++++++++++++++++++++------------- test/storage.test.ts | 26 ++++++++++++++++++++++- 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index cbe54e0c..58fe4507 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -57,10 +57,10 @@ import { } from "./quota-cache.js"; import { assessNamedBackupRestore, + importAccounts, getNamedBackupsDirectoryPath, listNamedBackups, NAMED_BACKUP_LIST_CONCURRENCY, - restoreNamedBackup, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -4334,14 +4334,25 @@ async function runBackupRestoreManager( } const netNewAccounts = latestAssessment.imported ?? 0; - const confirmMessage = `Restore backup "${latestAssessment.backup.name}"? This will add ${netNewAccounts} new account(s) (${latestAssessment.backup.accountCount ?? 0} in backup, ${latestAssessment.currentAccountCount} current -> ${latestAssessment.mergedAccountCount ?? latestAssessment.currentAccountCount} after dedupe).`; + 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; try { - const result = await restoreNamedBackup(latestAssessment.backup.name); + const result = await importAccounts(latestAssessment.backup.path); console.log( - `Restored backup "${latestAssessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, + UI_COPY.mainMenu.restoreBackupSuccess( + latestAssessment.backup.name, + result.imported, + result.skipped, + result.total, + ), ); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/lib/storage.ts b/lib/storage.ts index f720c709..67b0fb89 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1628,10 +1628,13 @@ export async function listNamedBackups(): Promise { function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" | "ENOTEMPTY" { - if (code === "EBUSY" || code === "EAGAIN" || code === "ENOTEMPTY") { + if (code === "EBUSY" || code === "ENOTEMPTY") { return true; } - return code === "EPERM" && process.platform === "win32"; + return ( + (code === "EPERM" || code === "EAGAIN") && + process.platform === "win32" + ); } async function retryTransientFilesystemOperation( diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 10f123e4..5f29767a 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -16,6 +16,21 @@ 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, + ) => + `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}.`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 01ecb966..2ca88ca0 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -9,7 +9,7 @@ const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); const listNamedBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); -const restoreNamedBackupMock = vi.fn(); +const importAccountsMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); @@ -110,7 +110,7 @@ vi.mock("../lib/storage.js", async () => { listNamedBackups: listNamedBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, - restoreNamedBackup: restoreNamedBackupMock, + importAccounts: importAccountsMock, exportNamedBackup: exportNamedBackupMock, normalizeAccountStorage: normalizeAccountStorageMock, }; @@ -506,7 +506,7 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); - restoreNamedBackupMock.mockReset(); + importAccountsMock.mockReset(); confirmMock.mockReset(); listNamedBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ @@ -531,7 +531,7 @@ describe("codex manager cli commands", () => { error: undefined, }); getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); - restoreNamedBackupMock.mockResolvedValue({ + importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, total: 1, @@ -2424,7 +2424,9 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).toHaveBeenCalledOnce(); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); }); it("offers backup restore from the login menu when no accounts are saved", async () => { @@ -2469,7 +2471,9 @@ describe("codex manager cli commands", () => { "named-backup", expect.objectContaining({ currentStorage: null }), ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); }); it("does not restore a named backup when confirmation is declined", async () => { @@ -2536,7 +2540,7 @@ describe("codex manager cli commands", () => { ); expect(confirmMock).toHaveBeenCalledOnce(); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); }); it("catches restore failures and returns to the login menu", async () => { @@ -2582,7 +2586,7 @@ describe("codex manager cli commands", () => { }; listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - restoreNamedBackupMock.mockRejectedValueOnce( + importAccountsMock.mockRejectedValueOnce( new Error("Import file not found: /mock/backups/named-backup.json"), ); promptLoginModeMock @@ -2598,7 +2602,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); const restoreFailureCalls = [ ...errorSpy.mock.calls, ...logSpy.mock.calls, @@ -2633,7 +2639,7 @@ describe("codex manager cli commands", () => { expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(assessNamedBackupRestoreMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining( "Could not read backup directory: EPERM: operation not permitted", @@ -2699,7 +2705,9 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("healthy-backup"); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/healthy-backup.json", + ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( 'Skipped backup assessment for "broken-backup": backup directory busy', @@ -2870,7 +2878,9 @@ describe("codex manager cli commands", () => { expect(confirmMock).toHaveBeenCalledWith( expect.stringContaining("add 1 new account(s)"), ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); }); it("returns to the login menu when backup reassessment becomes ineligible", async () => { @@ -2961,7 +2971,7 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).not.toHaveBeenCalled(); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith( "All accounts in this backup already exist", ); @@ -3048,7 +3058,7 @@ describe("codex manager cli commands", () => { }), ); expect(confirmMock).not.toHaveBeenCalled(); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(importAccountsMock).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Restore failed: backup busy"), ); diff --git a/test/storage.test.ts b/test/storage.test.ts index ce0a3cae..7f2fff9c 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1488,6 +1488,26 @@ describe("storage", () => { } }); + it("rethrows 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(1); + } finally { + readdirSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("retries transient backup directory errors while listing backups", async () => { await saveAccounts({ version: 3, @@ -1633,7 +1653,10 @@ describe("storage", () => { } }); - it("retries transient backup directory errors while restoring backups", async () => { + it("retries transient EAGAIN backup directory errors while restoring backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); await saveAccounts({ version: 3, activeIndex: 0, @@ -1672,6 +1695,7 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); + platformSpy.mockRestore(); } }); From 7f6870de8d247ffd7cf9dc4ba621b65da2becac4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:59:48 +0800 Subject: [PATCH 21/35] fix(auth): harden restore path guards --- lib/codex-manager.ts | 4 +++- lib/storage.ts | 26 +++++++++++++++++----- test/codex-manager-cli.test.ts | 40 ++++++++++++++++++++++++++++++++++ test/storage.test.ts | 33 ++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 58fe4507..7bca81a3 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -134,7 +134,9 @@ function formatReasonLabel(reason: string | undefined): string | undefined { function formatRelativeDateShort( timestamp: number | null | undefined, ): string | null { - if (!timestamp) return 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"; diff --git a/lib/storage.ts b/lib/storage.ts index 67b0fb89..5162bb87 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs, type Dirent } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -1862,18 +1862,34 @@ async function findExistingNamedBackupPath( return undefined; } +function assertNamedBackupRestorePath( + path: string, + backupRoot: string, +): string { + const resolvedPath = resolvePath(path); + const relativePath = relative(resolvePath(backupRoot), resolvedPath); + if ( + relativePath.length === 0 || + relativePath.startsWith("..") || + isAbsolute(relativePath) + ) { + throw new Error(`Backup path escapes backup directory: ${resolvedPath}`); + } + return resolvedPath; +} + async function resolveNamedBackupRestorePath(name: string): Promise { + const requested = (name ?? "").trim(); + const backupRoot = getNamedBackupRoot(getStoragePath()); const existingPath = await findExistingNamedBackupPath(name); if (existingPath) { - return existingPath; + return assertNamedBackupRestorePath(existingPath, backupRoot); } - const requested = (name ?? "").trim(); - const backupRoot = getNamedBackupRoot(getStoragePath()); const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; try { - return buildNamedBackupPath(name); + return assertNamedBackupRestorePath(buildNamedBackupPath(name), backupRoot); } catch (error) { const baseName = requestedWithExtension.slice(0, -".json".length); if ( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2ca88ca0..ddf05b43 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3104,6 +3104,46 @@ describe("codex manager cli commands", () => { expect(backupItems?.[0]?.hint).not.toContain("updated "); }); + it("suppresses invalid backup timestamps in restore hints", async () => { + setInteractiveTTY(true); + loadAccountsMock.mockResolvedValue(null); + const assessment = { + backup: { + name: "nan-backup", + path: "/mock/backups/nan-backup.json", + 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)); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7f2fff9c..4316ef99 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1394,6 +1394,39 @@ describe("storage", () => { }, ); + 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("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"); From 9ebc86e6324a348b1c37e51f8f26eb1e8ddd8c1a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:22:41 +0800 Subject: [PATCH 22/35] fix(auth): tighten backup restore edge cases --- lib/codex-manager.ts | 10 +- lib/storage.ts | 25 ++++- test/codex-manager-cli.test.ts | 173 ++++++++++++++++++++++++++++++++- test/storage.test.ts | 78 +++++++++++++++ 4 files changed, 275 insertions(+), 11 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7bca81a3..dbc6863f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -60,7 +60,7 @@ import { importAccounts, getNamedBackupsDirectoryPath, listNamedBackups, - NAMED_BACKUP_LIST_CONCURRENCY, + NAMED_BACKUP_ASSESS_CONCURRENCY, findMatchingAccountIndex, getStoragePath, loadFlaggedAccounts, @@ -4242,9 +4242,9 @@ async function runBackupRestoreManager( for ( let index = 0; index < backups.length; - index += NAMED_BACKUP_LIST_CONCURRENCY + index += NAMED_BACKUP_ASSESS_CONCURRENCY ) { - const chunk = backups.slice(index, index + NAMED_BACKUP_LIST_CONCURRENCY); + const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => assessNamedBackupRestore(backup.name, { currentStorage }), @@ -4348,6 +4348,10 @@ async function runBackupRestoreManager( try { const result = await importAccounts(latestAssessment.backup.path); + if (result.imported === 0) { + console.log("All accounts in this backup already exist"); + return; + } console.log( UI_COPY.mainMenu.restoreBackupSuccess( latestAssessment.backup.name, diff --git a/lib/storage.ts b/lib/storage.ts index 5162bb87..c2f40b14 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,7 +47,11 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; -export const NAMED_BACKUP_LIST_CONCURRENCY = 8; +const NAMED_BACKUP_PARALLELISM = 8; +export const NAMED_BACKUP_LIST_CONCURRENCY = NAMED_BACKUP_PARALLELISM; +// Keep assessment fan-out on the same ceiling unless both call sites are retuned +// together, since each assessment performs multiple filesystem operations. +export const NAMED_BACKUP_ASSESS_CONCURRENCY = NAMED_BACKUP_PARALLELISM; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -1611,7 +1615,11 @@ export async function listNamedBackups(): Promise { )), ); } - return backups.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); + return backups.sort((left, right) => { + const leftTime = Number.isFinite(left.updatedAt) ? left.updatedAt : 0; + const rightTime = Number.isFinite(right.updatedAt) ? right.updatedAt : 0; + return rightTime - leftTime; + }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -1892,6 +1900,10 @@ async function resolveNamedBackupRestorePath(name: string): Promise { return assertNamedBackupRestorePath(buildNamedBackupPath(name), backupRoot); } catch (error) { const baseName = requestedWithExtension.slice(0, -".json".length); + // 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 && @@ -2807,6 +2819,12 @@ export async function importAccounts( } const deduplicatedAccounts = deduplicateAccounts(merged); + const imported = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + + if (imported === 0) { + return { imported, total: deduplicatedAccounts.length, skipped }; + } const newStorage: AccountStorageV3 = { version: 3, @@ -2816,9 +2834,6 @@ 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 }; }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ddf05b43..21ca95f7 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2618,6 +2618,79 @@ describe("codex manager cli commands", () => { } }); + 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: "/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); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + }); + 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( + "/mock/backups/named-backup.json", + ); + 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( @@ -2721,11 +2794,11 @@ describe("codex manager cli commands", () => { it("limits concurrent backup assessments in the restore menu", async () => { setInteractiveTTY(true); loadAccountsMock.mockResolvedValue(null); - const { NAMED_BACKUP_LIST_CONCURRENCY } = + const { NAMED_BACKUP_ASSESS_CONCURRENCY } = await vi.importActual( "../lib/storage.js", ); - const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 3; + const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; const backups = Array.from({ length: totalBackups }, (_value, index) => ({ name: `named-backup-${index + 1}`, path: `/mock/backups/named-backup-${index + 1}.json`, @@ -2791,7 +2864,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(backups.length); expect(maxInFlight).toBeLessThanOrEqual( - NAMED_BACKUP_LIST_CONCURRENCY, + NAMED_BACKUP_ASSESS_CONCURRENCY, ); }); @@ -3104,6 +3177,100 @@ describe("codex manager cli commands", () => { 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: "/mock/backups/today-backup.json", + createdAt: null, + updatedAt: now - 1_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "yesterday-backup", + path: "/mock/backups/yesterday-backup.json", + 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: "/mock/backups/three-days-backup.json", + createdAt: null, + updatedAt: now - 3 * 86_400_000, + sizeBytes: 128, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + { + name: "older-backup", + path: "/mock/backups/older-backup.json", + 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); diff --git a/test/storage.test.ts b/test/storage.test.ts index 4316ef99..c3e757e3 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -434,6 +434,33 @@ 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 }); + expect(writeFileSpy).not.toHaveBeenCalled(); + } 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 = { @@ -1849,6 +1876,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[] = []; const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; From da40e702e3b0b3fffb3a0fe01f135ac0743b782a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:36:11 +0800 Subject: [PATCH 23/35] fix(storage): preserve duplicate-only metadata updates --- lib/codex-manager.ts | 2 +- lib/storage.ts | 54 ++++++++++++++++++++--- test/codex-manager-cli.test.ts | 2 + test/storage.test.ts | 79 ++++++++++++++++++++++++++++++++-- 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index dbc6863f..65a8b957 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4348,7 +4348,7 @@ async function runBackupRestoreManager( try { const result = await importAccounts(latestAssessment.backup.path); - if (result.imported === 0) { + if (!result.changed) { console.log("All accounts in this backup already exist"); return; } diff --git a/lib/storage.ts b/lib/storage.ts index c2f40b14..e2e0cd58 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1745,7 +1745,7 @@ export async function assessNamedBackupRestore( export async function restoreNamedBackup( name: string, -): Promise<{ imported: number; total: number; skipped: number }> { +): Promise { const assessment = await assessNamedBackupRestore(name); if (!assessment.eligibleForRestore) { throw new Error( @@ -1768,6 +1768,28 @@ function parseAndNormalizeStorage(data: unknown): { return { normalized, storedVersion, schemaErrors }; } +export type ImportAccountsResult = { + imported: number; + total: number; + skipped: number; + changed: boolean; +}; + +function haveEquivalentAccountRows( + left: readonly unknown[], + right: readonly unknown[], +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) { + return false; + } + } + return true; +} + async function loadAccountsFromPath(path: string): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; @@ -2777,7 +2799,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 @@ -2803,6 +2825,7 @@ export async function importAccounts( imported: importedCount, total, skipped: skippedCount, + changed, } = await withAccountStorageTransaction(async (existing, persist) => { const existingAccounts = existing?.accounts ?? []; const existingActiveIndex = existing?.activeIndex ?? 0; @@ -2821,9 +2844,18 @@ export async function importAccounts( const deduplicatedAccounts = deduplicateAccounts(merged); const imported = deduplicatedAccounts.length - existingAccounts.length; const skipped = normalized.accounts.length - imported; + const changed = !haveEquivalentAccountRows( + deduplicatedAccounts, + existingAccounts, + ); - if (imported === 0) { - return { imported, total: deduplicatedAccounts.length, skipped }; + if (!changed) { + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; } const newStorage: AccountStorageV3 = { @@ -2834,7 +2866,12 @@ export async function importAccounts( }; await persist(newStorage); - return { imported, total: deduplicatedAccounts.length, skipped }; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + changed, + }; }); log.info("Imported accounts", { @@ -2844,5 +2881,10 @@ export async function importAccounts( total, }); - return { imported: importedCount, total, skipped: skippedCount }; + return { + imported: importedCount, + total, + skipped: skippedCount, + changed, + }; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 21ca95f7..431abf6f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -535,6 +535,7 @@ describe("codex manager cli commands", () => { imported: 1, skipped: 0, total: 1, + changed: true, }); confirmMock.mockResolvedValue(true); withAccountStorageTransactionMock.mockImplementation( @@ -2665,6 +2666,7 @@ describe("codex manager cli commands", () => { imported: 0, skipped: 1, total: 1, + changed: false, }); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) diff --git a/test/storage.test.ts b/test/storage.test.ts index c3e757e3..ef2e132b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -454,13 +454,69 @@ describe("storage", () => { const writeFileSpy = vi.spyOn(fs, "writeFile"); try { const result = await importAccounts(exportPath); - expect(result).toEqual({ imported: 0, skipped: 1, total: 1 }); + expect(result).toEqual({ + imported: 0, + skipped: 1, + total: 1, + changed: false, + }); expect(writeFileSpy).not.toHaveBeenCalled(); } finally { writeFileSpy.mockRestore(); } }); + 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 preserve distinct shared-accountId imports when the imported row has no email", async () => { const { importAccounts } = await import("../lib/storage.js"); const existing = { @@ -506,7 +562,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([ @@ -557,7 +618,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", @@ -603,7 +669,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", From b107ba89718f40dcabba0a2ef07ac3a8dc4a977e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:07:43 +0800 Subject: [PATCH 24/35] fix(storage): align restore assessment with import semantics --- lib/storage.ts | 21 ++++---- test/storage.test.ts | 121 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index e2e0cd58..45fe8ade 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1725,7 +1725,8 @@ export async function assessNamedBackupRestore( const skipped = wouldExceedLimit ? null : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); - const nothingToImport = !wouldExceedLimit && imported === 0; + const changed = !haveEquivalentAccountRows(mergedAccounts, currentAccounts); + const nothingToImport = !wouldExceedLimit && !changed; return { backup, @@ -1775,6 +1776,9 @@ export type ImportAccountsResult = { changed: boolean; }; +// NOTE: comparison is order-sensitive. This early-exit relies on +// deduplicateAccounts preserving the existing-first ordering from +// [...existingAccounts, ...normalized.accounts]. function haveEquivalentAccountRows( left: readonly unknown[], right: readonly unknown[], @@ -2831,17 +2835,12 @@ export async function importAccounts( 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); + 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 - existingAccounts.length; const skipped = normalized.accounts.length - imported; const changed = !haveEquivalentAccountRows( diff --git a/test/storage.test.ts b/test/storage.test.ts index ef2e132b..4bc3940c 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1053,6 +1053,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 { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); @@ -1344,6 +1411,60 @@ describe("storage", () => { ); }); + 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), From 72b4367fcf833803e07c6457a772e59dfecd67d0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:30:51 +0800 Subject: [PATCH 25/35] fix: close remaining backup restore review gaps --- lib/storage.ts | 52 ++++++++++++++++++++----- lib/ui/copy.ts | 4 +- test/codex-manager-cli.test.ts | 69 ++++++++++++++++++++++++++++++++++ test/storage.test.ts | 34 +++++++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 45fe8ade..ee7fe691 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1616,8 +1616,22 @@ export async function listNamedBackups(): Promise { ); } return backups.sort((left, right) => { - const leftTime = Number.isFinite(left.updatedAt) ? left.updatedAt : 0; - const rightTime = Number.isFinite(right.updatedAt) ? right.updatedAt : 0; + // 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) { @@ -1648,7 +1662,8 @@ function isRetryableFilesystemErrorCode( async function retryTransientFilesystemOperation( operation: () => Promise, ): Promise { - for (let attempt = 0; attempt < 5; attempt += 1) { + let attempt = 0; + while (true) { try { return await operation(); } catch (error) { @@ -1662,9 +1677,8 @@ async function retryTransientFilesystemOperation( setTimeout(resolve, baseDelayMs + jitterMs), ); } + attempt += 1; } - - throw new Error("Retry loop exhausted unexpectedly"); } export function getNamedBackupsDirectoryPath(): string { @@ -1776,9 +1790,26 @@ export type ImportAccountsResult = { changed: boolean; }; -// NOTE: comparison is order-sensitive. This early-exit relies on -// deduplicateAccounts preserving the existing-first ordering from -// [...existingAccounts, ...normalized.accounts]. +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[], @@ -1787,7 +1818,10 @@ function haveEquivalentAccountRows( return false; } for (let index = 0; index < left.length; index += 1) { - if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) { + if ( + stableStringifyForComparison(left[index]) !== + stableStringifyForComparison(right[index]) + ) { return false; } } diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 5f29767a..3f2d32a7 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -23,7 +23,9 @@ export const UI_COPY = { currentAccountCount: number, mergedAccountCount: number, ) => - `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, + netNewAccounts === 0 + ? `Restore backup "${name}"? This will refresh stored metadata for ${backupAccountCount} existing account(s).` + : `Restore backup "${name}"? This will add ${netNewAccounts} new account(s) (${backupAccountCount} in backup, ${currentAccountCount} current -> ${mergedAccountCount} after dedupe).`, restoreBackupSuccess: ( name: string, imported: number, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 431abf6f..6d176066 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2958,6 +2958,75 @@ describe("codex manager cli commands", () => { ); }); + 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, + }, + ], + }); + 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: 1, + imported: 0, + skipped: 1, + wouldExceedLimit: false, + eligibleForRestore: true, + error: undefined, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + importAccountsMock.mockResolvedValueOnce({ + imported: 0, + skipped: 1, + total: 1, + changed: true, + }); + 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(confirmMock).toHaveBeenCalledWith( + expect.stringContaining("refresh stored metadata"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); + }); + it("returns to the login menu when backup reassessment becomes ineligible", async () => { setInteractiveTTY(true); const now = Date.now(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 4bc3940c..c53e251a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -517,6 +517,40 @@ describe("storage", () => { }); }); + 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, + }); + expect(writeFileSpy).not.toHaveBeenCalled(); + } 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 = { From a80c1e319c7e087d480bf7db278555269ae21067 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:49:06 +0800 Subject: [PATCH 26/35] fix: reuse listed backups during restore assessment --- lib/storage.ts | 22 +++++++++++++++------- test/storage.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index ee7fe691..7bdfc714 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -144,6 +144,15 @@ export interface BackupRestoreAssessment { error?: string; } +type LoadedBackupCandidate = { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; + error?: string; +}; + +const namedBackupCandidateCache = new Map(); + /** * Custom error class for storage operations with platform-aware hints. */ @@ -1585,6 +1594,7 @@ export async function getRestoreAssessment(): Promise { export async function listNamedBackups(): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); try { + namedBackupCandidateCache.clear(); const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); @@ -1606,6 +1616,7 @@ export async function listNamedBackups(): Promise { chunk.map(async (entry) => { const path = resolvePath(join(backupRoot, entry.name)); const candidate = await loadBackupCandidate(path); + namedBackupCandidateCache.set(path, candidate); return buildNamedBackupMetadata( entry.name.slice(0, -".json".length), path, @@ -1703,7 +1714,9 @@ export async function assessNamedBackupRestore( options: { currentStorage?: AccountStorageV3 | null } = {}, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); - const candidate = await loadBackupCandidate(backupPath); + const candidate = + namedBackupCandidateCache.get(backupPath) ?? (await loadBackupCandidate(backupPath)); + namedBackupCandidateCache.delete(backupPath); const backup = await buildNamedBackupMetadata( basename(backupPath).slice(0, -".json".length), backupPath, @@ -1838,12 +1851,7 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } -async function loadBackupCandidate(path: string): Promise<{ - normalized: AccountStorageV3 | null; - storedVersion: unknown; - schemaErrors: string[]; - error?: string; -}> { +async function loadBackupCandidate(path: string): Promise { try { return await retryTransientFilesystemOperation(() => loadAccountsFromPath(path), diff --git a/test/storage.test.ts b/test/storage.test.ts index c53e251a..8d81c541 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2153,6 +2153,42 @@ describe("storage", () => { } }); + 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"); + + try { + await listNamedBackups(); + await assessNamedBackupRestore("cached-backup", { currentStorage: null }); + + 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("limits concurrent backup reads while listing backups", async () => { const backupPaths: string[] = []; const totalBackups = NAMED_BACKUP_LIST_CONCURRENCY + 4; From edd1dc66da9b403a69d0089c589622cbba09a00c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:54:46 +0800 Subject: [PATCH 27/35] test: harden win32 stat retry coverage --- test/storage.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/storage.test.ts b/test/storage.test.ts index 8d81c541..ae4a154b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2062,7 +2062,10 @@ describe("storage", () => { } }); - it("retries transient backup stat errors while listing backups", async () => { + it("retries transient backup stat errors while listing backups on win32", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); await saveAccounts({ version: 3, activeIndex: 0, @@ -2099,6 +2102,7 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { statSpy.mockRestore(); + platformSpy.mockRestore(); } }); From 28ab4300803e6367f49f75d45a31fb7e59143fed Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:14:17 +0800 Subject: [PATCH 28/35] test: guard win32 platform spy cleanup --- test/storage.test.ts | 64 +++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/test/storage.test.ts b/test/storage.test.ts index ae4a154b..04c00830 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2063,36 +2063,38 @@ describe("storage", () => { }); it("retries transient backup stat 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-stat", - refreshToken: "ref-retry-stat", - addedAt: 1, - lastUsed: 1, - }, - ], - }); - 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; - 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)); - }); - + let platformSpy: ReturnType | undefined; + let statSpy: ReturnType | undefined; try { + platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + 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([ @@ -2101,8 +2103,8 @@ describe("storage", () => { ); expect(busyFailures).toBe(1); } finally { - statSpy.mockRestore(); - platformSpy.mockRestore(); + statSpy?.mockRestore(); + platformSpy?.mockRestore(); } }); From 58e3fd0999485e40f62ec030dd8ed66bd884df4d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:30:15 +0800 Subject: [PATCH 29/35] fix restore review regressions --- lib/codex-manager.ts | 8 +++- lib/storage.ts | 29 +++++++---- test/codex-manager-cli.test.ts | 54 +++++++++++---------- test/storage.test.ts | 88 +++++++++++++++++++++++++++++++++- 4 files changed, 142 insertions(+), 37 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 65a8b957..9d3e139c 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4220,9 +4220,10 @@ async function runBackupRestoreManager( displaySettings: DashboardDisplaySettings, ): Promise { const backupDir = getNamedBackupsDirectoryPath(); + const candidateCache = new Map(); let backups: Awaited>; try { - backups = await listNamedBackups(); + backups = await listNamedBackups({ candidateCache }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error( @@ -4247,7 +4248,10 @@ async function runBackupRestoreManager( const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }), + assessNamedBackupRestore(backup.name, { + currentStorage, + candidateCache, + }), ), ); for (const [resultIndex, result] of settledAssessments.entries()) { diff --git a/lib/storage.ts b/lib/storage.ts index 7bdfc714..061d3331 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -151,7 +151,13 @@ type LoadedBackupCandidate = { error?: string; }; -const namedBackupCandidateCache = new Map(); +type NamedBackupCandidateCache = Map; + +function getNamedBackupCandidateCache( + candidateCache: Map | undefined, +): NamedBackupCandidateCache | undefined { + return candidateCache as NamedBackupCandidateCache | undefined; +} /** * Custom error class for storage operations with platform-aware hints. @@ -1591,10 +1597,12 @@ export async function getRestoreAssessment(): Promise { }; } -export async function listNamedBackups(): Promise { +export async function listNamedBackups( + options: { candidateCache?: Map } = {}, +): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); + const candidateCache = getNamedBackupCandidateCache(options.candidateCache); try { - namedBackupCandidateCache.clear(); const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), ); @@ -1616,7 +1624,7 @@ export async function listNamedBackups(): Promise { chunk.map(async (entry) => { const path = resolvePath(join(backupRoot, entry.name)); const candidate = await loadBackupCandidate(path); - namedBackupCandidateCache.set(path, candidate); + candidateCache?.set(path, candidate); return buildNamedBackupMetadata( entry.name.slice(0, -".json".length), path, @@ -1711,12 +1719,16 @@ export async function createNamedBackup( export async function assessNamedBackupRestore( name: string, - options: { currentStorage?: AccountStorageV3 | null } = {}, + options: { + currentStorage?: AccountStorageV3 | null; + candidateCache?: Map; + } = {}, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); + const candidateCache = getNamedBackupCandidateCache(options.candidateCache); const candidate = - namedBackupCandidateCache.get(backupPath) ?? (await loadBackupCandidate(backupPath)); - namedBackupCandidateCache.delete(backupPath); + candidateCache?.get(backupPath) ?? (await loadBackupCandidate(backupPath)); + candidateCache?.delete(backupPath); const backup = await buildNamedBackupMetadata( basename(backupPath).slice(0, -".json".length), backupPath, @@ -1944,9 +1956,10 @@ function assertNamedBackupRestorePath( ): string { const resolvedPath = resolvePath(path); const relativePath = relative(resolvePath(backupRoot), resolvedPath); + const firstSegment = relativePath.split(/[\\/]/)[0]; if ( relativePath.length === 0 || - relativePath.startsWith("..") || + firstSegment === ".." || isAbsolute(relativePath) ) { throw new Error(`Backup path escapes backup directory: ${resolvedPath}`); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6d176066..4b6947af 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3178,34 +3178,38 @@ describe("codex manager cli commands", () => { }); 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", "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(importAccountsMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Restore failed: backup busy"), - ); + ); + 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 () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 04c00830..14b8735b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1647,6 +1647,35 @@ describe("storage", () => { }, ); + 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); @@ -2174,10 +2203,14 @@ describe("storage", () => { }); const backup = await createNamedBackup("cached-backup"); const readFileSpy = vi.spyOn(fs, "readFile"); + const candidateCache = new Map(); try { - await listNamedBackups(); - await assessNamedBackupRestore("cached-backup", { currentStorage: null }); + await listNamedBackups({ candidateCache }); + await assessNamedBackupRestore("cached-backup", { + currentStorage: null, + candidateCache, + }); const firstPassReads = readFileSpy.mock.calls.filter( ([path]) => path === backup.path, @@ -2195,6 +2228,57 @@ describe("storage", () => { } }); + 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; From 082b061a0dc1ea95d12e4107800fd79ee1f2fea9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:42:27 +0800 Subject: [PATCH 30/35] fix backup restore review followups --- lib/codex-manager.ts | 9 +++++++++ lib/storage.ts | 5 ++++- lib/ui/copy.ts | 2 ++ test/codex-manager-cli.test.ts | 29 ++++++++++++++++++++--------- test/storage.test.ts | 31 +++++++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 9d3e139c..4a6bbf18 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4356,6 +4356,15 @@ async function runBackupRestoreManager( console.log("All accounts in this backup already exist"); return; } + if (result.imported === 0) { + console.log( + UI_COPY.mainMenu.restoreBackupRefreshSuccess( + latestAssessment.backup.name, + result.skipped, + ), + ); + return; + } console.log( UI_COPY.mainMenu.restoreBackupSuccess( latestAssessment.backup.name, diff --git a/lib/storage.ts b/lib/storage.ts index 061d3331..7a40ce28 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1622,7 +1622,10 @@ export async function listNamedBackups( backups.push( ...(await Promise.all( chunk.map(async (entry) => { - const path = resolvePath(join(backupRoot, entry.name)); + const path = assertNamedBackupRestorePath( + resolvePath(join(backupRoot, entry.name)), + backupRoot, + ); const candidate = await loadBackupCandidate(path); candidateCache?.set(path, candidate); return buildNamedBackupMetadata( diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 3f2d32a7..3c731fab 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -33,6 +33,8 @@ export const UI_COPY = { total: number, ) => `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, + restoreBackupRefreshSuccess: (name: string, refreshed: number) => + `Restored backup "${name}". Refreshed metadata for ${refreshed} existing account(s).`, dangerZone: "Danger Zone", removeAllAccounts: "Delete Saved Accounts", resetLocalState: "Reset Local State", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 4b6947af..360ae44f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3014,17 +3014,28 @@ describe("codex manager cli commands", () => { type: "restore", assessment, }); + const logSpy = vi.spyOn(console, "log").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(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("refresh stored metadata"), - ); - expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", - ); + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledWith( + expect.stringContaining("refresh stored metadata"), + ); + expect(importAccountsMock).toHaveBeenCalledWith( + "/mock/backups/named-backup.json", + ); + expect(logSpy).toHaveBeenCalledWith( + 'Restored backup "named-backup". Refreshed metadata for 1 existing account(s).', + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Imported 0, skipped 1"), + ); + } finally { + logSpy.mockRestore(); + } }); it("returns to the login menu when backup reassessment becomes ineligible", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 14b8735b..ff5ddd18 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1709,6 +1709,37 @@ describe("storage", () => { } }); + 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"); From da5c402fa6f496d3dec54028fb66d3a336239173 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 04:26:16 +0800 Subject: [PATCH 31/35] fix restore review follow-ups --- lib/codex-manager.ts | 32 ++++++--- lib/storage.ts | 77 +++++++++++++-------- lib/ui/copy.ts | 6 +- test/cli.test.ts | 10 +++ test/codex-manager-cli.test.ts | 64 +++++++++++++++--- test/storage.test.ts | 118 ++++++++++++++++++++++++++++----- 6 files changed, 244 insertions(+), 63 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4a6bbf18..a755d61f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4360,19 +4360,33 @@ async function runBackupRestoreManager( 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, ), ); - return; } - 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" + }`, + ); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error( diff --git a/lib/storage.ts b/lib/storage.ts index 7a40ce28..74f2caa3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1619,23 +1619,34 @@ export async function listNamedBackups( index, index + NAMED_BACKUP_LIST_CONCURRENCY, ); - backups.push( - ...(await Promise.all( - 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 }, - ); - }), - )), + 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; + } + log.warn("Skipped named backup during listing", { + path: join(backupRoot, chunk[chunkIndex]?.name ?? ""), + error: String(result.reason), + }); + } } return backups.sort((left, right) => { // Treat epoch (0), null, and non-finite mtimes as "unknown" so the @@ -1672,13 +1683,10 @@ export async function listNamedBackups( function isRetryableFilesystemErrorCode( code: string | undefined, ): code is "EPERM" | "EBUSY" | "EAGAIN" | "ENOTEMPTY" { - if (code === "EBUSY" || code === "ENOTEMPTY") { + if (code === "EBUSY" || code === "ENOTEMPTY" || code === "EAGAIN") { return true; } - return ( - (code === "EPERM" || code === "EAGAIN") && - process.platform === "win32" - ); + return code === "EPERM" && process.platform === "win32"; } async function retryTransientFilesystemOperation( @@ -1818,6 +1826,11 @@ export type ImportAccountsResult = { 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)); @@ -1970,6 +1983,13 @@ function assertNamedBackupRestorePath( return resolvedPath; } +function isNamedBackupContainmentError(error: unknown): boolean { + return ( + error instanceof Error && + /escapes backup directory/i.test(error.message) + ); +} + async function resolveNamedBackupRestorePath(name: string): Promise { const requested = (name ?? "").trim(); const backupRoot = getNamedBackupRoot(getStoragePath()); @@ -1980,10 +2000,11 @@ async function resolveNamedBackupRestorePath(name: string): Promise { const requestedWithExtension = requested.toLowerCase().endsWith(".json") ? requested : `${requested}.json`; + const baseName = requestedWithExtension.slice(0, -".json".length); + let builtPath: string; try { - return assertNamedBackupRestorePath(buildNamedBackupPath(name), backupRoot); + builtPath = buildNamedBackupPath(name); } catch (error) { - const baseName = requestedWithExtension.slice(0, -".json".length); // 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 @@ -2000,6 +2021,7 @@ async function resolveNamedBackupRestorePath(name: string): Promise { } throw error; } + return assertNamedBackupRestorePath(builtPath, backupRoot); } async function loadAccountsFromJournal( @@ -2816,9 +2838,10 @@ export async function exportAccounts( } const transactionState = transactionSnapshotContext.getStore(); - const currentStoragePath = getStoragePath(); + const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); const storage = transactionState?.active - ? transactionState.storagePath === currentStoragePath + ? normalizeStoragePathForComparison(transactionState.storagePath) === + currentStoragePath ? transactionState.snapshot : (() => { throw new Error( @@ -2869,7 +2892,9 @@ export async function importAccounts( throw new Error(`Import file not found: ${resolvedPath}`); } - const content = await fs.readFile(resolvedPath, "utf-8"); + const content = await retryTransientFilesystemOperation(() => + fs.readFile(resolvedPath, "utf-8"), + ); let imported: unknown; try { diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 3c731fab..1b14d107 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -24,7 +24,7 @@ export const UI_COPY = { mergedAccountCount: number, ) => netNewAccounts === 0 - ? `Restore backup "${name}"? This will refresh stored metadata for ${backupAccountCount} existing account(s).` + ? `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, @@ -33,8 +33,8 @@ export const UI_COPY = { total: number, ) => `Restored backup "${name}". Imported ${imported}, skipped ${skipped}, total ${total}.`, - restoreBackupRefreshSuccess: (name: string, refreshed: number) => - `Restored backup "${name}". Refreshed metadata for ${refreshed} existing account(s).`, + 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 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 360ae44f..e9ecdae5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -465,6 +465,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + setCodexCliActiveSelectionMock.mockResolvedValue(true); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -2433,7 +2434,27 @@ describe("codex manager cli commands", () => { it("offers backup restore from the login menu when no accounts are saved", async () => { setInteractiveTTY(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue(null); + 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", @@ -2475,6 +2496,14 @@ describe("codex manager cli commands", () => { expect(importAccountsMock).toHaveBeenCalledWith( "/mock/backups/named-backup.json", ); + 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 () => { @@ -2976,6 +3005,16 @@ describe("codex manager cli commands", () => { 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 = { @@ -2986,15 +3025,15 @@ describe("codex manager cli commands", () => { updatedAt: now, sizeBytes: 128, version: 3, - accountCount: 1, + accountCount: 2, schemaErrors: [], valid: true, loadError: undefined, }, - currentAccountCount: 1, - mergedAccountCount: 1, + currentAccountCount: 2, + mergedAccountCount: 2, imported: 0, - skipped: 1, + skipped: 2, wouldExceedLimit: false, eligibleForRestore: true, error: undefined, @@ -3003,8 +3042,8 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue(assessment); importAccountsMock.mockResolvedValueOnce({ imported: 0, - skipped: 1, - total: 1, + skipped: 2, + total: 2, changed: true, }); promptLoginModeMock @@ -3022,16 +3061,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(confirmMock).toHaveBeenCalledWith( - expect.stringContaining("refresh stored metadata"), + expect.stringContaining( + "refresh stored metadata for matching existing account(s)", + ), + ); + expect(confirmMock).not.toHaveBeenCalledWith( + expect.stringContaining("for 2 existing account(s)"), ); expect(importAccountsMock).toHaveBeenCalledWith( "/mock/backups/named-backup.json", ); expect(logSpy).toHaveBeenCalledWith( - 'Restored backup "named-backup". Refreshed metadata for 1 existing account(s).', + 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', ); expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Imported 0, skipped 1"), + expect.stringContaining("Imported 0, skipped 2"), ); } finally { logSpy.mockRestore(); diff --git a/test/storage.test.ts b/test/storage.test.ts index ff5ddd18..a4e9cde6 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -398,6 +398,41 @@ 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"); @@ -460,7 +495,14 @@ describe("storage", () => { total: 1, changed: false, }); - expect(writeFileSpy).not.toHaveBeenCalled(); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); } finally { writeFileSpy.mockRestore(); } @@ -545,7 +587,14 @@ describe("storage", () => { total: 1, changed: false, }); - expect(writeFileSpy).not.toHaveBeenCalled(); + const storageWrites = writeFileSpy.mock.calls.filter(([targetPath]) => { + const target = String(targetPath); + return ( + target === testStoragePath || + target.startsWith(`${testStoragePath}.`) + ); + }); + expect(storageWrites).toHaveLength(0); } finally { writeFileSpy.mockRestore(); } @@ -1171,6 +1220,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 {["); @@ -1834,7 +1928,7 @@ describe("storage", () => { } }); - it("rethrows EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { + it("retries EAGAIN backup directory errors while restoring backups on non-Windows platforms", async () => { const platformSpy = vi .spyOn(process, "platform", "get") .mockReturnValue("linux"); @@ -1847,7 +1941,7 @@ describe("storage", () => { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ code: "EAGAIN", }); - expect(readdirSpy).toHaveBeenCalledTimes(1); + expect(readdirSpy).toHaveBeenCalledTimes(5); } finally { readdirSpy.mockRestore(); platformSpy.mockRestore(); @@ -1999,10 +2093,7 @@ describe("storage", () => { } }); - it("retries transient EAGAIN backup directory errors while restoring backups on win32", async () => { - const platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); + it("retries transient EAGAIN backup directory errors while restoring backups", async () => { await saveAccounts({ version: 3, activeIndex: 0, @@ -2041,7 +2132,6 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); - platformSpy.mockRestore(); } }); @@ -2072,12 +2162,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 backup read errors while listing backups", async () => { @@ -2122,13 +2215,9 @@ describe("storage", () => { } }); - it("retries transient backup stat errors while listing backups on win32", async () => { - let platformSpy: ReturnType | undefined; + it("retries transient backup stat EAGAIN errors while listing backups", async () => { let statSpy: ReturnType | undefined; try { - platformSpy = vi - .spyOn(process, "platform", "get") - .mockReturnValue("win32"); await saveAccounts({ version: 3, activeIndex: 0, @@ -2164,7 +2253,6 @@ describe("storage", () => { expect(busyFailures).toBe(1); } finally { statSpy?.mockRestore(); - platformSpy?.mockRestore(); } }); From bd0e9442b8bc58815981adf5c7d5ceafac2df63c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 04:42:03 +0800 Subject: [PATCH 32/35] fix restore path guard follow-ups --- lib/codex-manager.ts | 9 ++- lib/storage.ts | 23 ++++-- test/codex-manager-cli.test.ts | 135 ++++++++++++++++++++++++++------- test/storage.test.ts | 55 ++++++++++++++ 4 files changed, 185 insertions(+), 37 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a755d61f..18934642 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -57,6 +57,7 @@ import { } from "./quota-cache.js"; import { assessNamedBackupRestore, + assertNamedBackupRestorePath, importAccounts, getNamedBackupsDirectoryPath, listNamedBackups, @@ -4220,6 +4221,8 @@ 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 { @@ -4351,7 +4354,11 @@ async function runBackupRestoreManager( if (!confirmed) return; try { - const result = await importAccounts(latestAssessment.backup.path); + const validatedBackupPath = assertNamedBackupRestorePath( + latestAssessment.backup.path, + getNamedBackupsDirectoryPath(), + ); + const result = await importAccounts(validatedBackupPath); if (!result.changed) { console.log("All accounts in this backup already exist"); return; diff --git a/lib/storage.ts b/lib/storage.ts index 74f2caa3..d4500d44 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,11 +47,12 @@ 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 NAMED_BACKUP_PARALLELISM = 8; -export const NAMED_BACKUP_LIST_CONCURRENCY = NAMED_BACKUP_PARALLELISM; -// Keep assessment fan-out on the same ceiling unless both call sites are retuned -// together, since each assessment performs multiple filesystem operations. -export const NAMED_BACKUP_ASSESS_CONCURRENCY = NAMED_BACKUP_PARALLELISM; +const NAMED_BACKUP_LIST_PARALLELISM = 8; +const NAMED_BACKUP_ASSESS_PARALLELISM = 4; +export const NAMED_BACKUP_LIST_CONCURRENCY = NAMED_BACKUP_LIST_PARALLELISM; +// 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 = NAMED_BACKUP_ASSESS_PARALLELISM; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -156,7 +157,10 @@ type NamedBackupCandidateCache = Map; function getNamedBackupCandidateCache( candidateCache: Map | undefined, ): NamedBackupCandidateCache | undefined { - return candidateCache as NamedBackupCandidateCache | undefined; + if (!candidateCache) return undefined; + // Caller contract: only reuse caches created by listNamedBackups() for the + // matching assessNamedBackupRestore() flow. + return candidateCache as NamedBackupCandidateCache; } /** @@ -1855,6 +1859,9 @@ function haveEquivalentAccountRows( left: readonly unknown[], right: readonly unknown[], ): boolean { + // deduplicateAccounts() preserves the existing-first ordering from + // [...currentAccounts, ...incomingAccounts], so index-aligned comparison is + // the correct no-op check for restore assessment/import. if (left.length !== right.length) { return false; } @@ -1966,7 +1973,7 @@ async function findExistingNamedBackupPath( return undefined; } -function assertNamedBackupRestorePath( +export function assertNamedBackupRestorePath( path: string, backupRoot: string, ): string { @@ -1978,7 +1985,7 @@ function assertNamedBackupRestorePath( firstSegment === ".." || isAbsolute(relativePath) ) { - throw new Error(`Backup path escapes backup directory: ${resolvedPath}`); + throw new Error("Backup path escapes backup directory"); } return resolvedPath; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e9ecdae5..fe7359f7 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1,5 +1,10 @@ +import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const MOCK_BACKUP_DIR = resolve(process.cwd(), ".vitest-mock-backups"); +const mockBackupPath = (name: string): string => + resolve(MOCK_BACKUP_DIR, `${name}.json`); + const loadAccountsMock = vi.fn(); const loadFlaggedAccountsMock = vi.fn(); const saveAccountsMock = vi.fn(); @@ -513,7 +518,7 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue({ backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: null, sizeBytes: null, @@ -531,7 +536,7 @@ describe("codex manager cli commands", () => { eligibleForRestore: true, error: undefined, }); - getNamedBackupsDirectoryPathMock.mockReturnValue("/mock/backups"); + getNamedBackupsDirectoryPathMock.mockReturnValue(MOCK_BACKUP_DIR); importAccountsMock.mockResolvedValue({ imported: 1, skipped: 0, @@ -2387,7 +2392,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2427,10 +2432,84 @@ describe("codex manager cli commands", () => { ); expect(confirmMock).toHaveBeenCalledOnce(); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); }); + it("rejects a restore when the reassessed backup path escapes the backup directory", 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, + }; + const escapedAssessment = { + ...assessment, + backup: { + ...assessment.backup, + path: resolve(MOCK_BACKUP_DIR, "../outside.json"), + }, + }; + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + assessNamedBackupRestoreMock + .mockResolvedValueOnce(assessment) + .mockResolvedValueOnce(escapedAssessment); + 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("offers backup restore from the login menu when no accounts are saved", async () => { setInteractiveTTY(true); const now = Date.now(); @@ -2458,7 +2537,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2494,7 +2573,7 @@ describe("codex manager cli commands", () => { expect.objectContaining({ currentStorage: null }), ); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -2529,7 +2608,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2596,7 +2675,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2617,7 +2696,7 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockResolvedValue([assessment.backup]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); importAccountsMock.mockRejectedValueOnce( - new Error("Import file not found: /mock/backups/named-backup.json"), + new Error(`Import file not found: ${mockBackupPath("named-backup")}`), ); promptLoginModeMock .mockResolvedValueOnce({ mode: "restore-backup" }) @@ -2633,7 +2712,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptLoginModeMock).toHaveBeenCalledTimes(2); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); const restoreFailureCalls = [ ...errorSpy.mock.calls, @@ -2671,7 +2750,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2709,7 +2788,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); expect(logSpy).toHaveBeenCalledWith( "All accounts in this backup already exist", @@ -2761,7 +2840,7 @@ describe("codex manager cli commands", () => { const healthyAssessment = { backup: { name: "healthy-backup", - path: "/mock/backups/healthy-backup.json", + path: mockBackupPath("healthy-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2783,7 +2862,7 @@ describe("codex manager cli commands", () => { { ...healthyAssessment.backup, name: "broken-backup", - path: "/mock/backups/broken-backup.json", + path: mockBackupPath("broken-backup"), }, healthyAssessment.backup, ]); @@ -2810,7 +2889,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/healthy-backup.json", + mockBackupPath("healthy-backup"), ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -2832,7 +2911,7 @@ describe("codex manager cli commands", () => { const totalBackups = NAMED_BACKUP_ASSESS_CONCURRENCY + 3; const backups = Array.from({ length: totalBackups }, (_value, index) => ({ name: `named-backup-${index + 1}`, - path: `/mock/backups/named-backup-${index + 1}.json`, + path: mockBackupPath(`named-backup-${index + 1}`), createdAt: null, updatedAt: Date.now() + index, sizeBytes: 128, @@ -2922,7 +3001,7 @@ describe("codex manager cli commands", () => { const initialAssessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -2983,7 +3062,7 @@ describe("codex manager cli commands", () => { expect.stringContaining("add 1 new account(s)"), ); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); }); @@ -3020,7 +3099,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3069,7 +3148,7 @@ describe("codex manager cli commands", () => { expect.stringContaining("for 2 existing account(s)"), ); expect(importAccountsMock).toHaveBeenCalledWith( - "/mock/backups/named-backup.json", + mockBackupPath("named-backup"), ); expect(logSpy).toHaveBeenCalledWith( 'Restored backup "named-backup". Refreshed stored metadata for matching existing account(s).', @@ -3105,7 +3184,7 @@ describe("codex manager cli commands", () => { const initialAssessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3202,7 +3281,7 @@ describe("codex manager cli commands", () => { const initialAssessment = { backup: { name: "named-backup", - path: "/mock/backups/named-backup.json", + path: mockBackupPath("named-backup"), createdAt: null, updatedAt: now, sizeBytes: 128, @@ -3273,7 +3352,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "epoch-backup", - path: "/mock/backups/epoch-backup.json", + path: mockBackupPath("epoch-backup"), createdAt: null, updatedAt: 0, sizeBytes: 128, @@ -3315,7 +3394,7 @@ describe("codex manager cli commands", () => { const backups = [ { name: "today-backup", - path: "/mock/backups/today-backup.json", + path: mockBackupPath("today-backup"), createdAt: null, updatedAt: now - 1_000, sizeBytes: 128, @@ -3327,7 +3406,7 @@ describe("codex manager cli commands", () => { }, { name: "yesterday-backup", - path: "/mock/backups/yesterday-backup.json", + path: mockBackupPath("yesterday-backup"), createdAt: null, updatedAt: now - 1.5 * 86_400_000, sizeBytes: 128, @@ -3339,7 +3418,7 @@ describe("codex manager cli commands", () => { }, { name: "three-days-backup", - path: "/mock/backups/three-days-backup.json", + path: mockBackupPath("three-days-backup"), createdAt: null, updatedAt: now - 3 * 86_400_000, sizeBytes: 128, @@ -3351,7 +3430,7 @@ describe("codex manager cli commands", () => { }, { name: "older-backup", - path: "/mock/backups/older-backup.json", + path: mockBackupPath("older-backup"), createdAt: null, updatedAt: now - 8 * 86_400_000, sizeBytes: 128, @@ -3407,7 +3486,7 @@ describe("codex manager cli commands", () => { const assessment = { backup: { name: "nan-backup", - path: "/mock/backups/nan-backup.json", + path: mockBackupPath("nan-backup"), createdAt: null, updatedAt: Number.NaN, sizeBytes: 128, diff --git a/test/storage.test.ts b/test/storage.test.ts index a4e9cde6..87771851 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1539,6 +1539,61 @@ describe("storage", () => { ); }); + 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, From 5a08e646c879860486a5fbb2970ce60997419ac6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 05:03:18 +0800 Subject: [PATCH 33/35] fix(auth): close remaining restore review gaps --- lib/codex-manager.ts | 5 +- lib/storage.ts | 52 +++++++++++--- test/codex-manager-cli.test.ts | 66 +++++++++++++++++ test/storage.test.ts | 128 +++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 18934642..e155ca9a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4396,8 +4396,11 @@ async function runBackupRestoreManager( } } catch (error) { const message = error instanceof Error ? error.message : String(error); + const collapsedMessage = collapseWhitespace(message) || "unknown error"; console.error( - `Restore failed: ${collapseWhitespace(message) || "unknown error"}`, + /exceed maximum/i.test(collapsedMessage) + ? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.` + : `Restore failed: ${collapsedMessage}`, ); } } diff --git a/lib/storage.ts b/lib/storage.ts index d4500d44..ffdfa989 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,12 +47,10 @@ 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 NAMED_BACKUP_LIST_PARALLELISM = 8; -const NAMED_BACKUP_ASSESS_PARALLELISM = 4; -export const NAMED_BACKUP_LIST_CONCURRENCY = NAMED_BACKUP_LIST_PARALLELISM; +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 = NAMED_BACKUP_ASSESS_PARALLELISM; +export const NAMED_BACKUP_ASSESS_CONCURRENCY = 4; const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -152,15 +150,50 @@ type LoadedBackupCandidate = { error?: string; }; -type NamedBackupCandidateCache = Map; +type NamedBackupCandidateCache = Map; function getNamedBackupCandidateCache( candidateCache: Map | undefined, ): NamedBackupCandidateCache | undefined { if (!candidateCache) return undefined; - // Caller contract: only reuse caches created by listNamedBackups() for the - // matching assessNamedBackupRestore() flow. - return candidateCache as NamedBackupCandidateCache; + return candidateCache; +} + +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; + }; + return ( + "storedVersion" in typedCandidate && + Array.isArray(typedCandidate.schemaErrors) && + (typedCandidate.normalized === null || + typeof typedCandidate.normalized === "object") && + (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; } /** @@ -1742,7 +1775,8 @@ export async function assessNamedBackupRestore( const backupPath = await resolveNamedBackupRestorePath(name); const candidateCache = getNamedBackupCandidateCache(options.candidateCache); const candidate = - candidateCache?.get(backupPath) ?? (await loadBackupCandidate(backupPath)); + getCachedNamedBackupCandidate(candidateCache, backupPath) ?? + (await loadBackupCandidate(backupPath)); candidateCache?.delete(backupPath); const backup = await buildNamedBackupMetadata( basename(backupPath).slice(0, -".json".length), diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index fe7359f7..d1ed467d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2434,6 +2434,7 @@ describe("codex manager cli commands", () => { expect(importAccountsMock).toHaveBeenCalledWith( mockBackupPath("named-backup"), ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledOnce(); }); it("rejects a restore when the reassessed backup path escapes the backup directory", async () => { @@ -2727,6 +2728,71 @@ describe("codex manager cli commands", () => { } }); + 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(); diff --git a/test/storage.test.ts b/test/storage.test.ts index 87771851..667f0482 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2148,6 +2148,89 @@ describe("storage", () => { } }); + it("retries a second-chunk backup read when listing more than one chunk of backups", async () => { + 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-08", + valid: true, + }), + ]), + ); + expect(busyFailures).toBe(1); + expect( + readFileSpy.mock.calls.filter( + ([path]) => String(path) === secondChunkBackup?.path, + ), + ).toHaveLength(2); + } finally { + readFileSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); + it("retries transient EAGAIN backup directory errors while restoring backups", async () => { await saveAccounts({ version: 3, @@ -2402,6 +2485,51 @@ describe("storage", () => { } }); + 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: "invalid" }], + ]); + + 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, From 620d2a66ad6cb418a24c1520c04f449cbf92ea78 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 05:10:42 +0800 Subject: [PATCH 34/35] refactor(storage): inline backup candidate cache usage --- lib/storage.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index ffdfa989..e5273b1b 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -152,13 +152,6 @@ type LoadedBackupCandidate = { type NamedBackupCandidateCache = Map; -function getNamedBackupCandidateCache( - candidateCache: Map | undefined, -): NamedBackupCandidateCache | undefined { - if (!candidateCache) return undefined; - return candidateCache; -} - function isLoadedBackupCandidate( candidate: unknown, ): candidate is LoadedBackupCandidate { @@ -1638,7 +1631,7 @@ export async function listNamedBackups( options: { candidateCache?: Map } = {}, ): Promise { const backupRoot = getNamedBackupRoot(getStoragePath()); - const candidateCache = getNamedBackupCandidateCache(options.candidateCache); + const candidateCache = options.candidateCache; try { const entries = await retryTransientFilesystemOperation(() => fs.readdir(backupRoot, { withFileTypes: true }), @@ -1773,7 +1766,7 @@ export async function assessNamedBackupRestore( } = {}, ): Promise { const backupPath = await resolveNamedBackupRestorePath(name); - const candidateCache = getNamedBackupCandidateCache(options.candidateCache); + const candidateCache = options.candidateCache; const candidate = getCachedNamedBackupCandidate(candidateCache, backupPath) ?? (await loadBackupCandidate(backupPath)); From 4b779ca0bd0bf7a2e269b7e0c85b85c7333f3aa5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 05:33:24 +0800 Subject: [PATCH 35/35] fix(storage): tighten restore retry safeguards --- lib/storage.ts | 17 +++++++++++++---- test/storage.test.ts | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index e5273b1b..0e507feb 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,6 +47,8 @@ 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 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. @@ -1728,10 +1730,13 @@ async function retryTransientFilesystemOperation( return await operation(); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { + if ( + !isRetryableFilesystemErrorCode(code) || + attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 + ) { throw error; } - const baseDelayMs = 10 * 2 ** attempt; + const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt; const jitterMs = Math.floor(Math.random() * 10); await new Promise((resolve) => setTimeout(resolve, baseDelayMs + jitterMs), @@ -1834,7 +1839,11 @@ export async function restoreNamedBackup( assessment.error ?? "Backup is not eligible for restore.", ); } - return importAccounts(assessment.backup.path); + const validatedPath = assertNamedBackupRestorePath( + assessment.backup.path, + getNamedBackupRoot(getStoragePath()), + ); + return importAccounts(validatedPath); } function parseAndNormalizeStorage(data: unknown): { @@ -2959,7 +2968,7 @@ export async function importAccounts( ); } const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; + const skipped = Math.max(0, normalized.accounts.length - imported); const changed = !haveEquivalentAccountRows( deduplicatedAccounts, existingAccounts, diff --git a/test/storage.test.ts b/test/storage.test.ts index 667f0482..1221c54b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1996,7 +1996,7 @@ describe("storage", () => { await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({ code: "EAGAIN", }); - expect(readdirSpy).toHaveBeenCalledTimes(5); + expect(readdirSpy).toHaveBeenCalledTimes(7); } finally { readdirSpy.mockRestore(); platformSpy.mockRestore();