From 69f19839160dd50e6c12791a06a47312a5648283 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 12:38:05 +0800 Subject: [PATCH 1/8] Add rotating account backup fallback recovery Keep three backup snapshots for account storage (.bak, .bak.1, .bak.2), rotate them on save, and scan backups in recovery order during load. Also clear historical backups in clearAccounts and add regression tests for historical backup restore and backup rotation behavior. Co-authored-by: Codex --- lib/storage.ts | 146 ++++++++++++++++++---------- test/storage-recovery-paths.test.ts | 28 ++++++ test/storage.test.ts | 39 ++++++++ 3 files changed, 164 insertions(+), 49 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 1f6c5cb1..557dfc1b 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -31,6 +31,7 @@ const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; const ACCOUNTS_BACKUP_SUFFIX = ".bak"; const ACCOUNTS_WAL_SUFFIX = ".wal"; +const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; @@ -156,10 +157,72 @@ function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } +function getAccountsBackupPathAtIndex(path: string, index: number): string { + if (index <= 0) { + return getAccountsBackupPath(path); + } + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +function getAccountsBackupRecoveryCandidates(path: string): string[] { + const candidates: string[] = []; + for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + function getAccountsWalPath(path: string): string { return `${path}${ACCOUNTS_WAL_SUFFIX}`; } +async function copyFileWithRetry( + sourcePath: string, + destinationPath: string, + options?: { allowMissingSource?: boolean }, +): Promise { + const allowMissingSource = options?.allowMissingSource ?? false; + for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.copyFile(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (allowMissingSource && code === "ENOENT") { + return; + } + const canRetry = + (code === "EPERM" || code === "EBUSY") && + attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; + if (canRetry) { + await new Promise((resolve) => + setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt), + ); + continue; + } + throw error; + } + } +} + +async function createRotatingAccountsBackup(path: string): Promise { + const candidates = getAccountsBackupRecoveryCandidates(path); + for (let i = candidates.length - 1; i > 0; i -= 1) { + const previousPath = candidates[i - 1]; + const currentPath = candidates[i]; + if (!previousPath || !currentPath || !existsSync(previousPath)) { + continue; + } + await copyFileWithRetry(previousPath, currentPath, { allowMissingSource: true }); + } + + const latestBackupPath = candidates[0]; + if (!latestBackupPath) { + return; + } + await copyFileWithRetry(path, latestBackupPath); +} + function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -718,36 +781,38 @@ async function loadAccountsInternal( } if (storageBackupEnabled) { - const backupPath = getAccountsBackupPath(path); - try { - const backup = await loadAccountsFromPath(backupPath); - if (backup.schemaErrors.length > 0) { - log.warn("Backup account storage schema validation warnings", { - path: backupPath, - errors: backup.schemaErrors.slice(0, 5), - }); - } - if (backup.normalized) { - log.warn("Recovered account storage from backup file", { path, backupPath }); - if (persistMigration) { - try { - await persistMigration(backup.normalized); - } catch (persistError) { - log.warn("Failed to persist recovered backup storage", { - path, - error: String(persistError), - }); + const backupCandidates = getAccountsBackupRecoveryCandidates(path); + for (const backupPath of backupCandidates) { + try { + const backup = await loadAccountsFromPath(backupPath); + if (backup.schemaErrors.length > 0) { + log.warn("Backup account storage schema validation warnings", { + path: backupPath, + errors: backup.schemaErrors.slice(0, 5), + }); + } + if (backup.normalized) { + log.warn("Recovered account storage from backup file", { path, backupPath }); + if (persistMigration) { + try { + await persistMigration(backup.normalized); + } catch (persistError) { + log.warn("Failed to persist recovered backup storage", { + path, + error: String(persistError), + }); + } } + return backup.normalized; + } + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn("Failed to load backup account storage", { + path: backupPath, + error: String(backupError), + }); } - return backup.normalized; - } - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load backup account storage", { - path: backupPath, - error: String(backupError), - }); } } } @@ -770,29 +835,12 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { await ensureGitignore(path); if (storageBackupEnabled && existsSync(path)) { - const backupPath = getAccountsBackupPath(path); try { - for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { - try { - await fs.copyFile(path, backupPath); - break; - } catch (backupError) { - const code = (backupError as NodeJS.ErrnoException).code; - const canRetry = (code === "EPERM" || code === "EBUSY") && - attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; - if (canRetry) { - await new Promise((resolve) => - setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt) - ); - continue; - } - throw backupError; - } - } + await createRotatingAccountsBackup(path); } catch (backupError) { log.warn("Failed to create account storage backup", { path, - backupPath, + backupPath: getAccountsBackupPath(path), error: String(backupError), }); } @@ -902,7 +950,7 @@ export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); const walPath = getAccountsWalPath(path); - const backupPath = getAccountsBackupPath(path); + const backupPaths = getAccountsBackupRecoveryCandidates(path); const clearPath = async (targetPath: string): Promise => { try { await fs.unlink(targetPath); @@ -918,7 +966,7 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([clearPath(path), clearPath(walPath), clearPath(backupPath)]); + await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index f4f63f79..d1526765 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -93,6 +93,34 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.accountId).toBe("from-backup"); }); + it("falls back to historical backup snapshots when the latest backup is unreadable", async () => { + await fs.writeFile(storagePath, "{broken-primary", "utf-8"); + await fs.writeFile(`${storagePath}.bak`, "{broken-latest-backup", "utf-8"); + + const historicalBackupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "historical-refresh", + accountId: "from-backup-history", + addedAt: 4, + lastUsed: 4, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak.1`, JSON.stringify(historicalBackupPayload), "utf-8"); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("from-backup-history"); + + const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ accountId?: string }>; + }; + expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-history"); + }); + it("does not use backup recovery when backups are disabled", async () => { setStorageBackupEnabled(false); await fs.writeFile(storagePath, "{broken-primary", "utf-8"); diff --git a/test/storage.test.ts b/test/storage.test.ts index a6edd30b..abf6815d 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1570,6 +1570,39 @@ describe("storage", () => { expect(copyAttempts).toBe(2); copySpy.mockRestore(); }); + + it("rotates backups and retains historical snapshots", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], + }); + + const latestBackupRaw = await fs.readFile(`${storagePath}.bak`, "utf-8"); + const historicalBackupRaw = await fs.readFile(`${storagePath}.bak.1`, "utf-8"); + const latestBackup = JSON.parse(latestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(historicalBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); }); describe("clearAccounts edge cases", () => { @@ -1584,16 +1617,22 @@ describe("storage", () => { const storagePath = getStoragePath(); await saveAccounts(storage); await fs.writeFile(`${storagePath}.bak`, JSON.stringify(storage), "utf-8"); + await fs.writeFile(`${storagePath}.bak.1`, JSON.stringify(storage), "utf-8"); + await fs.writeFile(`${storagePath}.bak.2`, JSON.stringify(storage), "utf-8"); await fs.writeFile(`${storagePath}.wal`, JSON.stringify(storage), "utf-8"); expect(existsSync(storagePath)).toBe(true); expect(existsSync(`${storagePath}.bak`)).toBe(true); + expect(existsSync(`${storagePath}.bak.1`)).toBe(true); + expect(existsSync(`${storagePath}.bak.2`)).toBe(true); expect(existsSync(`${storagePath}.wal`)).toBe(true); await clearAccounts(); expect(existsSync(storagePath)).toBe(false); expect(existsSync(`${storagePath}.bak`)).toBe(false); + expect(existsSync(`${storagePath}.bak.1`)).toBe(false); + expect(existsSync(`${storagePath}.bak.2`)).toBe(false); expect(existsSync(`${storagePath}.wal`)).toBe(false); }); From 025280a699c35e98faffb7bd442823cc82f25ad3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 12:53:37 +0800 Subject: [PATCH 2/8] fix(storage): harden user home auto-detection order Prefer user-scoped environment variables before os.homedir() so account storage resolves consistently across users on Windows.\n\nAdd runtime-path tests covering USERPROFILE, HOME, and HOMEDRIVE/HOMEPATH fallback behavior. Co-authored-by: Codex --- lib/runtime-paths.ts | 37 +++++++++++++++++++++++++++++---- test/runtime-paths.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index dd8ab244..bf97c053 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -2,6 +2,34 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync } from "node:fs"; +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const trimmed = (value ?? "").trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return null; +} + +function getResolvedUserHomeDir(): string { + if (process.platform === "win32") { + const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); + const homePath = (process.env.HOMEPATH ?? "").trim(); + const drivePathHome = + homeDrive.length > 0 && homePath.length > 0 ? `${homeDrive}${homePath}` : undefined; + return ( + firstNonEmpty([ + process.env.USERPROFILE, + process.env.HOME, + drivePathHome, + homedir(), + ]) ?? homedir() + ); + } + return firstNonEmpty([process.env.HOME, homedir()]) ?? homedir(); +} + /** * Resolve the Codex home directory path used by the CLI, honoring an environment override or a sensible default. * @@ -12,7 +40,7 @@ import { existsSync } from "node:fs"; export function getCodexHomeDir(): string { const fromEnv = (process.env.CODEX_HOME ?? "").trim(); - return fromEnv.length > 0 ? fromEnv : join(homedir(), ".codex"); + return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); } /** @@ -93,10 +121,11 @@ function hasAccountsStorage(dir: string): boolean { * @returns An array of unique, trimmed directory paths to probe for Codex home data, in prioritized order. */ function getFallbackCodexHomeDirs(): string[] { + const userHome = getResolvedUserHomeDir(); return deduplicatePaths([ getCodexHomeDir(), - join(homedir(), "DevTools", "config", "codex"), - join(homedir(), ".codex"), + join(userHome, "DevTools", "config", "codex"), + join(userHome, ".codex"), ]); } @@ -189,6 +218,6 @@ export function getCodexLogDir(): string { * @returns The filesystem path for the legacy directory (e.g. `/home/alice/.codex`). */ export function getLegacyCodexDir(): string { - return join(homedir(), ".codex"); + return join(getResolvedUserHomeDir(), ".codex"); } diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 7d52fee6..9b506a97 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -13,6 +13,10 @@ describe("runtime-paths", () => { vi.clearAllMocks(); delete process.env.CODEX_HOME; delete process.env.CODEX_MULTI_AUTH_DIR; + delete process.env.USERPROFILE; + delete process.env.HOME; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; homedir.mockReturnValue("/home/neil"); }); @@ -68,4 +72,42 @@ describe("runtime-paths", () => { platformSpy.mockRestore(); } }); + + it("prefers USERPROFILE over os.homedir on Windows when CODEX_HOME is unset", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.USERPROFILE = "C:\\Users\\Alice"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("C:\\Users\\Alice\\.codex"); + expect(mod.getLegacyCodexDir()).toBe("C:\\Users\\Alice\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("falls back to HOME when USERPROFILE is missing on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOME = "D:\\Users\\Bob"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("D:\\Users\\Bob\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("falls back to HOMEDRIVE and HOMEPATH when USERPROFILE and HOME are missing on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOMEDRIVE = "E:"; + process.env.HOMEPATH = "\\Users\\Carol"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("E:\\Users\\Carol\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); }); From 88ebe653ab336dc8c57a2d7ffdf189ca52e4f6c2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 13:54:48 +0800 Subject: [PATCH 3/8] fix: address PR review findings for backup and path handling Normalize Windows HOMEDRIVE/HOMEPATH resolution, restore env vars in runtime path tests, add HOMEPATH no-leading-slash regression, extend backup recovery coverage to .bak.2, and strengthen backup rotation tests with EPERM retry and parallel-save ordering checks. Co-authored-by: Codex --- lib/runtime-paths.ts | 6 +- test/runtime-paths.test.ts | 44 ++++++++++++-- test/storage-recovery-paths.test.ts | 29 +++++++++ test/storage.test.ts | 94 ++++++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index bf97c053..427749e3 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import { existsSync } from "node:fs"; function firstNonEmpty(values: Array): string | null { @@ -17,7 +17,9 @@ function getResolvedUserHomeDir(): string { const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); const homePath = (process.env.HOMEPATH ?? "").trim(); const drivePathHome = - homeDrive.length > 0 && homePath.length > 0 ? `${homeDrive}${homePath}` : undefined; + homeDrive.length > 0 && homePath.length > 0 + ? win32.resolve(`${homeDrive}\\`, homePath) + : undefined; return ( firstNonEmpty([ process.env.USERPROFILE, diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 9b506a97..aeba1d52 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -7,20 +7,39 @@ const homedir = vi.fn(() => "/home/neil"); vi.mock("node:fs", () => ({ existsSync })); vi.mock("node:os", () => ({ homedir })); +const ENV_KEYS = [ + "CODEX_HOME", + "CODEX_MULTI_AUTH_DIR", + "USERPROFILE", + "HOME", + "HOMEDRIVE", + "HOMEPATH", +] as const; + +type EnvKey = (typeof ENV_KEYS)[number]; + describe("runtime-paths", () => { + const originalEnv: Partial> = {}; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - delete process.env.CODEX_HOME; - delete process.env.CODEX_MULTI_AUTH_DIR; - delete process.env.USERPROFILE; - delete process.env.HOME; - delete process.env.HOMEDRIVE; - delete process.env.HOMEPATH; + for (const key of ENV_KEYS) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } homedir.mockReturnValue("/home/neil"); }); afterEach(() => { + for (const key of ENV_KEYS) { + const value = originalEnv[key]; + if (typeof value === "string") { + process.env[key] = value; + } else { + delete process.env[key]; + } + } vi.restoreAllMocks(); }); @@ -110,4 +129,17 @@ describe("runtime-paths", () => { platformSpy.mockRestore(); } }); + + it("normalizes HOMEPATH without a leading slash on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOMEDRIVE = "E:"; + process.env.HOMEPATH = "Users\\Carol"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("E:\\Users\\Carol\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); }); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index d1526765..fdfa69ef 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -121,6 +121,35 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-history"); }); + it("falls back to .bak.2 when newer backups are unreadable", async () => { + await fs.writeFile(storagePath, "{broken-primary", "utf-8"); + await fs.writeFile(`${storagePath}.bak`, "{broken-bak", "utf-8"); + await fs.writeFile(`${storagePath}.bak.1`, "{broken-bak-1", "utf-8"); + + const oldestBackupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "deep-refresh", + accountId: "from-backup-2", + addedAt: 5, + lastUsed: 5, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak.2`, JSON.stringify(oldestBackupPayload), "utf-8"); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("from-backup-2"); + + const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ accountId?: string }>; + }; + expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-2"); + }); + it("does not use backup recovery when backups are disabled", async () => { setStorageBackupEnabled(false); await fs.writeFile(storagePath, "{broken-primary", "utf-8"); diff --git a/test/storage.test.ts b/test/storage.test.ts index abf6815d..768cb509 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1571,6 +1571,38 @@ describe("storage", () => { copySpy.mockRestore(); }); + it("retries backup copyFile on transient EPERM and succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalCopy = fs.copyFile.bind(fs); + let copyAttempts = 0; + const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { + copyAttempts += 1; + if (copyAttempts === 1) { + const err = new Error("EPERM copy") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); + + expect(copyAttempts).toBe(2); + copySpy.mockRestore(); + }); + it("rotates backups and retains historical snapshots", async () => { const now = Date.now(); const storagePath = getStoragePath(); @@ -1590,18 +1622,76 @@ describe("storage", () => { activeIndex: 0, accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], + }); const latestBackupRaw = await fs.readFile(`${storagePath}.bak`, "utf-8"); const historicalBackupRaw = await fs.readFile(`${storagePath}.bak.1`, "utf-8"); + const oldestBackupRaw = await fs.readFile(`${storagePath}.bak.2`, "utf-8"); const latestBackup = JSON.parse(latestBackupRaw) as { accounts?: Array<{ refreshToken?: string }>; }; const historicalBackup = JSON.parse(historicalBackupRaw) as { accounts?: Array<{ refreshToken?: string }>; }; + const oldestBackup = JSON.parse(oldestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + + it("keeps rotating backup order deterministic across parallel saves", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-0", addedAt: now, lastUsed: now }], + }); + + await Promise.all([ + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now + 1, lastUsed: now + 1 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 2, lastUsed: now + 2 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 3, lastUsed: now + 3 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 4, lastUsed: now + 4 }], + }), + ]); + + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; - expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-2"); - expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); }); }); From 6f6682d87a895bd2bd752a0a45cf3785f0173368 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 14:03:57 +0800 Subject: [PATCH 4/8] test/storage: finalize review fixes for retry and mock safety Add transient 429 retry support for backup copy operations and harden retry tests by always restoring fs.copyFile spies in finally blocks to avoid cross-test contamination. Co-authored-by: Codex --- lib/storage.ts | 2 +- test/storage.test.ts | 62 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 557dfc1b..e24b50c6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -192,7 +192,7 @@ async function copyFileWithRetry( return; } const canRetry = - (code === "EPERM" || code === "EBUSY") && + (code === "EPERM" || code === "EBUSY" || code === "E429" || code === "429") && attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; if (canRetry) { await new Promise((resolve) => diff --git a/test/storage.test.ts b/test/storage.test.ts index 768cb509..2dc2bd8f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1561,14 +1561,16 @@ describe("storage", () => { } return originalCopy(src as string, dest as string); }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], - }); - - expect(copyAttempts).toBe(2); - copySpy.mockRestore(); + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } }); it("retries backup copyFile on transient EPERM and succeeds", async () => { @@ -1593,14 +1595,50 @@ describe("storage", () => { } return originalCopy(src as string, dest as string); }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } + }); + + it("retries backup copyFile on transient 429 and succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalCopy = fs.copyFile.bind(fs); + let copyAttempts = 0; + const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { + copyAttempts += 1; + if (copyAttempts === 1) { + const err = new Error("429 copy") as NodeJS.ErrnoException; + err.code = "429"; + throw err; + } + return originalCopy(src as string, dest as string); }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); - expect(copyAttempts).toBe(2); - copySpy.mockRestore(); + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } }); it("rotates backups and retains historical snapshots", async () => { From 8a39fadcbe84aa7e81d9e0571c1a6a71ce0d4b3f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 15:04:26 +0800 Subject: [PATCH 5/8] fix(storage): remove invalid backup retry error codes Limit filesystem backup copy retries to real Node fs transient codes (EPERM, EBUSY) and remove synthetic 429 retry test coverage. Co-authored-by: Codex --- lib/storage.ts | 2 +- test/storage.test.ts | 34 ---------------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index e24b50c6..557dfc1b 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -192,7 +192,7 @@ async function copyFileWithRetry( return; } const canRetry = - (code === "EPERM" || code === "EBUSY" || code === "E429" || code === "429") && + (code === "EPERM" || code === "EBUSY") && attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; if (canRetry) { await new Promise((resolve) => diff --git a/test/storage.test.ts b/test/storage.test.ts index 2dc2bd8f..4ef3df68 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1607,40 +1607,6 @@ describe("storage", () => { } }); - it("retries backup copyFile on transient 429 and succeeds", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - // Seed a primary file so backup creation path runs on next save. - await saveAccounts(storage); - - const originalCopy = fs.copyFile.bind(fs); - let copyAttempts = 0; - const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { - copyAttempts += 1; - if (copyAttempts === 1) { - const err = new Error("429 copy") as NodeJS.ErrnoException; - err.code = "429"; - throw err; - } - return originalCopy(src as string, dest as string); - }); - try { - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], - }); - - expect(copyAttempts).toBe(2); - } finally { - copySpy.mockRestore(); - } - }); - it("rotates backups and retains historical snapshots", async () => { const now = Date.now(); const storagePath = getStoragePath(); From 35fef030661027bfb18260153e150bbd2ef1d758 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 15:30:28 +0800 Subject: [PATCH 6/8] fix(storage): stage backup rotation and close review gaps Stage rotation copies to temp files before applying backup slot updates to avoid partial backup-chain rewrites when the latest backup copy fails.\n\nAdd regression coverage for preserving historical backups on latest-copy failure and assert primary snapshot in parallel rotation test. Co-authored-by: Codex --- lib/storage.ts | 52 ++++++++++++++++++++++++++-------- test/storage.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 557dfc1b..bf6ec1d7 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -207,20 +207,50 @@ async function copyFileWithRetry( async function createRotatingAccountsBackup(path: string): Promise { const candidates = getAccountsBackupRecoveryCandidates(path); - for (let i = candidates.length - 1; i > 0; i -= 1) { - const previousPath = candidates[i - 1]; - const currentPath = candidates[i]; - if (!previousPath || !currentPath || !existsSync(previousPath)) { - continue; + const rotationNonce = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const stagedWrites: Array<{ targetPath: string; stagedPath: string }> = []; + const buildStagedPath = (targetPath: string, label: string): string => + `${targetPath}.rotate.${rotationNonce}.${label}.tmp`; + + try { + for (let i = candidates.length - 1; i > 0; i -= 1) { + const previousPath = candidates[i - 1]; + const currentPath = candidates[i]; + if (!previousPath || !currentPath || !existsSync(previousPath)) { + continue; + } + const stagedPath = buildStagedPath(currentPath, `slot-${i}`); + await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true }); + if (existsSync(stagedPath)) { + stagedWrites.push({ targetPath: currentPath, stagedPath }); + } } - await copyFileWithRetry(previousPath, currentPath, { allowMissingSource: true }); - } - const latestBackupPath = candidates[0]; - if (!latestBackupPath) { - return; + const latestBackupPath = candidates[0]; + if (!latestBackupPath) { + return; + } + const latestStagedPath = buildStagedPath(latestBackupPath, "latest"); + await copyFileWithRetry(path, latestStagedPath); + if (existsSync(latestStagedPath)) { + stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath }); + } + + for (const stagedWrite of stagedWrites) { + await fs.rename(stagedWrite.stagedPath, stagedWrite.targetPath); + } + } finally { + for (const stagedWrite of stagedWrites) { + if (!existsSync(stagedWrite.stagedPath)) { + continue; + } + try { + await fs.unlink(stagedWrite.stagedPath); + } catch { + // Best effort cleanup for staged rotation artifacts. + } + } } - await copyFileWithRetry(path, latestBackupPath); } function computeSha256(value: string): string { diff --git a/test/storage.test.ts b/test/storage.test.ts index 4ef3df68..068cafa3 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1650,6 +1650,69 @@ describe("storage", () => { expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); }); + it("preserves historical backups when creating the latest backup fails", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], + }); + + const originalCopy = fs.copyFile.bind(fs); + const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { + if (src === storagePath) { + const err = new Error("ENOSPC backup copy") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-5", addedAt: now + 4, lastUsed: now + 4 }], + }); + } finally { + copySpy.mockRestore(); + } + + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(primary.accounts?.[0]?.refreshToken).toBe("token-5"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + it("keeps rotating backup order deterministic across parallel saves", async () => { const now = Date.now(); const storagePath = getStoragePath(); @@ -1686,6 +1749,9 @@ describe("storage", () => { const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { accounts?: Array<{ refreshToken?: string }>; }; + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { accounts?: Array<{ refreshToken?: string }>; }; @@ -1693,6 +1759,7 @@ describe("storage", () => { accounts?: Array<{ refreshToken?: string }>; }; + expect(primary.accounts?.[0]?.refreshToken).toBe("token-4"); expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); From e250bf204d5fc59b25c3d5741b80f56243c73ae4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 2 Mar 2026 16:04:51 +0800 Subject: [PATCH 7/8] fix(storage): retry staged backup rename commits Add retry/backoff for staged backup rename commits to tolerate transient filesystem locks during rotation, and add regression coverage for EBUSY staged rename retry. Co-authored-by: Codex --- lib/storage.ts | 23 ++++++++++++++++++++++- test/storage.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index bf6ec1d7..7df5d5bc 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -205,6 +205,27 @@ async function copyFileWithRetry( } } +async function renameFileWithRetry(sourcePath: string, destinationPath: string): Promise { + for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const canRetry = + (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && + attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; + if (!canRetry) { + throw error; + } + const jitterMs = Math.floor(Math.random() * BACKUP_COPY_BASE_DELAY_MS); + await new Promise((resolve) => + setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs), + ); + } + } +} + async function createRotatingAccountsBackup(path: string): Promise { const candidates = getAccountsBackupRecoveryCandidates(path); const rotationNonce = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; @@ -237,7 +258,7 @@ async function createRotatingAccountsBackup(path: string): Promise { } for (const stagedWrite of stagedWrites) { - await fs.rename(stagedWrite.stagedPath, stagedWrite.targetPath); + await renameFileWithRetry(stagedWrite.stagedPath, stagedWrite.targetPath); } } finally { for (const stagedWrite of stagedWrites) { diff --git a/test/storage.test.ts b/test/storage.test.ts index 068cafa3..3c3157e8 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1607,6 +1607,48 @@ describe("storage", () => { } }); + it("retries staged backup rename on transient EBUSY and succeeds", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalRename = fs.rename.bind(fs); + let stagedRenameAttempts = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { + const sourcePath = String(oldPath); + if (sourcePath.includes(".rotate.")) { + stagedRenameAttempts += 1; + if (stagedRenameAttempts === 1) { + const err = new Error("EBUSY staged rename") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + } + return originalRename(oldPath as string, newPath as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now + 1, lastUsed: now + 1 }], + }); + + expect(stagedRenameAttempts).toBe(2); + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token"); + } finally { + renameSpy.mockRestore(); + } + }); + it("rotates backups and retains historical snapshots", async () => { const now = Date.now(); const storagePath = getStoragePath(); From a6e6a75ec47991dd487a1f5cc2be5e0068f2e3f5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 02:29:33 +0800 Subject: [PATCH 8/8] fix(storage): clean stale staged backup temp artifacts Delete orphaned backup rotation staged files during account load to prevent leftover token-bearing temp artifacts after interrupted writes. Add recovery-path regression coverage that verifies .bak/.bak.1/.bak.2 rotate temp files are removed while unrelated temp files remain untouched. Co-authored-by: Codex --- lib/storage.ts | 54 +++++++++++++++++++++++++++++ test/storage-recovery-paths.test.ts | 36 ++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index 7df5d5bc..30c765c2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -274,6 +274,59 @@ async function createRotatingAccountsBackup(path: string): Promise { } } +function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string): boolean { + const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`; + if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) { + return false; + } + + const suffix = candidatePath.slice(backupPrefix.length); + const rotateSeparatorIndex = suffix.indexOf(".rotate."); + if (rotateSeparatorIndex === -1) { + return false; + } + + const backupIndexSuffix = suffix.slice(0, rotateSeparatorIndex); + if (backupIndexSuffix.length > 0 && !/^\.\d+$/.test(backupIndexSuffix)) { + return false; + } + + return true; +} + +async function cleanupStaleRotatingBackupArtifacts(path: string): Promise { + const directoryPath = dirname(path); + try { + const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true }); + const staleArtifacts = directoryEntries + .filter((entry) => entry.isFile()) + .map((entry) => join(directoryPath, entry.name)) + .filter((entryPath) => isRotatingBackupTempArtifact(path, entryPath)); + + for (const staleArtifactPath of staleArtifacts) { + try { + await fs.unlink(staleArtifactPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove stale rotating backup artifact", { + path: staleArtifactPath, + error: String(error), + }); + } + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to scan for stale rotating backup artifacts", { + path, + error: String(error), + }); + } + } +} + function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -789,6 +842,7 @@ async function loadAccountsInternal( persistMigration: ((storage: AccountStorageV3) => Promise) | null, ): Promise { const path = getStoragePath(); + await cleanupStaleRotatingBackupArtifacts(path); const migratedLegacyStorage = persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index fdfa69ef..3b7ab43c 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { promises as fs } from "node:fs"; +import { promises as fs, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -150,6 +150,40 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-2"); }); + it("cleans up stale staged backup artifacts during load", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "primary-refresh", accountId: "primary", addedAt: 6, lastUsed: 6 }], + }), + "utf-8", + ); + + const staleArtifacts = [ + `${storagePath}.bak.rotate.12345.abc123.latest.tmp`, + `${storagePath}.bak.1.rotate.12345.abc123.slot-1.tmp`, + `${storagePath}.bak.2.rotate.12345.abc123.slot-2.tmp`, + ]; + for (const staleArtifactPath of staleArtifacts) { + await fs.writeFile(staleArtifactPath, "stale", "utf-8"); + expect(existsSync(staleArtifactPath)).toBe(true); + } + const unrelatedArtifactPath = `${storagePath}.rotate.12345.abc123.latest.tmp`; + await fs.writeFile(unrelatedArtifactPath, "keep", "utf-8"); + expect(existsSync(unrelatedArtifactPath)).toBe(true); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("primary"); + + for (const staleArtifactPath of staleArtifacts) { + expect(existsSync(staleArtifactPath)).toBe(false); + } + expect(existsSync(unrelatedArtifactPath)).toBe(true); + }); + it("does not use backup recovery when backups are disabled", async () => { setStorageBackupEnabled(false); await fs.writeFile(storagePath, "{broken-primary", "utf-8");