From 6b69b70efb8f708013afa5d0eb4d7c23ace12c99 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 19:14:49 +0800 Subject: [PATCH 01/11] feat(auth): add backup browser --- lib/codex-manager.ts | 296 +++++++++++++++++++++++++-------- lib/storage.ts | 81 ++++++++- test/codex-manager-cli.test.ts | 88 +++------- test/storage.test.ts | 69 +++++++- 4 files changed, 398 insertions(+), 136 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 17dd506..d7f3d83 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -81,9 +81,9 @@ import { getNamedBackupsDirectoryPath, getStoragePath, listNamedBackups, + listRotatingBackups, loadAccounts, loadFlaggedAccounts, - restoreNamedBackup, saveAccounts, saveFlaggedAccounts, setStoragePath, @@ -148,6 +148,28 @@ function formatRelativeDateShort( return new Date(timestamp).toLocaleDateString(); } +function formatDateTimeLong(timestamp: number | null | undefined): string { + if (!timestamp) return "unknown"; + return new Date(timestamp).toLocaleString(); +} + +function formatFileSize(sizeBytes: number | null | undefined): string { + if ( + typeof sizeBytes !== "number" || + !Number.isFinite(sizeBytes) || + sizeBytes < 0 + ) { + return "unknown"; + } + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; +} + function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -4087,93 +4109,231 @@ async function handleManageAction( } } +type NamedBackupAssessment = Awaited< + ReturnType +>; +type NamedBackupEntry = Awaited>[number]; +type RotatingBackupEntry = Awaited< + ReturnType +>[number]; + +type BackupBrowserEntry = + | { + kind: "named"; + label: string; + backup: NamedBackupEntry; + assessment: NamedBackupAssessment; + } + | { + kind: "rotating"; + label: string; + backup: RotatingBackupEntry; + }; + type BackupMenuAction = | { - type: "restore"; - assessment: Awaited>; + type: "inspect"; + entry: BackupBrowserEntry; } | { type: "back" }; -async function runBackupRestoreManager( +function buildBackupBrowserHint(entry: BackupBrowserEntry): string { + const backup = entry.backup; + const lastUpdated = formatRelativeDateShort(backup.updatedAt); + const backupType = + entry.kind === "named" ? "named" : `rotating slot ${entry.backup.slot}`; + const parts = [ + backupType, + backup.valid ? "valid" : "invalid", + backup.accountCount !== null + ? `${backup.accountCount} account${backup.accountCount === 1 ? "" : "s"}` + : undefined, + lastUpdated ? `updated ${lastUpdated}` : undefined, + entry.kind === "named" && entry.assessment.wouldExceedLimit + ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` + : undefined, + backup.loadError, + ].filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ); + return parts.join(" | "); +} + +function backupMenuColor( + entry: BackupBrowserEntry, +): MenuItem["color"] { + return entry.backup.valid ? "green" : "red"; +} + +function buildBackupStatusSummary(entry: BackupBrowserEntry): string { + const backup = entry.backup; + if (!backup.valid) { + return stylePromptText("Invalid backup", "danger"); + } + if (entry.kind === "named" && entry.assessment.wouldExceedLimit) { + return stylePromptText("Valid file, restore would exceed limit", "warning"); + } + return stylePromptText("Valid backup", "success"); +} + +function showBackupBrowserDetails(entry: BackupBrowserEntry): Promise { + const backup = entry.backup; + const typeLabel = + entry.kind === "named" + ? "Named backup" + : `Rotating backup (.bak${entry.backup.slot === 0 ? "" : `.${entry.backup.slot}`})`; + const lines = [ + stylePromptText(entry.label, "accent"), + buildBackupStatusSummary(entry), + stylePromptText(backup.path, "muted"), + "", + `${stylePromptText("Type:", "muted")} ${typeLabel}`, + `${stylePromptText("Accounts:", "muted")} ${backup.accountCount ?? "unknown"}`, + `${stylePromptText("Version:", "muted")} ${backup.version ?? "unknown"}`, + `${stylePromptText("Size:", "muted")} ${formatFileSize(backup.sizeBytes)}`, + `${stylePromptText("Created:", "muted")} ${formatDateTimeLong(backup.createdAt)}`, + `${stylePromptText("Updated:", "muted")} ${formatDateTimeLong(backup.updatedAt)}`, + ]; + + if (entry.kind === "named") { + const assessment = entry.assessment; + lines.push( + "", + stylePromptText("Restore Assessment", "accent"), + `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, + `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, + `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, + `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, + `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.valid ? "Recoverable" : (assessment.error ?? "Unavailable")}`, + ); + } + + if (backup.schemaErrors.length > 0 || backup.loadError) { + lines.push("", stylePromptText("Validation", "accent")); + for (const schemaError of backup.schemaErrors.slice(0, 3)) { + lines.push(`${stylePromptText("-", "muted")} ${schemaError}`); + } + if (backup.schemaErrors.length > 3) { + lines.push( + `${stylePromptText("-", "muted")} ${backup.schemaErrors.length - 3} more schema error${backup.schemaErrors.length - 3 === 1 ? "" : "s"}`, + ); + } + if (backup.loadError) { + lines.push(`${stylePromptText("-", "muted")} ${backup.loadError}`); + } + } + + if (output.isTTY) { + output.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } + for (const line of lines) { + console.log(line); + } + console.log(""); + return waitForMenuReturn(); +} + +async function runBackupBrowserManager( 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}.`); + const [namedBackups, rotatingBackups] = await Promise.all([ + listNamedBackups(), + listRotatingBackups(), + ]); + if (namedBackups.length === 0 && rotatingBackups.length === 0) { + console.log( + `No backups found. Named backups live in ${backupDir}. Rotating backups live next to ${getStoragePath()}.`, + ); return; } const currentStorage = await loadAccounts(); const assessments = await Promise.all( - backups.map((backup) => + namedBackups.map((backup) => assessNamedBackupRestore(backup.name, { currentStorage }), ), ); + const namedEntries: BackupBrowserEntry[] = assessments.map((assessment) => ({ + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + })); + const rotatingEntries: BackupBrowserEntry[] = rotatingBackups.map( + (backup) => ({ + kind: "rotating", + label: backup.label, + backup, + }), + ); - 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, - }; - }); + const ui = getUiRuntimeOptions(); - items.push({ label: "Back", value: { type: "back" } }); + while (true) { + const items: MenuItem[] = [ + { label: "Named Backups", value: { type: "back" }, kind: "heading" }, + ]; + if (namedEntries.length === 0) { + items.push({ + label: "No named backups found", + value: { type: "back" }, + disabled: true, + }); + } else { + items.push( + ...namedEntries.map((entry) => ({ + label: entry.label, + hint: buildBackupBrowserHint(entry), + value: { type: "inspect" as const, entry }, + color: backupMenuColor(entry), + })), + ); + } - const ui = getUiRuntimeOptions(); - const selection = await select(items, { - message: "Restore From Backup", - subtitle: backupDir, - help: UI_COPY.mainMenu.helpCompact, - clearScreen: true, - selectedEmphasis: "minimal", - focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - theme: ui.theme, - }); + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ + label: "Rotating Backups", + value: { type: "back" }, + kind: "heading", + }); + if (rotatingEntries.length === 0) { + items.push({ + label: "No rotating backups found", + value: { type: "back" }, + disabled: true, + }); + } else { + items.push( + ...rotatingEntries.map((entry) => ({ + label: entry.label, + hint: buildBackupBrowserHint(entry), + value: { type: "inspect" as const, entry }, + color: backupMenuColor(entry), + })), + ); + } - if (!selection || selection.type === "back") { - return; - } + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ label: "Back", value: { type: "back" } }); - const assessment = selection.assessment; - if (!assessment.valid || assessment.wouldExceedLimit) { - console.log(assessment.error ?? "Backup is not eligible for restore."); - return; - } + const selection = await select(items, { + message: "Backup Browser", + subtitle: `Named: ${backupDir} | Rotating: ${dirname(getStoragePath())}`, + help: "Enter Inspect | Q Back", + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", + theme: ui.theme, + }); - const confirmMessage = `Restore backup "${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; + if (!selection || selection.type === "back") { + return; + } - const result = await restoreNamedBackup(assessment.backup.name); - console.log( - `Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`, - ); + await showBackupBrowserDetails(selection.entry); + } } async function runAuthLogin(): Promise { @@ -4302,7 +4462,7 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "restore-backup") { - await runBackupRestoreManager(displaySettings); + await runBackupBrowserManager(displaySettings); continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { @@ -4389,10 +4549,10 @@ async function runAuthLogin(): Promise { const restoreNow = await confirm( `Found ${recoveryState.assessments.length} recoverable backup${ recoveryState.assessments.length === 1 ? "" : "s" - } (${backupLabel}) in ${backupDir}. Restore now?`, + } (${backupLabel}) in ${backupDir}. Open backup browser now?`, ); if (restoreNow) { - await runBackupRestoreManager(displaySettings); + await runBackupBrowserManager(displaySettings); continue; } } diff --git a/lib/storage.ts b/lib/storage.ts index 1fbb87f..5d8e6c1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -494,8 +494,7 @@ type AccountsJournalEntry = { content: string; }; -export interface NamedBackupMetadata { - name: string; +interface BackupFileMetadata { path: string; createdAt: number | null; updatedAt: number | null; @@ -507,6 +506,15 @@ export interface NamedBackupMetadata { loadError?: string; } +export interface NamedBackupMetadata extends BackupFileMetadata { + name: string; +} + +export interface RotatingBackupMetadata extends BackupFileMetadata { + label: string; + slot: number; +} + export interface BackupRestoreAssessment { backup: NamedBackupMetadata; currentAccountCount: number; @@ -1246,6 +1254,17 @@ async function buildNamedBackupMetadata( path: string, opts: { candidate?: Awaited> } = {}, ): Promise { + const metadata = await buildBackupFileMetadata(path, opts); + return { + name, + ...metadata, + }; +} + +async function buildBackupFileMetadata( + path: string, + opts: { candidate?: Awaited> } = {}, +): Promise { const candidate = opts.candidate ?? (await loadBackupCandidate(path)); let stats: { size?: number; @@ -1272,7 +1291,6 @@ async function buildNamedBackupMetadata( const updatedAt = stats?.mtimeMs ?? null; return { - name, path, createdAt, updatedAt, @@ -1285,6 +1303,34 @@ async function buildNamedBackupMetadata( }; } +function parseRotatingBackupSlot( + storagePath: string, + candidatePath: string, +): number | null { + const latestBackupPath = getAccountsBackupPath(storagePath); + if (candidatePath === latestBackupPath) { + return 0; + } + + const slotMatch = candidatePath.match(/\.bak\.(\d+)$/i); + if (!slotMatch) { + return null; + } + + const parsed = Number.parseInt(slotMatch[1] ?? "", 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return null; + } + + return parsed; +} + +function formatRotatingBackupLabel(slot: number): string { + return slot === 0 + ? "Latest rotating backup (.bak)" + : `Rotating backup ${slot} (.bak.${slot})`; +} + export async function listNamedBackups(): Promise { const backupDir = getNamedBackupsDirectory(); try { @@ -1313,6 +1359,35 @@ export async function listNamedBackups(): Promise { } } +export async function listRotatingBackups(): Promise { + const storagePath = getStoragePath(); + const candidates = getAccountsBackupRecoveryCandidates(storagePath); + const backups: RotatingBackupMetadata[] = []; + + for (const candidatePath of candidates) { + if (!existsSync(candidatePath)) { + continue; + } + + const slot = parseRotatingBackupSlot(storagePath, candidatePath); + if (slot === null) { + continue; + } + + const candidate = await loadBackupCandidate(candidatePath); + const metadata = await buildBackupFileMetadata(candidatePath, { + candidate, + }); + backups.push({ + label: formatRotatingBackupLabel(slot), + slot, + ...metadata, + }); + } + + return backups.sort((a, b) => a.slot - b.slot); +} + export function getNamedBackupsDirectoryPath(): string { return getNamedBackupsDirectory(); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b1a6695..6d3878b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -11,6 +11,7 @@ const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); const getActionableNamedBackupRestoresMock = vi.fn(); const listNamedBackupsMock = vi.fn(); +const listRotatingBackupsMock = vi.fn(); const assessNamedBackupRestoreMock = vi.fn(); const getNamedBackupsDirectoryPathMock = vi.fn(); const restoreNamedBackupMock = vi.fn(); @@ -109,6 +110,7 @@ vi.mock("../lib/storage.js", () => ({ getStoragePath: getStoragePathMock, getActionableNamedBackupRestores: getActionableNamedBackupRestoresMock, listNamedBackups: listNamedBackupsMock, + listRotatingBackups: listRotatingBackupsMock, assessNamedBackupRestore: assessNamedBackupRestoreMock, getNamedBackupsDirectoryPath: getNamedBackupsDirectoryPathMock, restoreNamedBackup: restoreNamedBackupMock, @@ -295,6 +297,7 @@ describe("codex manager cli commands", () => { deleteAccountAtIndexMock.mockResolvedValue(null); getActionableNamedBackupRestoresMock.mockReset(); listNamedBackupsMock.mockReset(); + listRotatingBackupsMock.mockReset(); assessNamedBackupRestoreMock.mockReset(); getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); @@ -304,6 +307,7 @@ describe("codex manager cli commands", () => { totalBackups: 0, }); listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue({ backup: { name: "", @@ -499,7 +503,7 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toContain("Codex Multi-Auth CLI"); }); - it("offers startup restore before OAuth when interactive login starts empty", async () => { + it("offers startup backup browser before OAuth when interactive login starts empty", async () => { setInteractiveTTY(true); const emptyStorage = { version: 3, @@ -507,26 +511,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [], }; - const restoredStorage = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "restored@example.com", - refreshToken: "restore-refresh", - addedAt: Date.now(), - lastUsed: Date.now(), - }, - ], - }; - let loadCount = 0; - loadAccountsMock.mockImplementation(async () => { - loadCount += 1; - return loadCount <= 3 - ? structuredClone(emptyStorage) - : structuredClone(restoredStorage); - }); + loadAccountsMock.mockImplementation(async () => + structuredClone(emptyStorage), + ); const assessment = { backup: { name: "startup-backup", @@ -553,9 +540,12 @@ describe("codex manager cli commands", () => { totalBackups: 1, }); listNamedBackupsMock.mockResolvedValue([assessment.backup]); + listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); + confirmMock.mockResolvedValueOnce(true); + selectMock + .mockResolvedValueOnce({ type: "back" }) + .mockResolvedValueOnce("cancel"); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -563,8 +553,8 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("startup-backup"); - expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); it("skips startup restore prompt in fallback login mode", async () => { @@ -1359,21 +1349,7 @@ describe("codex manager cli commands", () => { it("offers backup recovery before OAuth when actionable backups exist", async () => { setInteractiveTTY(true); const now = Date.now(); - let storageState: { - version: number; - activeIndex: number; - activeIndexByFamily: { codex: number }; - accounts: Array<{ - email?: string; - refreshToken: string; - addedAt: number; - lastUsed: number; - enabled?: boolean; - }>; - } | null = null; - loadAccountsMock.mockImplementation(async () => - storageState ? structuredClone(storageState) : null, - ); + loadAccountsMock.mockResolvedValue(null); const assessment = { backup: { name: "named-backup", @@ -1400,43 +1376,27 @@ describe("codex manager cli commands", () => { totalBackups: 2, }); listNamedBackupsMock.mockResolvedValue([assessment.backup]); + listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce({ type: "restore", assessment }); - restoreNamedBackupMock.mockImplementation(async () => { - storageState = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "restored@example.com", - refreshToken: "refresh-restored", - addedAt: now, - lastUsed: now, - enabled: true, - }, - ], - }; - return { imported: 1, skipped: 0, total: 1 }; - }); + confirmMock.mockResolvedValueOnce(true); + selectMock + .mockResolvedValueOnce({ type: "back" }) + .mockResolvedValueOnce("cancel"); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const authModule = await import("../lib/auth/auth.js"); const createAuthorizationFlowMock = vi.mocked( authModule.createAuthorizationFlow, ); - createAuthorizationFlowMock.mockRejectedValue( - new Error("oauth flow should be skipped when restoring backup"), - ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledTimes(2); + expect(confirmMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenCalled(); - expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }); it("keeps login loop running when settings action is selected", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 811329b..6a8a9dd 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -15,6 +15,7 @@ import { getStoragePath, importAccounts, listNamedBackups, + listRotatingBackups, loadAccounts, normalizeAccountStorage, restoreNamedBackup, @@ -380,7 +381,73 @@ describe("storage", () => { }, ], }); - await expect(createNamedBackup(" ")).rejects.toThrow(/Invalid backup name/); + await expect(createNamedBackup(" ")).rejects.toThrow( + /Invalid backup name/, + ); + }); + + it("lists rotating backups and marks invalid snapshots distinctly", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await createNamedBackup("browser-good"); + await fs.writeFile( + join(testWorkDir, "backups", "browser-bad.json"), + "{broken-json", + "utf-8", + ); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile(`${testStoragePath}.bak.1`, "{broken-bak", "utf-8"); + + const rotatingBackups = await listRotatingBackups(); + const namedBackups = await listNamedBackups(); + + expect(namedBackups.map((backup) => backup.name)).toEqual( + expect.arrayContaining(["browser-good", "browser-bad"]), + ); + expect( + namedBackups.find((backup) => backup.name === "browser-good")?.valid, + ).toBe(true); + expect( + namedBackups.find((backup) => backup.name === "browser-bad")?.valid, + ).toBe(false); + expect(rotatingBackups).toHaveLength(2); + expect(rotatingBackups[0]).toMatchObject({ + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }); + expect(rotatingBackups[1]).toMatchObject({ + slot: 1, + label: "Rotating backup 1 (.bak.1)", + valid: false, + }); + expect(rotatingBackups[1]?.loadError).toBeTruthy(); }); }); From 8842d9494d2feb9f26831a3ca93472193bf1ea8a Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 14:05:02 +0800 Subject: [PATCH 02/11] fix(auth): complete backup browser restore flow --- lib/codex-manager.ts | 59 ++++++++++++++++++++++++++++++++-- test/codex-manager-cli.test.ts | 53 +++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d7f3d83..b8fd3fe 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -84,6 +84,7 @@ import { listRotatingBackups, loadAccounts, loadFlaggedAccounts, + restoreNamedBackup, saveAccounts, saveFlaggedAccounts, setStoragePath, @@ -4177,7 +4178,9 @@ function buildBackupStatusSummary(entry: BackupBrowserEntry): string { return stylePromptText("Valid backup", "success"); } -function showBackupBrowserDetails(entry: BackupBrowserEntry): Promise { +async function showBackupBrowserDetails( + entry: BackupBrowserEntry, +): Promise<"back" | "restore"> { const backup = entry.backup; const typeLabel = entry.kind === "named" @@ -4231,7 +4234,30 @@ function showBackupBrowserDetails(entry: BackupBrowserEntry): Promise { console.log(line); } console.log(""); - return waitForMenuReturn(); + if ( + entry.kind !== "named" || + !entry.assessment.valid || + entry.assessment.wouldExceedLimit + ) { + await waitForMenuReturn(); + return "back"; + } + const action = await select<"restore" | "back">( + [ + { label: "Restore This Backup", value: "restore", color: "green" }, + { label: "Back", value: "back" }, + ], + { + message: "Backup Browser", + subtitle: entry.label, + help: "Enter Select | Q Back", + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: getUiRuntimeOptions().theme ? "row-invert" : "row-invert", + theme: getUiRuntimeOptions().theme, + }, + ); + return action === "restore" ? "restore" : "back"; } async function runBackupBrowserManager( @@ -4332,7 +4358,34 @@ async function runBackupBrowserManager( return; } - await showBackupBrowserDetails(selection.entry); + const entry = selection.entry; + const action = await showBackupBrowserDetails(entry); + if (action === "restore") { + if (entry.kind !== "named") { + continue; + } + const namedEntry = entry as Extract< + BackupBrowserEntry, + { kind: "named" } + >; + const backupName = namedEntry.backup.name; + const confirmed = await confirm(`Restore backup ${backupName}?`); + if (!confirmed) { + continue; + } + await runActionPanel( + "Restore Backup", + `Restoring ${backupName}`, + async () => { + const result = await restoreNamedBackup(backupName); + console.log( + `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, + ); + }, + displaySettings, + ); + return; + } } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6d3878b..6514797 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -542,7 +542,7 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockResolvedValue([assessment.backup]); listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock.mockResolvedValueOnce(true); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce({ type: "back" }) .mockResolvedValueOnce("cancel"); @@ -1347,9 +1347,28 @@ describe("codex manager cli commands", () => { }); it("offers backup recovery before OAuth when actionable backups exist", async () => { - setInteractiveTTY(true); + setInteractiveTTY(false); + isInteractiveLoginMenuAvailableMock.mockReturnValue(true); const now = Date.now(); - loadAccountsMock.mockResolvedValue(null); + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + if (loadCount <= 2) return null; + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "restored@example.com", + refreshToken: "refresh-restored", + accountId: "acc_restored", + addedAt: now, + lastUsed: now, + }, + ], + }; + }); const assessment = { backup: { name: "named-backup", @@ -1380,8 +1399,16 @@ describe("codex manager cli commands", () => { assessNamedBackupRestoreMock.mockResolvedValue(assessment); confirmMock.mockResolvedValueOnce(true); selectMock - .mockResolvedValueOnce({ type: "back" }) - .mockResolvedValueOnce("cancel"); + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("restore"); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const authModule = await import("../lib/auth/auth.js"); const createAuthorizationFlowMock = vi.mocked( @@ -1392,12 +1419,16 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(getActionableNamedBackupRestoresMock).toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalled(); - expect(restoreNamedBackupMock).not.toHaveBeenCalled(); - expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); - }); + expect(confirmMock).toHaveBeenNthCalledWith( + 1, + "Found 1 recoverable backup (named-backup) in /mock/backups. Open backup browser now?", + ); + expect(confirmMock).toHaveBeenNthCalledWith( + 2, + "Restore backup named-backup?", + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }, 20_000); it("keeps login loop running when settings action is selected", async () => { const now = Date.now(); From 386be25d8fed11602ce7998f8908546a6028b224 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 22:00:33 +0800 Subject: [PATCH 03/11] fix(auth): harden backup browser restore safety --- lib/codex-manager.ts | 83 ++++++++++++---------- lib/storage.ts | 86 ++++++++++++++++------- test/codex-manager-cli.test.ts | 82 +++++++++++++++++++++- test/storage.test.ts | 122 ++++++++++++++++++++++++++++++++- 4 files changed, 310 insertions(+), 63 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b8fd3fe..97b36f5 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4180,6 +4180,7 @@ function buildBackupStatusSummary(entry: BackupBrowserEntry): string { async function showBackupBrowserDetails( entry: BackupBrowserEntry, + displaySettings: DashboardDisplaySettings, ): Promise<"back" | "restore"> { const backup = entry.backup; const typeLabel = @@ -4251,53 +4252,59 @@ async function showBackupBrowserDetails( message: "Backup Browser", subtitle: entry.label, help: "Enter Select | Q Back", - clearScreen: true, + clearScreen: false, selectedEmphasis: "minimal", - focusStyle: getUiRuntimeOptions().theme ? "row-invert" : "row-invert", + focusStyle: displaySettings.menuFocusStyle ?? "row-invert", theme: getUiRuntimeOptions().theme, }, ); return action === "restore" ? "restore" : "back"; } -async function runBackupBrowserManager( - displaySettings: DashboardDisplaySettings, -): Promise { - const backupDir = getNamedBackupsDirectoryPath(); +async function loadBackupBrowserEntries(): Promise<{ + namedEntries: Extract[]; + rotatingEntries: Extract[]; +}> { const [namedBackups, rotatingBackups] = await Promise.all([ listNamedBackups(), listRotatingBackups(), ]); - if (namedBackups.length === 0 && rotatingBackups.length === 0) { - console.log( - `No backups found. Named backups live in ${backupDir}. Rotating backups live next to ${getStoragePath()}.`, - ); - return; - } - const currentStorage = await loadAccounts(); const assessments = await Promise.all( namedBackups.map((backup) => assessNamedBackupRestore(backup.name, { currentStorage }), ), ); - const namedEntries: BackupBrowserEntry[] = assessments.map((assessment) => ({ - kind: "named", - label: assessment.backup.name, - backup: assessment.backup, - assessment, - })); - const rotatingEntries: BackupBrowserEntry[] = rotatingBackups.map( - (backup) => ({ + return { + namedEntries: assessments.map((assessment) => ({ + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + })), + rotatingEntries: rotatingBackups.map((backup) => ({ kind: "rotating", label: backup.label, backup, - }), - ); + })), + }; +} +async function runBackupBrowserManager( + displaySettings: DashboardDisplaySettings, +): Promise { + const backupDir = getNamedBackupsDirectoryPath(); const ui = getUiRuntimeOptions(); while (true) { + const { namedEntries, rotatingEntries } = await loadBackupBrowserEntries(); + if (namedEntries.length === 0 && rotatingEntries.length === 0) { + console.log( + `No backups found. Named backups live in ${backupDir}. Rotating backups live next to ${getStoragePath()}.`, + ); + return; + } + const items: MenuItem[] = [ { label: "Named Backups", value: { type: "back" }, kind: "heading" }, ]; @@ -4359,7 +4366,7 @@ async function runBackupBrowserManager( } const entry = selection.entry; - const action = await showBackupBrowserDetails(entry); + const action = await showBackupBrowserDetails(entry, displaySettings); if (action === "restore") { if (entry.kind !== "named") { continue; @@ -4373,18 +4380,22 @@ async function runBackupBrowserManager( if (!confirmed) { continue; } - await runActionPanel( - "Restore Backup", - `Restoring ${backupName}`, - async () => { - const result = await restoreNamedBackup(backupName); - console.log( - `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, - ); - }, - displaySettings, - ); - return; + try { + await runActionPanel( + "Restore Backup", + `Restoring ${backupName}`, + async () => { + const result = await restoreNamedBackup(backupName); + console.log( + `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`, + ); + }, + displaySettings, + ); + return; + } catch { + continue; + } } } } diff --git a/lib/storage.ts b/lib/storage.ts index 5d8e6c1..9f77271 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -461,6 +461,26 @@ function normalizeBackupName(rawName: string): string { return sanitized; } +function normalizeBackupLookupName(rawName: string): string { + const trimmed = rawName.trim().replace(/\.(json|bak)$/i, ""); + const hasPathSeparator = /[\\/]/.test(trimmed); + const hasDrivePrefix = /^[a-zA-Z]:/.test(trimmed); + const segments = trimmed.split(/[\\/]+/).filter(Boolean); + if ( + hasPathSeparator || + hasDrivePrefix || + segments.some((segment) => segment === "." || segment === "..") + ) { + throw new StorageError( + `Invalid backup name: ${rawName}`, + "EINVALID", + getNamedBackupsDirectory(), + "Named backup restore operations only accept backup names from the backups directory.", + ); + } + return normalizeBackupName(trimmed); +} + function getNamedBackupsDirectory(): string { return join(dirname(getStoragePath()), NAMED_BACKUP_DIRECTORY); } @@ -473,10 +493,21 @@ async function ensureNamedBackupsDirectory(): Promise { function resolveNamedBackupPath(name: string): string { const normalizedName = normalizeBackupName(name); - return join( - getNamedBackupsDirectory(), + const backupDir = resolve(getNamedBackupsDirectory()); + const backupPath = resolve( + backupDir, `${normalizedName}${NAMED_BACKUP_EXTENSION}`, ); + const relativePath = relative(backupDir, backupPath); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new StorageError( + `Invalid backup name: ${name}`, + "EINVALID", + backupPath, + `Named backups must stay inside ${backupDir}.`, + ); + } + return backupPath; } function deriveBackupNameFromFile(fileName: string): string { @@ -1361,31 +1392,38 @@ export async function listNamedBackups(): Promise { export async function listRotatingBackups(): Promise { const storagePath = getStoragePath(); - const candidates = getAccountsBackupRecoveryCandidates(storagePath); - const backups: RotatingBackupMetadata[] = []; + try { + const candidates = getAccountsBackupRecoveryCandidates(storagePath); + const backups: RotatingBackupMetadata[] = []; - for (const candidatePath of candidates) { - if (!existsSync(candidatePath)) { - continue; - } + for (const candidatePath of candidates) { + const slot = parseRotatingBackupSlot(storagePath, candidatePath); + if (slot === null) { + continue; + } - const slot = parseRotatingBackupSlot(storagePath, candidatePath); - if (slot === null) { - continue; + const candidate = await loadBackupCandidate(candidatePath); + if (!candidate.normalized && candidate.error?.includes("ENOENT")) { + continue; + } + const metadata = await buildBackupFileMetadata(candidatePath, { + candidate, + }); + backups.push({ + label: formatRotatingBackupLabel(slot), + slot, + ...metadata, + }); } - const candidate = await loadBackupCandidate(candidatePath); - const metadata = await buildBackupFileMetadata(candidatePath, { - candidate, - }); - backups.push({ - label: formatRotatingBackupLabel(slot), - slot, - ...metadata, + return backups.sort((a, b) => a.slot - b.slot); + } catch (error) { + log.warn("Failed to list rotating backups", { + path: storagePath, + error: String(error), }); + return []; } - - return backups.sort((a, b) => a.slot - b.slot); } export function getNamedBackupsDirectoryPath(): string { @@ -1440,7 +1478,7 @@ export async function assessNamedBackupRestore( name: string, options: { currentStorage?: AccountStorageV3 | null } = {}, ): Promise { - const normalizedName = normalizeBackupName(name); + const normalizedName = normalizeBackupLookupName(name); const backupPath = resolveNamedBackupPath(normalizedName); const candidate = await loadBackupCandidate(backupPath); const backup = await buildNamedBackupMetadata(normalizedName, backupPath, { @@ -1494,7 +1532,7 @@ export async function assessNamedBackupRestore( export async function restoreNamedBackup( name: string, ): Promise<{ imported: number; total: number; skipped: number }> { - const normalizedName = normalizeBackupName(name); + const normalizedName = normalizeBackupLookupName(name); const backupPath = resolveNamedBackupPath(normalizedName); return importAccounts(backupPath); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6514797..1b98fce 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1397,7 +1397,7 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockResolvedValue([assessment.backup]); listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); - confirmMock.mockResolvedValueOnce(true); + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce({ type: "inspect", @@ -1427,9 +1427,89 @@ describe("codex manager cli commands", () => { 2, "Restore backup named-backup?", ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }, 20_000); + it("keeps auth login running when backup restore fails inside the browser", async () => { + setInteractiveTTY(false); + const now = Date.now(); + const currentStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "refresh-existing", + accountId: "acc-existing", + addedAt: now, + lastUsed: now, + }, + ], + }; + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 1, + mergedAccountCount: 2, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: undefined, + }; + + loadAccountsMock.mockResolvedValue(currentStorage); + listNamedBackupsMock + .mockResolvedValueOnce([assessment.backup]) + .mockResolvedValueOnce([]); + listRotatingBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + restoreNamedBackupMock.mockRejectedValueOnce( + new Error("backup removed during restore"), + ); + confirmMock.mockResolvedValueOnce(true); + selectMock + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }, + }) + .mockResolvedValueOnce("restore") + .mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked( + authModule.createAuthorizationFlow, + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(2); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + it("keeps login loop running when settings action is selected", async () => { const now = Date.now(); const storage = { diff --git a/test/storage.test.ts b/test/storage.test.ts index 6a8a9dd..7658dd0 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -31,6 +31,30 @@ import { // accept that this test won't even compile/run until we add them. // But Task 0 says: "Tests should fail initially (RED phase)" +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]); + const maxAttempts = 6; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + const maybeCode = "code" in error ? (error as { code?: string }).code : undefined; + if (!maybeCode || !retryableCodes.has(maybeCode) || attempt === maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, attempt * 50)); + } + } +} + describe("storage", () => { const _origCODEX_HOME = process.env.CODEX_HOME; const _origCODEX_MULTI_AUTH_DIR = process.env.CODEX_MULTI_AUTH_DIR; @@ -128,7 +152,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("should export accounts to a file", async () => { @@ -314,7 +338,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("creates and lists named backups with metadata", async () => { @@ -386,6 +410,24 @@ describe("storage", () => { ); }); + it.each([ + "../openai-codex-accounts", + "..\\openai-codex-accounts", + "nested/openai-codex-accounts", + "nested\\openai-codex-accounts", + "C:openai-codex-accounts", + ])( + "rejects path-like lookup name %s when assessing or restoring named backups", + async (backupName) => { + await expect(assessNamedBackupRestore(backupName)).rejects.toThrow( + /Invalid backup name/, + ); + await expect(restoreNamedBackup(backupName)).rejects.toThrow( + /Invalid backup name/, + ); + }, + ); + it("lists rotating backups and marks invalid snapshots distinctly", async () => { await saveAccounts({ version: 3, @@ -449,6 +491,82 @@ describe("storage", () => { }); expect(rotatingBackups[1]?.loadError).toBeTruthy(); }); + + it("skips rotating backups that disappear during load", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + `${testStoragePath}.bak.1`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-missing", + refreshToken: "ref-rotating-missing", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + "utf-8", + ); + + const originalReadFile = fs.readFile.bind(fs) as typeof fs.readFile; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation((async ( + ...args: Parameters + ) => { + const [filePath] = args; + if (String(filePath).endsWith(".bak.1")) { + throw Object.assign( + new Error("ENOENT: no such file or directory"), + { code: "ENOENT" }, + ); + } + return originalReadFile(...args); + }) as typeof fs.readFile); + + try { + await expect(listRotatingBackups()).resolves.toMatchObject([ + { + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }, + ]); + } finally { + readFileSpy.mockRestore(); + } + }); }); describe("filename migration (TDD)", () => { From 80d2aa6238d889f2ecb571f5f8fccbbf6e7ec266 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 22:12:20 +0800 Subject: [PATCH 04/11] test(auth): make backup browser restore success explicit --- test/codex-manager-cli.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 1b98fce..5d1a883 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1350,6 +1350,7 @@ describe("codex manager cli commands", () => { setInteractiveTTY(false); isInteractiveLoginMenuAvailableMock.mockReturnValue(true); const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); let loadCount = 0; loadAccountsMock.mockImplementation(async () => { loadCount += 1; @@ -1397,6 +1398,11 @@ describe("codex manager cli commands", () => { listNamedBackupsMock.mockResolvedValue([assessment.backup]); listRotatingBackupsMock.mockResolvedValue([]); assessNamedBackupRestoreMock.mockResolvedValue(assessment); + restoreNamedBackupMock.mockResolvedValueOnce({ + imported: 1, + skipped: 0, + total: 1, + }); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce({ @@ -1428,6 +1434,12 @@ describe("codex manager cli commands", () => { "Restore backup named-backup?", ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect( + logSpy.mock.calls.some( + (call) => + call[0] === "Imported 1 account. Skipped 0. Total accounts: 1.", + ), + ).toBe(true); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }, 20_000); From c40e509eb5799ab3af45107a13c0f448541b9dec Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 22:33:36 +0800 Subject: [PATCH 05/11] fix(auth): finish backup browser review follow-ups --- lib/codex-manager.ts | 21 +++- lib/storage.ts | 8 +- test/codex-manager-cli.test.ts | 204 +++++++++++++++++++++++++++++++++ test/storage.test.ts | 90 +++++++++++++++ 4 files changed, 318 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 97b36f5..acb6143 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -141,7 +141,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"; @@ -150,7 +150,7 @@ function formatRelativeDateShort( } function formatDateTimeLong(timestamp: number | null | undefined): string { - if (!timestamp) return "unknown"; + if (timestamp == null) return "unknown"; return new Date(timestamp).toLocaleString(); } @@ -4272,11 +4272,24 @@ async function loadBackupBrowserEntries(): Promise<{ const currentStorage = await loadAccounts(); const assessments = await Promise.all( namedBackups.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }), + assessNamedBackupRestore(backup.name, { currentStorage }).catch( + (error) => { + console.warn( + `Failed to assess named backup ${backup.name}: ${String(error)}`, + ); + return null; + }, + ), ), ); + const validAssessments = assessments.filter( + ( + assessment, + ): assessment is Awaited> => + assessment !== null, + ); return { - namedEntries: assessments.map((assessment) => ({ + namedEntries: validAssessments.map((assessment) => ({ kind: "named", label: assessment.backup.name, backup: assessment.backup, diff --git a/lib/storage.ts b/lib/storage.ts index 9f77271..c144aea 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1084,15 +1084,21 @@ async function loadBackupCandidate(path: string): Promise<{ storedVersion: unknown; schemaErrors: string[]; error?: string; + errorCode?: string; }> { try { return await loadAccountsFromPath(path); } catch (error) { + const errorCode = + typeof (error as NodeJS.ErrnoException).code === "string" + ? (error as NodeJS.ErrnoException).code + : undefined; return { normalized: null, storedVersion: undefined, schemaErrors: [], error: String(error), + errorCode, }; } } @@ -1403,7 +1409,7 @@ export async function listRotatingBackups(): Promise { } const candidate = await loadBackupCandidate(candidatePath); - if (!candidate.normalized && candidate.error?.includes("ENOENT")) { + if (!candidate.normalized && candidate.errorCode === "ENOENT") { continue; } const metadata = await buildBackupFileMetadata(candidatePath, { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 5d1a883..7afd984 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1522,6 +1522,210 @@ describe("codex manager cli commands", () => { expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); + it("keeps auth login running when a named backup assessment fails inside the browser", async () => { + setInteractiveTTY(false); + const now = Date.now(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "refresh-existing", + accountId: "acc-existing", + addedAt: now, + lastUsed: now, + }, + ], + }; + const recoverableAssessment = { + backup: { + name: "recoverable-backup", + path: "/mock/backups/recoverable-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: undefined, + }; + const brokenBackup = { + name: "broken-backup", + path: "/mock/backups/broken-backup.json", + createdAt: now - 2_000, + updatedAt: now - 2_000, + sizeBytes: 256, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }; + + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 3 + ? structuredClone(emptyStorage) + : structuredClone(populatedStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [recoverableAssessment], + totalBackups: 2, + }); + listNamedBackupsMock.mockResolvedValue([ + brokenBackup, + recoverableAssessment.backup, + ]); + listRotatingBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockImplementation(async (name: string) => { + if (name === "broken-backup") { + throw new Error("EPERM: stale symlink"); + } + return recoverableAssessment; + }); + confirmMock.mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Failed to assess named backup broken-backup: Error: EPERM: stale symlink", + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it("shows epoch timestamps in backup browser details instead of unknown", async () => { + setInteractiveTTY(false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const epochDisplay = new Date(0).toLocaleString(); + const now = Date.now(); + const rotatingBackup = { + label: "Latest rotating backup (.bak)", + slot: 0, + path: "/mock/openai-codex-accounts.json.bak", + createdAt: 0, + updatedAt: 0, + sizeBytes: 64, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }; + + const emptyStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "refresh-existing", + accountId: "acc-existing", + addedAt: now, + lastUsed: now, + }, + ], + }; + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + return loadCount <= 4 + ? structuredClone(emptyStorage) + : structuredClone(populatedStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [ + { + backup: { + name: "recoverable-backup", + path: "/mock/backups/recoverable-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: undefined, + }, + ], + totalBackups: 1, + }); + confirmMock.mockResolvedValueOnce(true); + listNamedBackupsMock.mockResolvedValue([]); + listRotatingBackupsMock.mockResolvedValue([rotatingBackup]); + selectMock + .mockResolvedValueOnce({ + type: "inspect", + entry: { + kind: "rotating", + label: rotatingBackup.label, + backup: rotatingBackup, + }, + }) + .mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith(`Created: ${epochDisplay}`); + expect(logSpy).toHaveBeenCalledWith(`Updated: ${epochDisplay}`); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + } + }); + it("keeps login loop running when settings action is selected", async () => { const now = Date.now(); const storage = { diff --git a/test/storage.test.ts b/test/storage.test.ts index 7658dd0..07369e9 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -567,6 +567,96 @@ describe("storage", () => { readFileSpy.mockRestore(); } }); + + it("keeps rotating backups visible when a non-ENOENT error mentions an ENOENT path segment", async () => { + const storageDir = join(testWorkDir, "ENOENT-project"); + await fs.mkdir(storageDir, { recursive: true }); + testStoragePath = join(storageDir, "openai-codex-accounts.json"); + setStoragePathDirect(testStoragePath); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "primary", + refreshToken: "ref-primary", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await fs.writeFile( + `${testStoragePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-good", + refreshToken: "ref-rotating-good", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + `${testStoragePath}.bak.1`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "rotating-locked", + refreshToken: "ref-rotating-locked", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + "utf-8", + ); + + const lockedBackupPath = `${testStoragePath}.bak.1`; + const originalReadFile = fs.readFile.bind(fs) as typeof fs.readFile; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockImplementation((async ( + ...args: Parameters + ) => { + const [filePath] = args; + if (String(filePath) === lockedBackupPath) { + throw Object.assign( + new Error( + `EPERM: operation not permitted, open '${lockedBackupPath}'`, + ), + { code: "EPERM" }, + ); + } + return originalReadFile(...args); + }) as typeof fs.readFile); + + try { + const rotatingBackups = await listRotatingBackups(); + expect(rotatingBackups).toHaveLength(2); + expect(rotatingBackups[0]).toMatchObject({ + slot: 0, + label: "Latest rotating backup (.bak)", + valid: true, + accountCount: 1, + }); + expect(rotatingBackups[1]).toMatchObject({ + slot: 1, + label: "Rotating backup 1 (.bak.1)", + valid: false, + }); + expect(rotatingBackups[1]?.loadError).toContain("EPERM"); + } finally { + readFileSpy.mockRestore(); + } + }); }); describe("filename migration (TDD)", () => { From f9ed29f0a789657d09cefde42da4f21c9113eae5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:15:28 +0800 Subject: [PATCH 06/11] fix(auth): keep degraded backups visible --- lib/codex-manager.ts | 126 +++++++++++++++++++++++---------- test/codex-manager-cli.test.ts | 57 +++++++++++---- 2 files changed, 134 insertions(+), 49 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index acb6143..204ad24 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -58,6 +58,7 @@ import { recommendForecastAccount, summarizeForecast, } from "./forecast.js"; +import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { loadQuotaCache, @@ -104,6 +105,7 @@ type TokenSuccessWithAccount = TokenSuccess & { accountLabel?: string; }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +const log = createLogger("codex-manager"); function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; @@ -4118,13 +4120,24 @@ type RotatingBackupEntry = Awaited< ReturnType >[number]; -type BackupBrowserEntry = +type NamedBackupBrowserEntry = | { kind: "named"; label: string; backup: NamedBackupEntry; assessment: NamedBackupAssessment; + assessmentError?: undefined; } + | { + kind: "named"; + label: string; + backup: NamedBackupEntry; + assessment: null; + assessmentError: string; + }; + +type BackupBrowserEntry = + | NamedBackupBrowserEntry | { kind: "rotating"; label: string; @@ -4138,6 +4151,21 @@ type BackupMenuAction = } | { type: "back" }; +function hasNamedBackupAssessment( + entry: BackupBrowserEntry, +): entry is Extract { + return entry.kind === "named" && entry.assessment !== null; +} + +function normalizeBackupAssessmentError(error: unknown): string { + const detail = collapseWhitespace( + error instanceof Error ? error.message : String(error), + ); + return detail.length > 0 + ? detail + : "Unable to assess restore eligibility"; +} + function buildBackupBrowserHint(entry: BackupBrowserEntry): string { const backup = entry.backup; const lastUpdated = formatRelativeDateShort(backup.updatedAt); @@ -4150,9 +4178,15 @@ function buildBackupBrowserHint(entry: BackupBrowserEntry): string { ? `${backup.accountCount} account${backup.accountCount === 1 ? "" : "s"}` : undefined, lastUpdated ? `updated ${lastUpdated}` : undefined, - entry.kind === "named" && entry.assessment.wouldExceedLimit + hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit ? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}` : undefined, + entry.kind === "named" && entry.assessment === null + ? "restore assessment unavailable" + : undefined, + entry.kind === "named" && entry.assessment === null + ? entry.assessmentError + : undefined, backup.loadError, ].filter( (value): value is string => @@ -4164,6 +4198,9 @@ function buildBackupBrowserHint(entry: BackupBrowserEntry): string { function backupMenuColor( entry: BackupBrowserEntry, ): MenuItem["color"] { + if (entry.kind === "named" && entry.assessment === null) { + return entry.backup.valid ? "yellow" : "red"; + } return entry.backup.valid ? "green" : "red"; } @@ -4172,7 +4209,10 @@ function buildBackupStatusSummary(entry: BackupBrowserEntry): string { if (!backup.valid) { return stylePromptText("Invalid backup", "danger"); } - if (entry.kind === "named" && entry.assessment.wouldExceedLimit) { + if (entry.kind === "named" && entry.assessment === null) { + return stylePromptText("Restore assessment unavailable", "warning"); + } + if (hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit) { return stylePromptText("Valid file, restore would exceed limit", "warning"); } return stylePromptText("Valid backup", "success"); @@ -4201,16 +4241,22 @@ async function showBackupBrowserDetails( ]; if (entry.kind === "named") { - const assessment = entry.assessment; - lines.push( - "", - stylePromptText("Restore Assessment", "accent"), - `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, - `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, - `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, - `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, - `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.valid ? "Recoverable" : (assessment.error ?? "Unavailable")}`, - ); + lines.push("", stylePromptText("Restore Assessment", "accent")); + if (entry.assessment === null) { + lines.push( + `${stylePromptText("Eligibility:", "muted")} Unavailable`, + `${stylePromptText("Reason:", "muted")} ${entry.assessmentError}`, + ); + } else { + const assessment = entry.assessment; + lines.push( + `${stylePromptText("Current accounts:", "muted")} ${assessment.currentAccountCount}`, + `${stylePromptText("Merged after dedupe:", "muted")} ${assessment.mergedAccountCount ?? "unknown"}`, + `${stylePromptText("Would import:", "muted")} ${assessment.imported ?? "unknown"}`, + `${stylePromptText("Would skip:", "muted")} ${assessment.skipped ?? "unknown"}`, + `${stylePromptText("Eligibility:", "muted")} ${assessment.wouldExceedLimit ? `Would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts` : assessment.valid ? "Recoverable" : (assessment.error ?? "Unavailable")}`, + ); + } } if (backup.schemaErrors.length > 0 || backup.loadError) { @@ -4237,6 +4283,7 @@ async function showBackupBrowserDetails( console.log(""); if ( entry.kind !== "named" || + entry.assessment === null || !entry.assessment.valid || entry.assessment.wouldExceedLimit ) { @@ -4262,7 +4309,7 @@ async function showBackupBrowserDetails( } async function loadBackupBrowserEntries(): Promise<{ - namedEntries: Extract[]; + namedEntries: NamedBackupBrowserEntry[]; rotatingEntries: Extract[]; }> { const [namedBackups, rotatingBackups] = await Promise.all([ @@ -4270,31 +4317,36 @@ async function loadBackupBrowserEntries(): Promise<{ listRotatingBackups(), ]); const currentStorage = await loadAccounts(); - const assessments = await Promise.all( - namedBackups.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }).catch( - (error) => { - console.warn( - `Failed to assess named backup ${backup.name}: ${String(error)}`, - ); - return null; - }, - ), - ), - ); - const validAssessments = assessments.filter( - ( - assessment, - ): assessment is Awaited> => - assessment !== null, + const namedEntries: NamedBackupBrowserEntry[] = await Promise.all( + namedBackups.map(async (backup) => { + try { + const assessment = await assessNamedBackupRestore(backup.name, { + currentStorage, + }); + return { + kind: "named", + label: assessment.backup.name, + backup: assessment.backup, + assessment, + }; + } catch (error) { + const assessmentError = normalizeBackupAssessmentError(error); + log.warn("Failed to assess named backup for backup browser", { + name: backup.name, + error: assessmentError, + }); + return { + kind: "named", + label: backup.name, + backup, + assessment: null, + assessmentError, + }; + } + }), ); return { - namedEntries: validAssessments.map((assessment) => ({ - kind: "named", - label: assessment.backup.name, - backup: assessment.backup, - assessment, - })), + namedEntries, rotatingEntries: rotatingBackups.map((backup) => ({ kind: "rotating", label: backup.label, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 7afd984..d5c49d5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -40,14 +40,16 @@ const deleteSavedAccountsMock = vi.fn(); const resetLocalStateMock = vi.fn(); const deleteAccountAtIndexMock = vi.fn(); const confirmMock = vi.fn(); +const loggerWarnMock = vi.fn(); +const createLoggerMock = vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: loggerWarnMock, + error: vi.fn(), +})); vi.mock("../lib/logger.js", () => ({ - createLogger: vi.fn(() => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - })), + createLogger: createLoggerMock, logWarn: vi.fn(), })); @@ -302,6 +304,8 @@ describe("codex manager cli commands", () => { getNamedBackupsDirectoryPathMock.mockReset(); restoreNamedBackupMock.mockReset(); confirmMock.mockReset(); + createLoggerMock.mockClear(); + loggerWarnMock.mockReset(); getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [], totalBackups: 0, @@ -1525,7 +1529,9 @@ describe("codex manager cli commands", () => { it("keeps auth login running when a named backup assessment fails inside the browser", async () => { setInteractiveTTY(false); const now = Date.now(); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); const emptyStorage = { version: 3, activeIndex: 0, @@ -1609,6 +1615,15 @@ describe("codex manager cli commands", () => { try { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const firstMenuItems = selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + hint?: string; + color?: string; + value: { type: string; entry?: unknown }; + }>; + const brokenItem = firstMenuItems.find( + (item) => item.label === "broken-backup", + ); expect(exitCode).toBe(0); expect(assessNamedBackupRestoreMock).toHaveBeenCalledTimes(2); @@ -1616,13 +1631,31 @@ describe("codex manager cli commands", () => { expect(promptLoginModeMock).toHaveBeenCalledTimes(1); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Failed to assess named backup broken-backup: Error: EPERM: stale symlink", - ), + expect(brokenItem).toMatchObject({ + label: "broken-backup", + color: "yellow", + hint: expect.stringContaining("restore assessment unavailable"), + value: { + type: "inspect", + entry: { + kind: "named", + label: "broken-backup", + backup: brokenBackup, + assessment: null, + assessmentError: "EPERM: stale symlink", + }, + }, + }); + expect(loggerWarnMock).toHaveBeenCalledWith( + "Failed to assess named backup for backup browser", + expect.objectContaining({ + name: "broken-backup", + error: "EPERM: stale symlink", + }), ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); } finally { - warnSpy.mockRestore(); + consoleWarnSpy.mockRestore(); } }); From 262224f874d21b1b61db223669f4ab79761a849f Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:29:36 +0800 Subject: [PATCH 07/11] fix(auth): mark blocked backups in browser list --- lib/codex-manager.ts | 3 ++ test/codex-manager-cli.test.ts | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 204ad24..c72a7c3 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4201,6 +4201,9 @@ function backupMenuColor( if (entry.kind === "named" && entry.assessment === null) { return entry.backup.valid ? "yellow" : "red"; } + if (hasNamedBackupAssessment(entry) && entry.assessment.wouldExceedLimit) { + return "red"; + } return entry.backup.valid ? "green" : "red"; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d5c49d5..0b409b2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1526,6 +1526,79 @@ describe("codex manager cli commands", () => { expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); }); + it("shows limit-exceeded named backups as red in the backup browser list", async () => { + setInteractiveTTY(false); + const now = Date.now(); + const currentStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "refresh-existing", + accountId: "acc-existing", + addedAt: now, + lastUsed: now, + }, + ], + }; + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 10, + mergedAccountCount: 11, + imported: 1, + skipped: 0, + wouldExceedLimit: true, + valid: true, + error: undefined, + }; + + loadAccountsMock.mockResolvedValue(currentStorage); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + listRotatingBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "restore-backup" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked( + authModule.createAuthorizationFlow, + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const firstMenuItems = selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + hint?: string; + color?: string; + }>; + const limitExceededItem = firstMenuItems.find( + (item) => item.label === "named-backup", + ); + + expect(exitCode).toBe(0); + expect(limitExceededItem).toMatchObject({ + label: "named-backup", + color: "red", + hint: expect.stringContaining("would exceed"), + }); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + }); + it("keeps auth login running when a named backup assessment fails inside the browser", async () => { setInteractiveTTY(false); const now = Date.now(); From 87452077032c2f732f01d857ba7e9e8b9065a3de Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:53:01 +0800 Subject: [PATCH 08/11] fix(auth): surface backup browser restore failures --- lib/codex-manager.ts | 20 +++++++++++++++++--- test/codex-manager-cli.test.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c72a7c3..d84de84 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4166,6 +4166,13 @@ function normalizeBackupAssessmentError(error: unknown): string { : "Unable to assess restore eligibility"; } +function normalizeBackupRestoreError(error: unknown): string { + const detail = collapseWhitespace( + error instanceof Error ? error.message : String(error), + ); + return detail.length > 0 ? detail : "Unable to restore backup"; +} + function buildBackupBrowserHint(entry: BackupBrowserEntry): string { const backup = entry.backup; const lastUpdated = formatRelativeDateShort(backup.updatedAt); @@ -4315,11 +4322,11 @@ async function loadBackupBrowserEntries(): Promise<{ namedEntries: NamedBackupBrowserEntry[]; rotatingEntries: Extract[]; }> { - const [namedBackups, rotatingBackups] = await Promise.all([ + const [namedBackups, rotatingBackups, currentStorage] = await Promise.all([ listNamedBackups(), listRotatingBackups(), + loadAccounts(), ]); - const currentStorage = await loadAccounts(); const namedEntries: NamedBackupBrowserEntry[] = await Promise.all( namedBackups.map(async (backup) => { try { @@ -4461,7 +4468,14 @@ async function runBackupBrowserManager( displaySettings, ); return; - } catch { + } catch (restoreError) { + const restoreErrorMessage = + normalizeBackupRestoreError(restoreError); + log.warn("Failed to restore named backup from backup browser", { + name: backupName, + error: restoreErrorMessage, + }); + console.log(`Restore failed: ${restoreErrorMessage}`); continue; } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0b409b2..2596692 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1449,6 +1449,7 @@ describe("codex manager cli commands", () => { it("keeps auth login running when backup restore fails inside the browser", async () => { setInteractiveTTY(false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const now = Date.now(); const currentStorage = { version: 3, @@ -1515,15 +1516,28 @@ describe("codex manager cli commands", () => { const createAuthorizationFlowMock = vi.mocked( authModule.createAuthorizationFlow, ); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); - expect(listNamedBackupsMock).toHaveBeenCalledTimes(2); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect(listNamedBackupsMock).toHaveBeenCalledTimes(2); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(loggerWarnMock).toHaveBeenCalledWith( + "Failed to restore named backup from backup browser", + expect.objectContaining({ + name: "named-backup", + error: "backup removed during restore", + }), + ); + expect(logSpy).toHaveBeenCalledWith( + "Restore failed: backup removed during restore", + ); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + } }); it("shows limit-exceeded named backups as red in the backup browser list", async () => { @@ -1826,6 +1840,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(logSpy).toHaveBeenCalledWith(`Created: ${epochDisplay}`); expect(logSpy).toHaveBeenCalledWith(`Updated: ${epochDisplay}`); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); } finally { logSpy.mockRestore(); From 67956cff0323468b00448083f65155c48a668a17 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:13:01 +0800 Subject: [PATCH 09/11] fix(auth): guard backup browser storage load --- lib/codex-manager.ts | 11 +++- test/codex-manager-cli.test.ts | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d84de84..88638a3 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4322,11 +4322,18 @@ async function loadBackupBrowserEntries(): Promise<{ namedEntries: NamedBackupBrowserEntry[]; rotatingEntries: Extract[]; }> { - const [namedBackups, rotatingBackups, currentStorage] = await Promise.all([ + const [namedBackups, rotatingBackups] = await Promise.all([ listNamedBackups(), listRotatingBackups(), - loadAccounts(), ]); + let currentStorage: Awaited> = null; + try { + currentStorage = await loadAccounts(); + } catch (error) { + log.warn("Failed to load current storage for backup browser", { + error: normalizeBackupAssessmentError(error), + }); + } const namedEntries: NamedBackupBrowserEntry[] = await Promise.all( namedBackups.map(async (backup) => { try { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2596692..126852e 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1746,6 +1746,104 @@ describe("codex manager cli commands", () => { } }); + it("keeps auth login running when backup browser storage load fails", async () => { + setInteractiveTTY(false); + const now = Date.now(); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + const assessment = { + backup: { + name: "named-backup", + path: "/mock/backups/named-backup.json", + createdAt: now - 1_000, + updatedAt: now - 1_000, + sizeBytes: 512, + version: 3, + accountCount: 1, + schemaErrors: [], + valid: true, + loadError: undefined, + }, + currentAccountCount: 0, + mergedAccountCount: 1, + imported: 1, + skipped: 0, + wouldExceedLimit: false, + valid: true, + error: undefined, + }; + const populatedStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "existing@example.com", + refreshToken: "refresh-existing", + accountId: "acc-existing", + addedAt: now, + lastUsed: now, + }, + ], + }; + + let loadCount = 0; + loadAccountsMock.mockImplementation(async () => { + loadCount += 1; + if (loadCount <= 2) { + return null; + } + if (loadCount === 3) { + throw new Error("EPERM: accounts file locked"); + } + return structuredClone(populatedStorage); + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [assessment], + totalBackups: 1, + }); + listNamedBackupsMock.mockResolvedValue([assessment.backup]); + listRotatingBackupsMock.mockResolvedValue([]); + assessNamedBackupRestoreMock.mockResolvedValue(assessment); + confirmMock.mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce({ type: "back" }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked( + authModule.createAuthorizationFlow, + ); + + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenCalledWith( + "Found 1 recoverable backup (named-backup) in /mock/backups. Open backup browser now?", + ); + expect(assessNamedBackupRestoreMock).toHaveBeenCalledWith( + "named-backup", + expect.objectContaining({ + currentStorage: null, + }), + ); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).toHaveBeenCalledWith( + "Failed to load current storage for backup browser", + expect.objectContaining({ + error: "EPERM: accounts file locked", + }), + ); + expect(restoreNamedBackupMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + it("shows epoch timestamps in backup browser details instead of unknown", async () => { setInteractiveTTY(false); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); From b057024a682cbfcb5491c67cc68a1fc1b8c66c52 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:27:41 +0800 Subject: [PATCH 10/11] fix(storage): preserve named backup lookup basenames --- lib/codex-manager.ts | 12 ++---- lib/storage.ts | 16 +++++--- test/codex-manager-cli.test.ts | 15 ++++++-- test/storage.test.ts | 68 +++++++++++++++++++++++++++++----- 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 88638a3..d6e3db8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4449,16 +4449,10 @@ async function runBackupBrowserManager( const entry = selection.entry; const action = await showBackupBrowserDetails(entry, displaySettings); - if (action === "restore") { - if (entry.kind !== "named") { - continue; - } - const namedEntry = entry as Extract< - BackupBrowserEntry, - { kind: "named" } - >; + if (action === "restore" && entry.kind === "named") { + const namedEntry = entry; const backupName = namedEntry.backup.name; - const confirmed = await confirm(`Restore backup ${backupName}?`); + const confirmed = await confirm(`Restore backup "${backupName}"?`); if (!confirmed) { continue; } diff --git a/lib/storage.ts b/lib/storage.ts index c144aea..a0b8197 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -463,6 +463,14 @@ function normalizeBackupName(rawName: string): string { function normalizeBackupLookupName(rawName: string): string { const trimmed = rawName.trim().replace(/\.(json|bak)$/i, ""); + if (!trimmed) { + throw new StorageError( + `Invalid backup name: ${rawName}`, + "EINVALID", + getNamedBackupsDirectory(), + "Named backup restore operations only accept backup names from the backups directory.", + ); + } const hasPathSeparator = /[\\/]/.test(trimmed); const hasDrivePrefix = /^[a-zA-Z]:/.test(trimmed); const segments = trimmed.split(/[\\/]+/).filter(Boolean); @@ -478,7 +486,7 @@ function normalizeBackupLookupName(rawName: string): string { "Named backup restore operations only accept backup names from the backups directory.", ); } - return normalizeBackupName(trimmed); + return trimmed; } function getNamedBackupsDirectory(): string { @@ -492,12 +500,8 @@ async function ensureNamedBackupsDirectory(): Promise { } function resolveNamedBackupPath(name: string): string { - const normalizedName = normalizeBackupName(name); const backupDir = resolve(getNamedBackupsDirectory()); - const backupPath = resolve( - backupDir, - `${normalizedName}${NAMED_BACKUP_EXTENSION}`, - ); + const backupPath = resolve(backupDir, `${name}${NAMED_BACKUP_EXTENSION}`); const relativePath = relative(backupDir, backupPath); if (relativePath.startsWith("..") || isAbsolute(relativePath)) { throw new StorageError( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 126852e..daff821 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1435,7 +1435,7 @@ describe("codex manager cli commands", () => { ); expect(confirmMock).toHaveBeenNthCalledWith( 2, - "Restore backup named-backup?", + 'Restore backup "named-backup"?', ); expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); expect( @@ -1445,7 +1445,7 @@ describe("codex manager cli commands", () => { ), ).toBe(true); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); - }, 20_000); + }); it("keeps auth login running when backup restore fails inside the browser", async () => { setInteractiveTTY(false); @@ -1847,7 +1847,10 @@ describe("codex manager cli commands", () => { it("shows epoch timestamps in backup browser details instead of unknown", async () => { setInteractiveTTY(false); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const epochDisplay = new Date(0).toLocaleString(); + const epochDisplay = "epoch-display"; + const toLocaleStringSpy = vi + .spyOn(Date.prototype, "toLocaleString") + .mockReturnValue(epochDisplay); const now = Date.now(); const rotatingBackup = { label: "Latest rotating backup (.bak)", @@ -1938,9 +1941,15 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(logSpy).toHaveBeenCalledWith(`Created: ${epochDisplay}`); expect(logSpy).toHaveBeenCalledWith(`Updated: ${epochDisplay}`); + expect( + selectMock.mock.calls + .flatMap(([items]) => items as Array<{ value: unknown }>) + .some((item) => item.value === "restore"), + ).toBe(false); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); } finally { + toLocaleStringSpy.mockRestore(); logSpy.mockRestore(); } }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 07369e9..9c69393 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -428,6 +428,54 @@ describe("storage", () => { }, ); + it("assesses and restores the exact named-backup basename from disk", async () => { + const backupDir = join(testWorkDir, "backups"); + await fs.mkdir(backupDir, { recursive: true }); + await fs.writeFile( + join(backupDir, "my backup.json"), + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "spaced-backup", + refreshToken: "ref-spaced", + addedAt: 10, + lastUsed: 10, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + join(backupDir, "my-backup.json"), + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "normalized-backup", + refreshToken: "ref-normalized", + addedAt: 20, + lastUsed: 20, + }, + ], + }), + "utf-8", + ); + await clearAccounts(); + + const assessment = await assessNamedBackupRestore("my backup"); + expect(assessment.backup.name).toBe("my backup"); + expect(assessment.backup.path).toBe(join(backupDir, "my backup.json")); + + const restoreResult = await restoreNamedBackup("my backup"); + expect(restoreResult.total).toBe(1); + + const restored = await loadAccounts(); + expect(restored?.accounts[0]?.accountId).toBe("spaced-backup"); + }); + it("lists rotating backups and marks invalid snapshots distinctly", async () => { await saveAccounts({ version: 3, @@ -1072,7 +1120,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("returns null when file does not exist", async () => { @@ -1174,7 +1222,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("creates directory and saves file", async () => { @@ -1224,7 +1272,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("deletes the file when it exists", async () => { @@ -1309,7 +1357,7 @@ describe("storage", () => { else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); } }); }); @@ -1386,7 +1434,7 @@ describe("storage", () => { afterEach(async () => { setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("logs but does not throw on non-ENOENT errors", async () => { @@ -1447,7 +1495,7 @@ describe("storage", () => { else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("writes .gitignore in project root when storage path is externalized", async () => { @@ -1564,7 +1612,7 @@ describe("storage", () => { else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("removes legacy project storage file after successful migration", async () => { @@ -1723,7 +1771,7 @@ describe("storage", () => { if (originalMultiAuthDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; else process.env.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -2036,7 +2084,7 @@ describe("storage", () => { afterEach(async () => { vi.useRealTimers(); setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); + await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); it("retries on EPERM and succeeds on second attempt", async () => { @@ -2573,7 +2621,7 @@ describe("storage", () => { afterEach(async () => { if (originalDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; else process.env.CODEX_MULTI_AUTH_DIR = originalDir; - await fs.rm(tmpRoot, { recursive: true, force: true }); + await removeWithRetry(tmpRoot, { recursive: true, force: true }); }); it("removes only the quota cache file", async () => { From ad63d5ae2dee6660b70a4f96b0b05b0513892f5a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:29:46 +0800 Subject: [PATCH 11/11] fix(storage): catch rotating backup path resolution --- lib/storage.ts | 5 ++-- test/codex-manager-cli.test.ts | 42 +++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index a0b8197..a34a514 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1401,8 +1401,9 @@ export async function listNamedBackups(): Promise { } export async function listRotatingBackups(): Promise { - const storagePath = getStoragePath(); + let storagePath: string | null = null; try { + storagePath = getStoragePath(); const candidates = getAccountsBackupRecoveryCandidates(storagePath); const backups: RotatingBackupMetadata[] = []; @@ -1429,7 +1430,7 @@ export async function listRotatingBackups(): Promise { return backups.sort((a, b) => a.slot - b.slot); } catch (error) { log.warn("Failed to list rotating backups", { - path: storagePath, + path: storagePath ?? "", error: String(error), }); return []; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index daff821..091df6d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1425,26 +1425,30 @@ describe("codex manager cli commands", () => { authModule.createAuthorizationFlow, ); - 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).toHaveBeenNthCalledWith( - 1, - "Found 1 recoverable backup (named-backup) in /mock/backups. Open backup browser now?", - ); - expect(confirmMock).toHaveBeenNthCalledWith( - 2, - 'Restore backup "named-backup"?', - ); - expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); - expect( - logSpy.mock.calls.some( - (call) => - call[0] === "Imported 1 account. Skipped 0. Total accounts: 1.", - ), - ).toBe(true); - expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + expect(confirmMock).toHaveBeenNthCalledWith( + 1, + "Found 1 recoverable backup (named-backup) in /mock/backups. Open backup browser now?", + ); + expect(confirmMock).toHaveBeenNthCalledWith( + 2, + 'Restore backup "named-backup"?', + ); + expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup"); + expect( + logSpy.mock.calls.some( + (call) => + call[0] === "Imported 1 account. Skipped 0. Total accounts: 1.", + ), + ).toBe(true); + expect(createAuthorizationFlowMock).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + } }); it("keeps auth login running when backup restore fails inside the browser", async () => {