From b9243700d7044396b6c4cb844bdbce3a4c04475c Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 09:15:27 +0800 Subject: [PATCH 01/15] test(storage): add resilience regression coverage --- test/runtime-paths.test.ts | 16 ++ test/storage-flagged.test.ts | 47 ++++- test/storage-recovery-paths.test.ts | 260 ++++++++++++++++++++++++++++ test/storage.test.ts | 55 ++++-- 4 files changed, 365 insertions(+), 13 deletions(-) diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 95976776..24b584db 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -86,6 +86,22 @@ describe("runtime-paths", () => { expect(mod.getCodexMultiAuthDir()).toBe(fallback); }); + it("keeps canonical multi-auth root steady-state even when fallback still holds accounts", async () => { + process.env.CODEX_HOME = "/home/neil/.codex-canonical"; + const primary = path.join("/home/neil/.codex-canonical", "multi-auth"); + const fallback = path.join("/home/neil/DevTools/config/codex", "multi-auth"); + + existsSync.mockImplementation((candidate: unknown) => { + if (typeof candidate !== "string") return false; + if (candidate === path.join(primary, "settings.json")) return true; + if (candidate === path.join(fallback, "openai-codex-accounts.json")) return true; + return false; + }); + + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexMultiAuthDir()).toBe(primary); + }); + it("uses legacy root when it is the only directory containing account storage", async () => { process.env.CODEX_HOME = "/home/neil/.codex"; const legacyRoot = path.join("/home/neil", ".codex"); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 7fce9e18..9efd733d 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { clearFlaggedAccounts, + getBackupMetadata, getFlaggedAccountsPath, getStoragePath, loadFlaggedAccounts, @@ -166,8 +167,50 @@ describe("flagged account storage", () => { await clearFlaggedAccounts(); await clearFlaggedAccounts(); - expect(existsSync(getFlaggedAccountsPath())).toBe(false); - }); + expect(existsSync(getFlaggedAccountsPath())).toBe(false); + }); + + it("emits snapshot metadata for flagged account backups", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "first-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "first-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "second-flagged", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const metadata = await getBackupMetadata(); + const flagged = metadata.flaggedAccounts; + expect(flagged.snapshotCount).toBeGreaterThanOrEqual(2); + expect(flagged.latestValidPath).toBe(getFlaggedAccountsPath()); + const primary = flagged.snapshots.find((snapshot) => snapshot.kind === "flagged-primary"); + const backup = flagged.snapshots.find((snapshot) => snapshot.kind === "flagged-backup"); + expect(primary?.flaggedCount).toBe(2); + expect(backup?.valid).toBe(true); + expect(backup?.flaggedCount).toBe(1); + }); it("cleans temporary file when flagged save fails", async () => { const flaggedPath = getFlaggedAccountsPath(); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 7e2a3f3a..286548cd 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -5,11 +5,25 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { loadAccounts, + getBackupMetadata, saveAccounts, setStorageBackupEnabled, setStoragePathDirect, + clearAccounts, + getRestoreAssessment, } from "../lib/storage.js"; +function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { + if (value && typeof value === "object" && "restoreEligible" in value) { + const candidate = value as { restoreEligible?: unknown; restoreReason?: unknown }; + return { + restoreEligible: typeof candidate.restoreEligible === "boolean" ? candidate.restoreEligible : undefined, + restoreReason: typeof candidate.restoreReason === "string" ? candidate.restoreReason : undefined, + }; + } + return {}; +} + function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -326,6 +340,180 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.email).toBe("realuser@gmail.com"); }); + it("surfaces restore eligibility when account pool is missing", async () => { + await fs.rm(storagePath, { force: true }); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("missing-storage"); + }); + + it("surfaces restore eligibility when account pool is empty", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("empty-storage"); + }); + + it("suppresses restore eligibility after intentional reset but flags unexpected empty state", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "token-reset", + accountId: "reset-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await clearAccounts(); + const afterIntentionalReset = await loadAccounts(); + const intentionalEligibility = getRestoreEligibility(afterIntentionalReset); + expect(intentionalEligibility.restoreEligible).toBe(false); + + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + const afterAccidentalEmpty = await loadAccounts(); + const accidentalEligibility = getRestoreEligibility(afterAccidentalEmpty); + expect(accidentalEligibility.restoreEligible).toBe(true); + }); + + it("assesses restore state with latest snapshot metadata", async () => { + const backupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "backup-refresh", + accountId: "from-backup", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak`, JSON.stringify(backupPayload), "utf-8"); + + const assessment = await getRestoreAssessment(); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot?.path).toBe(`${storagePath}.bak`); + expect(assessment.backupMetadata.accounts.latestValidPath).toBe(`${storagePath}.bak`); + }); + + it("ignores Codex CLI mirror files during restore assessment", async () => { + const codexCliAccountsPath = join(workDir, "accounts.json"); + const codexCliAuthPath = join(workDir, "auth.json"); + await fs.writeFile( + codexCliAccountsPath, + JSON.stringify({ + activeAccountId: "mirror-account", + accounts: [ + { + accountId: "mirror-account", + email: "mirror@example.com", + auth: { + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + }, + }, + }, + ], + }), + "utf-8", + ); + await fs.writeFile( + codexCliAuthPath, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + account_id: "mirror-account", + }, + }), + "utf-8", + ); + + const recovered = await loadAccounts(); + const eligibility = getRestoreEligibility(recovered); + expect(recovered?.accounts).toHaveLength(0); + expect(eligibility.restoreEligible).toBe(true); + expect(eligibility.restoreReason).toBe("missing-storage"); + + const assessment = await getRestoreAssessment(); + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot).toBeUndefined(); + expect(assessment.backupMetadata.accounts.latestValidPath).toBeUndefined(); + expect( + assessment.backupMetadata.accounts.snapshots.some( + (snapshot) => snapshot.path === codexCliAccountsPath || snapshot.path === codexCliAuthPath, + ), + ).toBe(false); + }); + + it("returns restore eligibility and snapshot when storage is empty", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const assessment = await getRestoreAssessment(); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("empty-storage"); + expect(assessment.latestSnapshot?.path).toBe(storagePath); + }); + + it("suppresses restore once after intentional reset marker", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "token-reset", + accountId: "reset-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await clearAccounts(); + + const suppressed = await getRestoreAssessment(); + expect(suppressed.restoreEligible).toBe(false); + expect(suppressed.restoreReason).toBe("intentional-reset"); + + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf-8", + ); + + const eligibleAfterReset = await getRestoreAssessment(); + expect(eligibleAfterReset.restoreEligible).toBe(true); + expect(eligibleAfterReset.restoreReason).toBe("empty-storage"); + }); + it("cleans up stale staged backup artifacts during load", async () => { await fs.writeFile( storagePath, @@ -376,5 +564,77 @@ describe("storage recovery paths", () => { const recovered = await loadAccounts(); expect(recovered).toBeNull(); }); + + it("exposes snapshot metadata and ignores cache-like artifacts", async () => { + await fs.writeFile(storagePath, "{invalid-json", "utf-8"); + + const walPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "wal-refresh-meta", + accountId: "wal-account", + addedAt: 10, + lastUsed: 10, + }, + ], + }; + const walContent = JSON.stringify(walPayload); + const walEntry = { + version: 1, + createdAt: Date.now(), + path: storagePath, + checksum: sha256(walContent), + content: walContent, + }; + await fs.writeFile(`${storagePath}.wal`, JSON.stringify(walEntry), "utf-8"); + + await fs.writeFile( + `${storagePath}.bak`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "bak-refresh-meta", + accountId: "bak-account", + addedAt: 5, + lastUsed: 5, + }, + ], + }), + "utf-8", + ); + + await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8"); + await fs.writeFile( + `${storagePath}.manual-meta-checkpoint`, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "manual-refresh-meta", + accountId: "manual-account", + addedAt: 7, + lastUsed: 7, + }, + ], + }), + "utf-8", + ); + + const metadata = await getBackupMetadata(); + const accountSnapshots = metadata.accounts.snapshots; + const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache")); + expect(cacheEntries).toHaveLength(0); + expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.wal`); + const discovered = accountSnapshots.find((snapshot) => snapshot.path.endsWith("manual-meta-checkpoint")); + expect(discovered?.kind).toBe("accounts-discovered-backup"); + expect(discovered?.valid).toBe(true); + expect(discovered?.accountCount).toBe(1); + expect(metadata.accounts.snapshotCount).toBeGreaterThanOrEqual(4); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 3c3157e8..db70c317 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -638,10 +638,12 @@ describe("storage", () => { await fs.rm(testWorkDir, { recursive: true, force: true }); }); - it("returns null when file does not exist", async () => { - const result = await loadAccounts(); - expect(result).toBeNull(); - }); + it("returns null when file does not exist", async () => { + const result = await loadAccounts(); + expect(result?.accounts).toHaveLength(0); + expect(result?.restoreEligible).toBe(true); + expect(result?.restoreReason).toBe("missing-storage"); + }); it("returns null on parse error", async () => { await fs.writeFile(testStoragePath, "not valid json{{{", "utf-8"); @@ -1060,6 +1062,37 @@ describe("storage", () => { expect(existsSync(legacyStoragePath)).toBe(false); expect(existsSync(getStoragePath())).toBe(true); }); + + it("migrates populated fallback root only after canonical write succeeds", async () => { + const fakeHome = join(testWorkDir, "home-fallback"); + const canonicalPath = join(fakeHome, ".codex", "multi-auth", "openai-codex-accounts.json"); + const fallbackPath = join(fakeHome, "DevTools", "config", "codex", "multi-auth", "openai-codex-accounts.json"); + + await fs.mkdir(dirname(fallbackPath), { recursive: true }); + await fs.writeFile( + fallbackPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "fallback-refresh", + accountId: "fallback-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + setStoragePathDirect(canonicalPath); + const loaded = await loadAccounts(); + + expect(loaded?.accounts?.[0]?.accountId).toBe("fallback-account"); + expect(existsSync(canonicalPath)).toBe(true); + expect(existsSync(fallbackPath)).toBe(false); + }); }); describe("worktree-scoped storage migration", () => { @@ -1830,14 +1863,14 @@ describe("storage", () => { expect(existsSync(`${storagePath}.bak.2`)).toBe(true); expect(existsSync(`${storagePath}.wal`)).toBe(true); - await clearAccounts(); + 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); - }); + expect(existsSync(storagePath)).toBe(false); + 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); + }); it("logs error for non-ENOENT errors during clear", async () => { const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue( From 7a4dab0d74e23e92fcec874a20422ca13a162afc Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 09:15:51 +0800 Subject: [PATCH 02/15] fix(storage): harden canonical recovery and reset flows --- docs/reference/storage-paths.md | 5 + lib/runtime-paths.ts | 20 + lib/storage.ts | 741 +++++++++++++++++++++++++++++--- 3 files changed, 706 insertions(+), 60 deletions(-) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index bae76b84..5a86b96e 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -25,6 +25,7 @@ Override root: | Accounts backup | `~/.codex/multi-auth/openai-codex-accounts.json.bak` | | Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | +| Flagged accounts backup | `~/.codex/multi-auth/openai-codex-flagged-accounts.json.bak` | | Quota cache | `~/.codex/multi-auth/quota-cache.json` | | Logs | `~/.codex/multi-auth/logs/codex-plugin/` | | Cache | `~/.codex/multi-auth/cache/` | @@ -36,6 +37,10 @@ Ownership note: - `~/.codex/multi-auth/*` is managed by this project. - `~/.codex/accounts.json` and `~/.codex/auth.json` are managed by official Codex CLI. +Backup metadata: + +- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool and flagged-account state (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts are excluded from recovery candidates. + --- ## Project-Scoped Account Paths diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index b0782ed5..b81af98c 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -72,6 +72,17 @@ function deduplicatePaths(paths: string[]): string[] { return result; } +function pathsEqualNormalized(a: string, b: string): boolean { + const normalize = (value: string): string => { + const trimmed = value.trim(); + if (process.platform === "win32") { + return win32.normalize(trimmed).toLowerCase(); + } + return trimmed; + }; + return normalize(a) === normalize(b); +} + /** * Detects whether a directory contains known Codex storage indicators. * @@ -169,6 +180,11 @@ export function getCodexMultiAuthDir(): string { return fromEnv; } + const codexHomeFromEnv = (process.env.CODEX_HOME ?? "").trim(); + const defaultCodexHome = join(getResolvedUserHomeDir(), ".codex"); + const isExplicitNonDefaultHome = + codexHomeFromEnv.length > 0 && !pathsEqualNormalized(codexHomeFromEnv, defaultCodexHome); + const primary = join(getCodexHomeDir(), "multi-auth"); const fallbackCandidates = deduplicatePaths([ ...getFallbackCodexHomeDirs().map((dir) => join(dir, "multi-auth")), @@ -176,6 +192,10 @@ export function getCodexMultiAuthDir(): string { ]); const orderedCandidates = deduplicatePaths([primary, ...fallbackCandidates]); + if (isExplicitNonDefaultHome) { + return primary; + } + // Prefer candidates that actually contain account storage. This prevents // accidentally switching to a fresh empty directory that only has settings files. for (const candidate of orderedCandidates) { diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426..5f9696d6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,10 +1,11 @@ import { promises as fs, existsSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, normalize } from "node:path"; import { createHash } from "node:crypto"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { getLegacyCodexDir } from "./runtime-paths.js"; import { getConfigDir, getProjectConfigDir, @@ -34,6 +35,7 @@ const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; +const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -114,6 +116,13 @@ type AccountLike = { lastUsed?: number; }; +type RestoreMetadata = { + restoreEligible?: boolean; + restoreReason?: "empty-storage" | "intentional-reset" | "missing-storage"; +}; + +export type AccountStorageWithMetadata = AccountStorageV3 & RestoreMetadata; + function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; const refreshToken = @@ -197,6 +206,11 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] { return candidates; } +function isCacheLikeBackupArtifactName(entryName: string): boolean { + const lower = entryName.toLowerCase(); + return lower.includes(".cache"); +} + async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): Promise { const knownCandidates = getAccountsBackupRecoveryCandidates(path); const discoveredCandidates = new Set(); @@ -209,6 +223,7 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): P for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.startsWith(candidatePrefix)) continue; + if (isCacheLikeBackupArtifactName(entry.name)) continue; if (entry.name.endsWith(".tmp")) continue; if (entry.name.includes(".rotate.")) continue; if (entry.name.endsWith(ACCOUNTS_WAL_SUFFIX)) continue; @@ -236,6 +251,48 @@ function getAccountsWalPath(path: string): string { return `${path}${ACCOUNTS_WAL_SUFFIX}`; } +function normalizePathForDedup(pathValue: string): string { + const normalized = normalize(pathValue.trim()); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function deduplicatePathList(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const candidate of paths) { + const trimmed = candidate.trim(); + if (!trimmed) continue; + const key = normalizePathForDedup(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + + return result; +} + +function pathsEqualNormalized(a: string, b: string): boolean { + return normalizePathForDedup(a) === normalizePathForDedup(b); +} + +function getFallbackAccountStoragePaths(currentPath: string): string[] { + const canonicalRoot = dirname(currentPath); + const canonicalHome = dirname(canonicalRoot); + const legacyRoot = getLegacyCodexDir(); + const candidateHomes = deduplicatePathList([dirname(canonicalHome), dirname(legacyRoot)]); + + const candidates = deduplicatePathList( + candidateHomes.flatMap((home) => [ + join(home, "DevTools", "config", "codex", "multi-auth", ACCOUNTS_FILE_NAME), + join(home, ".codex", "multi-auth", ACCOUNTS_FILE_NAME), + join(home, ".codex", ACCOUNTS_FILE_NAME), + ]), + ); + + return candidates.filter((candidate) => !pathsEqualNormalized(candidate, currentPath)); +} + async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -391,6 +448,364 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +export type BackupSnapshotKind = + | "accounts-primary" + | "accounts-wal" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup" + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup"; + +export interface BackupSnapshotMetadata { + kind: BackupSnapshotKind; + path: string; + index?: number; + exists: boolean; + valid: boolean; + bytes?: number; + mtimeMs?: number; + version?: number; + accountCount?: number; + flaggedCount?: number; + schemaErrors?: string[]; +} + +export interface BackupMetadataSection { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: BackupSnapshotMetadata[]; +} + +export interface BackupMetadata { + accounts: BackupMetadataSection; + flaggedAccounts: BackupMetadataSection; +} + +export interface RestoreAssessment { + storagePath: string; + restoreEligible: boolean; + restoreReason?: RestoreMetadata["restoreReason"]; + latestSnapshot?: BackupSnapshotMetadata; + backupMetadata: BackupMetadata; +} + +async function describePathStats( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise> { + try { + const stats = await fs.stat(path); + return { + kind, + path, + index, + exists: true, + bytes: stats.size, + mtimeMs: stats.mtimeMs, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to stat backup candidate", { path, error: String(error) }); + } + return { kind, path, index, exists: false }; + } +} + +async function describeAccountSnapshot( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise { + const base = await describePathStats(path, kind, index); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const { normalized, schemaErrors, storedVersion } = await loadAccountsFromPath(path); + return { + ...base, + valid: !!normalized, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect account snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function describeAccountsWalSnapshot(path: string): Promise { + const base = await describePathStats(path, "accounts-wal"); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const raw = await fs.readFile(path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) return { ...base, valid: false }; + const entry = parsed as Partial; + if (entry.version !== 1) return { ...base, valid: false }; + if (typeof entry.content !== "string" || typeof entry.checksum !== "string") { + return { ...base, valid: false }; + } + const computed = computeSha256(entry.content); + if (computed !== entry.checksum) { + return { ...base, valid: false }; + } + const data = JSON.parse(entry.content) as unknown; + const { normalized, schemaErrors, storedVersion } = parseAndNormalizeStorage(data); + return { + ...base, + valid: !!normalized, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect account WAL snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function loadFlaggedAccountsFromPath(path: string): Promise { + const content = await fs.readFile(path, "utf-8"); + const data = JSON.parse(content) as unknown; + return normalizeFlaggedStorage(data); +} + +async function describeFlaggedSnapshot( + path: string, + kind: BackupSnapshotKind, + index?: number, +): Promise { + const base = await describePathStats(path, kind, index); + if (!base.exists) { + return { ...base, valid: false }; + } + + try { + const storage = await loadFlaggedAccountsFromPath(path); + return { + ...base, + valid: true, + version: storage.version, + flaggedCount: storage.accounts.length, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to inspect flagged account snapshot", { + path, + error: String(error), + }); + } + return { ...base, valid: false }; + } +} + +async function recoverFlaggedAccountsFromBackups(path: string): Promise { + const candidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const candidate of candidates) { + try { + const recovered = await loadFlaggedAccountsFromPath(candidate); + log.warn("Recovered flagged account storage from backup file", { + path, + backupPath: candidate, + accounts: recovered.accounts.length, + }); + try { + await saveFlaggedAccounts(recovered); + } catch (persistError) { + log.warn("Failed to persist recovered flagged account storage", { + path, + error: String(persistError), + }); + } + return recovered; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to load flagged account backup", { + path: candidate, + error: String(error), + }); + } + } + } + return null; +} + +function selectLatestValidSnapshotPath(snapshots: BackupSnapshotMetadata[]): string | undefined { + const firstValid = snapshots.find((snapshot) => snapshot.valid); + return firstValid?.path; +} + +function selectSnapshotByPath( + section: BackupMetadataSection, + path: string | undefined, +): BackupSnapshotMetadata | undefined { + if (!path) return undefined; + return section.snapshots.find((snapshot) => snapshot.path === path); +} + +export async function getBackupMetadata(): Promise { + const storagePath = getStoragePath(); + const flaggedPath = getFlaggedAccountsPath(); + + const accountSnapshots: BackupSnapshotMetadata[] = []; + accountSnapshots.push(await describeAccountSnapshot(storagePath, "accounts-primary")); + accountSnapshots.push(await describeAccountsWalSnapshot(getAccountsWalPath(storagePath))); + + const knownAccountBackups = getAccountsBackupRecoveryCandidates(storagePath); + for (let i = 0; i < knownAccountBackups.length; i += 1) { + const candidate = knownAccountBackups[i]; + if (!candidate) continue; + const kind = i === 0 ? "accounts-backup" : "accounts-backup-history"; + accountSnapshots.push(await describeAccountSnapshot(candidate, kind, i)); + } + + const knownAccountSet = new Set(knownAccountBackups); + const discoveredAccountBackups = await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); + for (const candidate of discoveredAccountBackups) { + if (knownAccountSet.has(candidate)) continue; + accountSnapshots.push(await describeAccountSnapshot(candidate, "accounts-discovered-backup")); + } + + const accountsSection: BackupMetadataSection = { + storagePath, + latestValidPath: selectLatestValidSnapshotPath(accountSnapshots), + snapshotCount: accountSnapshots.length, + validSnapshotCount: accountSnapshots.filter((snapshot) => snapshot.valid).length, + snapshots: accountSnapshots, + }; + + const flaggedSnapshots: BackupSnapshotMetadata[] = []; + flaggedSnapshots.push(await describeFlaggedSnapshot(flaggedPath, "flagged-primary")); + + const knownFlaggedBackups = getAccountsBackupRecoveryCandidates(flaggedPath); + for (let i = 0; i < knownFlaggedBackups.length; i += 1) { + const candidate = knownFlaggedBackups[i]; + if (!candidate) continue; + const kind = i === 0 ? "flagged-backup" : "flagged-backup-history"; + flaggedSnapshots.push(await describeFlaggedSnapshot(candidate, kind, i)); + } + + const knownFlaggedSet = new Set(knownFlaggedBackups); + const discoveredFlaggedBackups = await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); + for (const candidate of discoveredFlaggedBackups) { + if (knownFlaggedSet.has(candidate)) continue; + flaggedSnapshots.push(await describeFlaggedSnapshot(candidate, "flagged-discovered-backup")); + } + + const flaggedSection: BackupMetadataSection = { + storagePath: flaggedPath, + latestValidPath: selectLatestValidSnapshotPath(flaggedSnapshots), + snapshotCount: flaggedSnapshots.length, + validSnapshotCount: flaggedSnapshots.filter((snapshot) => snapshot.valid).length, + snapshots: flaggedSnapshots, + }; + + return { accounts: accountsSection, flaggedAccounts: flaggedSection }; +} + +async function loadAccountsForRestoreAssessment(path: string): Promise { + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const hasIntentionalResetMarker = existsSync(resetMarkerPath); + + try { + const { normalized, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { + errors: schemaErrors.slice(0, 5), + }); + } + if (!normalized) { + return null; + } + + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + + const annotated: AccountStorageWithMetadata = { ...normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + } else if (hasIntentionalResetMarker) { + annotated.restoreEligible = false; + annotated.restoreReason = "intentional-reset"; + } + + return annotated; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } + return { + ...createEmptyAccountStorage(), + restoreEligible: true, + restoreReason: "missing-storage", + }; + } + + log.warn("Failed to load account storage for restore assessment", { + path, + error: String(error), + }); + return null; + } +} + +export async function getRestoreAssessment(): Promise { + const storagePath = getStoragePath(); + const [storage, backupMetadata] = await Promise.all([ + loadAccountsForRestoreAssessment(storagePath), + getBackupMetadata(), + ]); + + const latestSnapshot = selectSnapshotByPath( + backupMetadata.accounts, + backupMetadata.accounts.latestValidPath, + ); + + return { + storagePath, + restoreEligible: storage?.restoreEligible ?? false, + restoreReason: storage?.restoreReason, + latestSnapshot, + backupMetadata, + }; +} + type AccountsJournalEntry = { version: 1; createdAt: number; @@ -450,6 +865,38 @@ export function getStoragePath(): string { return join(getConfigDir(), ACCOUNTS_FILE_NAME); } +function getIntentionalResetMarkerPath(storagePath: string): string { + return `${storagePath}${RESET_MARKER_SUFFIX}`; +} + +async function writeIntentionalResetMarker(storagePath: string): Promise { + const markerPath = getIntentionalResetMarkerPath(storagePath); + try { + await fs.writeFile( + markerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + "utf-8", + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to write reset marker", { path: markerPath, error: String(error) }); + } + } +} + +async function removeIntentionalResetMarker(storagePath: string): Promise { + const markerPath = getIntentionalResetMarkerPath(storagePath); + try { + await fs.unlink(markerPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove reset marker", { path: markerPath, error: String(error) }); + } + } +} + export function getFlaggedAccountsPath(): string { return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); } @@ -537,6 +984,59 @@ async function migrateLegacyProjectStorageIfNeeded( return null; } +async function migrateFallbackAccountStorageIfNeeded( + path: string, + persist: (storage: AccountStorageV3) => Promise, +): Promise { + if (existsSync(path)) { + return null; + } + + const candidates = getFallbackAccountStoragePaths(path); + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + + const fallbackStorage = await loadNormalizedStorageFromPath( + candidate, + "fallback account storage", + ); + if (!fallbackStorage) { + continue; + } + + try { + await persist(fallbackStorage); + try { + await fs.unlink(candidate); + } catch (unlinkError) { + const code = (unlinkError as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove fallback account storage after migration", { + path: candidate, + error: String(unlinkError), + }); + } + } + log.info("Migrated fallback account storage into canonical root", { + from: candidate, + to: path, + accounts: fallbackStorage.accounts.length, + }); + } catch (persistError) { + log.warn("Failed to persist fallback account storage into canonical root", { + from: candidate, + to: path, + error: String(persistError), + }); + return fallbackStorage; + } + + return fallbackStorage; + } + + return null; +} + async function loadNormalizedStorageFromPath( path: string, label: string, @@ -845,8 +1345,8 @@ export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null * Automatically migrates v1 storage to v3 format if needed. * @returns AccountStorageV3 if file exists and is valid, null otherwise */ -export async function loadAccounts(): Promise { - return loadAccountsInternal(saveAccounts); +export async function loadAccounts(): Promise { + return loadAccountsInternal(saveAccounts); } function parseAndNormalizeStorage(data: unknown): { @@ -899,22 +1399,40 @@ async function loadAccountsFromJournal(path: string): Promise Promise) | null, -): Promise { + persistMigration: ((storage: AccountStorageV3) => Promise) | null, +): Promise { const path = getStoragePath(); + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const hasIntentionalResetMarker = existsSync(resetMarkerPath); await cleanupStaleRotatingBackupArtifacts(path); const migratedLegacyStorage = persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; + const migratedFallbackStorage = persistMigration + ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration) + : null; - try { - const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); - } - if (normalized && storedVersion !== normalized.version) { - log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); - if (persistMigration) { + if (hasIntentionalResetMarker && !existsSync(path)) { + await removeIntentionalResetMarker(path); + const emptyStorageWithMetadata: AccountStorageWithMetadata = { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + return emptyStorageWithMetadata; + } + + try { + const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); + } + if (!normalized) { + return null; + } + if (normalized && storedVersion !== normalized.version) { + log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); + if (persistMigration) { try { await persistMigration(normalized); } catch (saveError) { @@ -949,7 +1467,8 @@ async function loadAccountsInternal( }); } } - return backup.normalized; + const annotated: AccountStorageWithMetadata = { ...backup.normalized }; + return annotated; } catch (backupError) { const backupCode = (backupError as NodeJS.ErrnoException).code; if (backupCode !== "ENOENT") { @@ -962,12 +1481,38 @@ async function loadAccountsInternal( } } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" && migratedLegacyStorage) { - return migratedLegacyStorage; - } + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + + const annotated: AccountStorageWithMetadata = { ...normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + } else if (hasIntentionalResetMarker) { + annotated.restoreEligible = false; + annotated.restoreReason = "intentional-reset"; + } + + return annotated; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + if (migratedFallbackStorage) { + return migratedFallbackStorage; + } + if (migratedLegacyStorage) { + return migratedLegacyStorage; + } + } + if (code === "ENOENT" && hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } const recoveredFromWal = await loadAccountsFromJournal(path); if (recoveredFromWal) { @@ -981,7 +1526,15 @@ async function loadAccountsInternal( }); } } - return recoveredFromWal; + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + const annotated: AccountStorageWithMetadata = { ...recoveredFromWal }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = true; + annotated.restoreReason = "empty-storage"; + } + return annotated; } if (storageBackupEnabled) { @@ -1007,7 +1560,15 @@ async function loadAccountsInternal( }); } } - return backup.normalized; + if (hasIntentionalResetMarker) { + await removeIntentionalResetMarker(path); + } + const annotated: AccountStorageWithMetadata = { ...backup.normalized }; + if (annotated.accounts.length === 0) { + annotated.restoreEligible = true; + annotated.restoreReason = "empty-storage"; + } + return annotated; } } catch (backupError) { const backupCode = (backupError as NodeJS.ErrnoException).code; @@ -1021,10 +1582,16 @@ async function loadAccountsInternal( } } - if (code !== "ENOENT") { - log.error("Failed to load account storage", { error: String(error) }); - } - return null; + if (code === "ENOENT") { + return { + ...createEmptyAccountStorage(), + restoreEligible: true, + restoreReason: "missing-storage", + }; + } + + log.error("Failed to load account storage", { error: String(error) }); + return null; } } @@ -1034,9 +1601,10 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); - try { - await fs.mkdir(dirname(path), { recursive: true }); - await ensureGitignore(path); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await ensureGitignore(path); + await removeIntentionalResetMarker(path); if (looksLikeSyntheticFixtureStorage(storage)) { try { @@ -1152,6 +1720,34 @@ export async function withAccountStorageTransaction( }); } +export function cloneAccountStorage(storage: AccountStorageV3 | null): AccountStorageV3 | null { + if (!storage) { + return null; + } + + return { + ...storage, + accounts: storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: storage.activeIndexByFamily + ? { ...storage.activeIndexByFamily } + : undefined, + }; +} + +export function createEmptyAccountStorage(): AccountStorageV3 { + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + activeIndexByFamily[family] = 0; + } + + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily, + }; +} + /** * Persists account storage to disk using atomic write (temp file + rename). * Creates the Codex multi-auth storage directory if it doesn't exist. @@ -1167,32 +1763,32 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { /** * Deletes the account storage file from disk. - * Silently ignores if file doesn't exist. + * Preserves recovery artifacts while marking an intentional reset to suppress automatic restore. */ export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); - const walPath = getAccountsWalPath(path); - const backupPaths = getAccountsBackupRecoveryCandidates(path); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; + const markerPath = getIntentionalResetMarkerPath(path); - try { - await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } + try { + await fs.mkdir(dirname(path), { recursive: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOENT") { + log.warn("Failed to ensure storage directory before reset", { path, error: String(error) }); + } + } + + await writeIntentionalResetMarker(path); + + try { + await fs.unlink(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear account storage", { path, markerPath, error: String(error) }); + } + } }); } @@ -1277,17 +1873,19 @@ export async function loadFlaggedAccounts(): Promise { const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; try { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - return normalizeFlaggedStorage(data); + return await loadFlaggedAccountsFromPath(path); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { log.error("Failed to load flagged account storage", { path, error: String(error) }); - return empty; } } + const recovered = storageBackupEnabled ? await recoverFlaggedAccountsFromBackups(path) : null; + if (recovered) { + return recovered; + } + const legacyPath = getLegacyFlaggedAccountsPath(); if (!existsSync(legacyPath)) { return empty; @@ -1329,6 +1927,16 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro try { await fs.mkdir(dirname(path), { recursive: true }); + if (storageBackupEnabled && existsSync(path)) { + try { + await createRotatingAccountsBackup(path); + } catch (backupError) { + log.warn("Failed to create flagged account storage backup", { + path, + error: String(backupError), + }); + } + } const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await fs.rename(tempPath, path); @@ -1346,13 +1954,26 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { - try { - await fs.unlink(getFlaggedAccountsPath()); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { error: String(error) }); + const path = getFlaggedAccountsPath(); + const backupPaths = getAccountsBackupRecoveryCandidates(path); + const clearPath = async (targetPath: string): Promise => { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged account storage artifact", { + path: targetPath, + error: String(error), + }); + } } + }; + + try { + await Promise.all([clearPath(path), ...backupPaths.map(clearPath)]); + } catch { + // Individual cleanup is already best effort with per-artifact logging. } }); } From 48761308ac6e5d1a320df64706fbe58a66b65b7f Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 00:25:47 +0800 Subject: [PATCH 03/15] fix(storage): preserve reset safety during recovery Co-authored-by: Codex --- docs/reference/storage-paths.md | 2 +- lib/runtime-paths.ts | 2 +- lib/storage.ts | 72 ++++++++++++++++------------- test/storage-recovery-paths.test.ts | 60 +++++++++++++++++++++++- test/storage.test.ts | 45 +++++++++++++----- 5 files changed, 134 insertions(+), 47 deletions(-) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index 5a86b96e..c713a6d4 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -39,7 +39,7 @@ Ownership note: Backup metadata: -- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool and flagged-account state (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts are excluded from recovery candidates. +- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups) and flagged-account state (primary, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts are excluded from recovery candidates. --- diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index b81af98c..95c0c444 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -78,7 +78,7 @@ function pathsEqualNormalized(a: string, b: string): boolean { if (process.platform === "win32") { return win32.normalize(trimmed).toLowerCase(); } - return trimmed; + return trimmed === "/" ? "/" : trimmed.replace(/\/+$/, ""); }; return normalize(a) === normalize(b); } diff --git a/lib/storage.ts b/lib/storage.ts index 5f9696d6..20bceb4d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -745,15 +745,15 @@ async function loadAccountsForRestoreAssessment(path: string): Promise { return withStorageLock(async () => { const path = getStoragePath(); const markerPath = getIntentionalResetMarkerPath(path); + const walPath = getAccountsWalPath(path); try { await fs.mkdir(dirname(path), { recursive: true }); @@ -1779,14 +1775,24 @@ export async function clearAccounts(): Promise { } } + // withStorageLock serializes in-process resets; if another process observes the reset marker + // before the primary disappears, later loads still require a canonical file and never revive + // token-bearing WAL or backup artifacts while the marker is present. Logging stays path-only. + // See test/storage-recovery-paths.test.ts for the reset -> assessment -> load regression. await writeIntentionalResetMarker(path); - try { - await fs.unlink(path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage", { path, markerPath, error: String(error) }); + for (const targetPath of [path, walPath]) { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear account storage", { + path: targetPath, + markerPath, + error: String(error), + }); + } } } }); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 286548cd..d2799587 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -13,6 +13,29 @@ import { getRestoreAssessment, } from "../lib/storage.js"; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { if (value && typeof value === "object" && "restoreEligible" in value) { const candidate = value as { restoreEligible?: unknown; restoreReason?: unknown }; @@ -43,7 +66,7 @@ describe("storage recovery paths", () => { afterEach(async () => { setStoragePathDirect(null); setStorageBackupEnabled(true); - await fs.rm(workDir, { recursive: true, force: true }); + await removeWithRetry(workDir, { recursive: true, force: true }); }); it("recovers from WAL journal when primary storage is unreadable", async () => { @@ -514,6 +537,41 @@ describe("storage recovery paths", () => { expect(eligibleAfterReset.restoreReason).toBe("empty-storage"); }); + it("does not revive WAL contents after reset assessment runs before load", async () => { + const walPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "stale-refresh", + accountId: "stale-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const walContent = JSON.stringify(walPayload); + const walEntry = { + version: 1, + createdAt: Date.now(), + path: storagePath, + checksum: sha256(walContent), + content: walContent, + }; + + await saveAccounts(walPayload); + await clearAccounts(); + await fs.writeFile(`${storagePath}.wal`, JSON.stringify(walEntry), "utf-8"); + + const assessment = await getRestoreAssessment(); + expect(assessment.restoreEligible).toBe(false); + expect(assessment.restoreReason).toBe("intentional-reset"); + + const reloaded = await loadAccounts(); + expect(reloaded?.accounts).toHaveLength(0); + expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); + }); + it("cleans up stale staged backup artifacts during load", async () => { await fs.writeFile( storagePath, diff --git a/test/storage.test.ts b/test/storage.test.ts index db70c317..7ab63949 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -20,6 +20,29 @@ import { withAccountStorageTransaction, } from "../lib/storage.js"; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or // accept that this test won't even compile/run until we add them. @@ -113,7 +136,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 () => { @@ -635,7 +658,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 () => { @@ -712,7 +735,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 () => { @@ -742,7 +765,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 () => { @@ -812,7 +835,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 }); } }); }); @@ -886,7 +909,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 () => { @@ -940,7 +963,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 () => { @@ -1031,7 +1054,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 () => { @@ -1183,7 +1206,7 @@ describe("storage", () => { else process.env.USERPROFILE = originalUserProfile; 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(); }); @@ -1451,7 +1474,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 () => { @@ -1869,7 +1892,7 @@ describe("storage", () => { 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); + expect(existsSync(`${storagePath}.wal`)).toBe(false); }); it("logs error for non-ENOENT errors during clear", async () => { From 049fd50c7afde9831f66f37c675d468c3bebefde Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 00:33:39 +0800 Subject: [PATCH 04/15] fix(storage): align reset messaging and fallback detection Co-authored-by: Codex --- index.ts | 4 +++- lib/codex-manager.ts | 2 +- lib/storage.ts | 30 +++++++++++++++++++++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index 7db88088..75f58c00 100644 --- a/index.ts +++ b/index.ts @@ -3180,7 +3180,9 @@ while (attempted.size < Math.max(1, accountCount)) { await clearAccounts(); await clearFlaggedAccounts(); invalidateAccountManagerCache(); - console.log("\nDeleted all accounts. Starting fresh.\n"); + console.log( + "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + ); } break; } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c6..fbf58f52 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3808,7 +3808,7 @@ async function runAuthLogin(): Promise { if (menuResult.mode === "fresh" && menuResult.deleteAll) { await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { await clearAccountsAndReset(); - console.log("Deleted all accounts."); + console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); }, displaySettings); continue; } diff --git a/lib/storage.ts b/lib/storage.ts index 20bceb4d..eb4e0a10 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -276,11 +276,35 @@ function pathsEqualNormalized(a: string, b: string): boolean { return normalizePathForDedup(a) === normalizePathForDedup(b); } +function inferHomeFromStoragePath(currentPath: string): string | null { + const storageDir = dirname(currentPath); + if (basename(storageDir) === "multi-auth") { + const codexRoot = dirname(storageDir); + if (basename(codexRoot) === ".codex") { + return dirname(codexRoot); + } + if ( + basename(codexRoot) === "codex" && + basename(dirname(codexRoot)) === "config" && + basename(dirname(dirname(codexRoot))) === "DevTools" + ) { + return dirname(dirname(dirname(codexRoot))); + } + } + + if (basename(storageDir) === ".codex") { + return dirname(storageDir); + } + + return null; +} + function getFallbackAccountStoragePaths(currentPath: string): string[] { - const canonicalRoot = dirname(currentPath); - const canonicalHome = dirname(canonicalRoot); const legacyRoot = getLegacyCodexDir(); - const candidateHomes = deduplicatePathList([dirname(canonicalHome), dirname(legacyRoot)]); + const inferredHome = inferHomeFromStoragePath(currentPath); + const candidateHomes = inferredHome + ? deduplicatePathList([inferredHome, dirname(legacyRoot)]) + : []; const candidates = deduplicatePathList( candidateHomes.flatMap((home) => [ From d3bda4f29408dadac3b3daab897405676ab9e93c Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 00:41:58 +0800 Subject: [PATCH 05/15] fix(storage): address review follow-ups Co-authored-by: Codex --- lib/storage.ts | 35 +++++++++++---------- test/helpers/remove-with-retry.ts | 24 +++++++++++++++ test/storage-recovery-paths.test.ts | 48 +++++++++++++++-------------- test/storage.test.ts | 47 ++++++++++++++-------------- 4 files changed, 91 insertions(+), 63 deletions(-) create mode 100644 test/helpers/remove-with-retry.ts diff --git a/lib/storage.ts b/lib/storage.ts index eb4e0a10..2ad771ec 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -224,6 +224,7 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): P if (!entry.isFile()) continue; if (!entry.name.startsWith(candidatePrefix)) continue; if (isCacheLikeBackupArtifactName(entry.name)) continue; + if (entry.name.endsWith(RESET_MARKER_SUFFIX)) continue; if (entry.name.endsWith(".tmp")) continue; if (entry.name.includes(".rotate.")) continue; if (entry.name.endsWith(ACCOUNTS_WAL_SUFFIX)) continue; @@ -810,10 +811,8 @@ async function loadAccountsForRestoreAssessment(path: string): Promise { const storagePath = getStoragePath(); - const [storage, backupMetadata] = await Promise.all([ - loadAccountsForRestoreAssessment(storagePath), - getBackupMetadata(), - ]); + const storage = await loadAccountsForRestoreAssessment(storagePath); + const backupMetadata = await getBackupMetadata(); const latestSnapshot = selectSnapshotByPath( backupMetadata.accounts, @@ -894,18 +893,11 @@ function getIntentionalResetMarkerPath(storagePath: string): string { async function writeIntentionalResetMarker(storagePath: string): Promise { const markerPath = getIntentionalResetMarkerPath(storagePath); - try { - await fs.writeFile( - markerPath, - JSON.stringify({ version: 1, createdAt: Date.now() }), - "utf-8", - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to write reset marker", { path: markerPath, error: String(error) }); - } - } + await fs.writeFile( + markerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + "utf-8", + ); } async function removeIntentionalResetMarker(storagePath: string): Promise { @@ -1803,7 +1795,16 @@ export async function clearAccounts(): Promise { // before the primary disappears, later loads still require a canonical file and never revive // token-bearing WAL or backup artifacts while the marker is present. Logging stays path-only. // See test/storage-recovery-paths.test.ts for the reset -> assessment -> load regression. - await writeIntentionalResetMarker(path); + try { + await writeIntentionalResetMarker(path); + } catch (error) { + log.error("Failed to write reset marker; aborting reset to prevent token revival", { + path, + markerPath, + error: String(error), + }); + throw error; + } for (const targetPath of [path, walPath]) { try { diff --git a/test/helpers/remove-with-retry.ts b/test/helpers/remove-with-retry.ts new file mode 100644 index 00000000..ba1a07c9 --- /dev/null +++ b/test/helpers/remove-with-retry.ts @@ -0,0 +1,24 @@ +import { promises as fs } from "node:fs"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); + +export async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index d2799587..65906e2f 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -3,6 +3,7 @@ import { promises as fs, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { loadAccounts, getBackupMetadata, @@ -13,29 +14,6 @@ import { getRestoreAssessment, } from "../lib/storage.js"; -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - for (let attempt = 0; attempt < 6; attempt += 1) { - try { - await fs.rm(targetPath, options); - return; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return; - } - if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} - function getRestoreEligibility(value: unknown): { restoreEligible?: boolean; restoreReason?: string } { if (value && typeof value === "object" && "restoreEligible" in value) { const candidate = value as { restoreEligible?: unknown; restoreReason?: unknown }; @@ -572,6 +550,30 @@ describe("storage recovery paths", () => { expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); }); + it("excludes reset markers from discovered backup metadata", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "marker-refresh", + accountId: "marker-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await clearAccounts(); + + const metadata = await getBackupMetadata(); + expect( + metadata.accounts.snapshots.some((snapshot) => + snapshot.path.endsWith(".reset-intent"), + ), + ).toBe(false); + }); + it("cleans up stale staged backup artifacts during load", async () => { await fs.writeFile( storagePath, diff --git a/test/storage.test.ts b/test/storage.test.ts index 7ab63949..d626986a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { deduplicateAccounts, @@ -20,29 +21,6 @@ import { withAccountStorageTransaction, } from "../lib/storage.js"; -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - for (let attempt = 0; attempt < 6; attempt += 1) { - try { - await fs.rm(targetPath, options); - return; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return; - } - if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} - // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or // accept that this test won't even compile/run until we add them. @@ -778,6 +756,29 @@ describe("storage", () => { it("does not throw when file does not exist", async () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it("aborts reset when the reset marker cannot be written", async () => { + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(testStoragePath, "{}", "utf-8"); + await fs.writeFile(walPath, "{}", "utf-8"); + + const originalWriteFile = fs.writeFile.bind(fs); + const writeSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if (typeof targetPath === "string" && targetPath.endsWith(".reset-intent")) { + const error = new Error("EPERM marker write") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalWriteFile(...args); + }); + + await expect(clearAccounts()).rejects.toThrow("EPERM marker write"); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(true); + + writeSpy.mockRestore(); + }); }); describe("setStoragePath", () => { From 46e096531400124a65cad85eef09101dabf787c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 00:46:14 +0800 Subject: [PATCH 06/15] fix(storage): address latest review findings Co-authored-by: Codex --- lib/runtime-paths.ts | 6 +++- lib/storage.ts | 39 +++++++++----------- test/runtime-paths.test.ts | 19 ++++++++++ test/storage-recovery-paths.test.ts | 12 ++----- test/storage.test.ts | 55 +++++++++++++++++++++++++++-- 5 files changed, 95 insertions(+), 36 deletions(-) diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index 95c0c444..f2667214 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -76,7 +76,11 @@ function pathsEqualNormalized(a: string, b: string): boolean { const normalize = (value: string): string => { const trimmed = value.trim(); if (process.platform === "win32") { - return win32.normalize(trimmed).toLowerCase(); + const normalized = win32.normalize(trimmed); + const root = win32.parse(normalized).root; + const withoutTrailing = + normalized === root ? normalized : normalized.replace(/[\\/]+$/, ""); + return withoutTrailing.toLowerCase(); } return trimmed === "/" ? "/" : trimmed.replace(/\/+$/, ""); }; diff --git a/lib/storage.ts b/lib/storage.ts index 2ad771ec..7eb3292e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -772,13 +772,9 @@ async function loadAccountsForRestoreAssessment(path: string): Promise Promise, ): Promise { + if (currentProjectRoot) { + return null; + } if (existsSync(path)) { return null; } @@ -1493,14 +1492,10 @@ async function loadAccountsInternal( } } - if (hasIntentionalResetMarker) { - await removeIntentionalResetMarker(path); - } - const annotated: AccountStorageWithMetadata = { ...normalized }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = true; - annotated.restoreReason = "empty-storage"; + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; } else if (hasIntentionalResetMarker) { annotated.restoreEligible = false; annotated.restoreReason = "intentional-reset"; @@ -1537,13 +1532,10 @@ async function loadAccountsInternal( }); } } - if (hasIntentionalResetMarker) { - await removeIntentionalResetMarker(path); - } const annotated: AccountStorageWithMetadata = { ...recoveredFromWal }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = true; - annotated.restoreReason = "empty-storage"; + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; } return annotated; } @@ -1571,13 +1563,10 @@ async function loadAccountsInternal( }); } } - if (hasIntentionalResetMarker) { - await removeIntentionalResetMarker(path); - } const annotated: AccountStorageWithMetadata = { ...backup.normalized }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = true; - annotated.restoreReason = "empty-storage"; + annotated.restoreEligible = hasIntentionalResetMarker ? false : true; + annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; } return annotated; } @@ -1615,7 +1604,6 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { try { await fs.mkdir(dirname(path), { recursive: true }); await ensureGitignore(path); - await removeIntentionalResetMarker(path); if (looksLikeSyntheticFixtureStorage(storage)) { try { @@ -1679,6 +1667,11 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } catch { // Best effort cleanup. } + try { + await removeIntentionalResetMarker(path); + } catch { + // Best effort cleanup. Saved storage remains authoritative even if marker removal is delayed. + } return; } catch (renameError) { const code = (renameError as NodeJS.ErrnoException).code; diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 24b584db..3ba7c83d 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -135,6 +135,25 @@ describe("runtime-paths", () => { } }); + it("treats default Windows CODEX_HOME with a trailing separator as the default root", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Users\\Neil"); + process.env.CODEX_HOME = "C:\\Users\\Neil\\.codex\\"; + const fallback = "C:\\Users\\Neil\\DevTools\\config\\codex\\multi-auth"; + + existsSync.mockImplementation((candidate: unknown) => { + if (typeof candidate !== "string") return false; + return candidate === path.join(fallback, "openai-codex-accounts.json"); + }); + + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexMultiAuthDir()).toBe(fallback); + } finally { + 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 { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 65906e2f..d20abcfb 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -384,11 +384,7 @@ describe("storage recovery paths", () => { const intentionalEligibility = getRestoreEligibility(afterIntentionalReset); expect(intentionalEligibility.restoreEligible).toBe(false); - await fs.writeFile( - storagePath, - JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), - "utf-8", - ); + await saveAccounts({ version: 3, activeIndex: 0, accounts: [] }); const afterAccidentalEmpty = await loadAccounts(); const accidentalEligibility = getRestoreEligibility(afterAccidentalEmpty); expect(accidentalEligibility.restoreEligible).toBe(true); @@ -504,11 +500,7 @@ describe("storage recovery paths", () => { expect(suppressed.restoreEligible).toBe(false); expect(suppressed.restoreReason).toBe("intentional-reset"); - await fs.writeFile( - storagePath, - JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), - "utf-8", - ); + await saveAccounts({ version: 3, activeIndex: 0, accounts: [] }); const eligibleAfterReset = await getRestoreAssessment(); expect(eligibleAfterReset.restoreEligible).toBe(true); diff --git a/test/storage.test.ts b/test/storage.test.ts index d626986a..61c9d17e 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -639,7 +639,7 @@ describe("storage", () => { await removeWithRetry(testWorkDir, { recursive: true, force: true }); }); - it("returns null when file does not exist", async () => { + it("returns restore-suppressed empty state when file does not exist", async () => { const result = await loadAccounts(); expect(result?.accounts).toHaveLength(0); expect(result?.restoreEligible).toBe(true); @@ -858,6 +858,57 @@ describe("storage", () => { }); }); + describe("fallback migration scoping", () => { + const testWorkDir = join(tmpdir(), "codex-fallback-scope-" + Math.random().toString(36).slice(2)); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + process.env.HOME = testWorkDir; + process.env.USERPROFILE = testWorkDir; + }); + + afterEach(async () => { + setStoragePathDirect(null); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await removeWithRetry(testWorkDir, { recursive: true, force: true }); + }); + + it("does not migrate global fallback storage into project-scoped storage", async () => { + const projectDir = join(testWorkDir, "project-scope"); + const globalFallbackPath = join(testWorkDir, ".codex", "openai-codex-accounts.json"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(join(projectDir, ".git"), { recursive: true }); + await fs.mkdir(dirname(globalFallbackPath), { recursive: true }); + await fs.writeFile( + globalFallbackPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "global-refresh", + accountId: "global-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + setStoragePath(projectDir); + const result = await loadAccounts(); + + expect(result?.accounts).toHaveLength(0); + expect(existsSync(globalFallbackPath)).toBe(true); + }); + }); + describe("normalizeAccountStorage activeKey remapping", () => { it("remaps activeIndex using activeKey when present", () => { const now = Date.now(); @@ -1866,7 +1917,7 @@ describe("storage", () => { }); describe("clearAccounts edge cases", () => { - it("removes primary, backup, and wal artifacts", async () => { + it("removes primary and wal artifacts while preserving backups", async () => { const now = Date.now(); const storage = { version: 3 as const, From 86b9e83b2974ddf890d948fb97fdfce50e914952 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:04:01 +0800 Subject: [PATCH 07/15] fix(storage): close remaining review gaps Co-authored-by: Codex --- lib/storage.ts | 31 +++++++++++-------------------- test/storage-flagged.test.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 7eb3292e..621ae438 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1426,10 +1426,10 @@ async function loadAccountsInternal( restoreReason: "intentional-reset", }; } - const migratedLegacyStorage = persistMigration + const migratedLegacyStorage = !hasIntentionalResetMarker && persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; - const migratedFallbackStorage = persistMigration + const migratedFallbackStorage = !hasIntentionalResetMarker && persistMigration ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration) : null; @@ -1979,25 +1979,16 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); - const backupPaths = getAccountsBackupRecoveryCandidates(path); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; - try { - await Promise.all([clearPath(path), ...backupPaths.map(clearPath)]); - } catch { - // Individual cleanup is already best effort with per-artifact logging. + await fs.unlink(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged account storage", { + path, + error: String(error), + }); + } } }); } diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 9efd733d..460b3fdf 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -161,13 +161,26 @@ describe("flagged account storage", () => { }, ], }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "keep-backup", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); expect(existsSync(getFlaggedAccountsPath())).toBe(true); + expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); await clearFlaggedAccounts(); await clearFlaggedAccounts(); expect(existsSync(getFlaggedAccountsPath())).toBe(false); + expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); }); it("emits snapshot metadata for flagged account backups", async () => { From 11728d94752c9845ca52e4c0848b345483f61915 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:17:53 +0800 Subject: [PATCH 08/15] fix(storage): address comment follow-ups Co-authored-by: Codex --- lib/codex-manager.ts | 8 ++------ lib/storage.ts | 24 +++++++++++++--------- test/storage-flagged.test.ts | 39 +++++++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index fbf58f52..a2571ecd 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -50,6 +50,7 @@ import { type QuotaCacheEntry, } from "./quota-cache.js"; import { + clearAccounts, getStoragePath, loadFlaggedAccounts, loadAccounts, @@ -3650,12 +3651,7 @@ async function runDoctor(args: string[]): Promise { } async function clearAccountsAndReset(): Promise { - await saveAccounts({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }); + await clearAccounts(); } async function handleManageAction( diff --git a/lib/storage.ts b/lib/storage.ts index 621ae438..5adc4a7c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1417,8 +1417,9 @@ async function loadAccountsInternal( ): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); - const hasIntentionalResetMarker = existsSync(resetMarkerPath); + let hasIntentionalResetMarker = existsSync(resetMarkerPath); await cleanupStaleRotatingBackupArtifacts(path); + hasIntentionalResetMarker = existsSync(resetMarkerPath); if (hasIntentionalResetMarker && !existsSync(path)) { return { ...createEmptyAccountStorage(), @@ -1979,15 +1980,18 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); - try { - await fs.unlink(path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { - path, - error: String(error), - }); + const backupPaths = getAccountsBackupRecoveryCandidates(path); + for (const candidate of [path, ...backupPaths]) { + try { + await fs.unlink(candidate); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear flagged account storage", { + path: candidate, + error: String(error), + }); + } } } }); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 460b3fdf..d378104d 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -180,7 +180,44 @@ describe("flagged account storage", () => { await clearFlaggedAccounts(); expect(existsSync(getFlaggedAccountsPath())).toBe(false); - expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(true); + expect(existsSync(`${getFlaggedAccountsPath()}.bak`)).toBe(false); + }); + + it("does not revive flagged accounts from backups after clear", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "revive-test", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "revive-test", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "revive-test-2", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + await clearFlaggedAccounts(); + + const flagged = await loadFlaggedAccounts(); + expect(flagged.accounts).toHaveLength(0); }); it("emits snapshot metadata for flagged account backups", async () => { From b4fd15426bf370afce733a9aeda58b760a9e2e9c Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:28:59 +0800 Subject: [PATCH 09/15] docs(storage): mention reset marker exclusion Co-authored-by: Codex --- docs/reference/storage-paths.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index c713a6d4..1220c734 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -39,7 +39,7 @@ Ownership note: Backup metadata: -- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups) and flagged-account state (primary, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts are excluded from recovery candidates. +- `getBackupMetadata()` reports deterministic snapshot lists for the canonical account pool (primary, WAL, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups) and flagged-account state (primary, `.bak`, `.bak.1`, `.bak.2`, and discovered manual backups). Cache-like artifacts and `.reset-intent` markers are excluded from recovery candidates. --- From 26c14d7c2afa6b0175771186db9d14b0d2230645 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:32:12 +0800 Subject: [PATCH 10/15] fix(storage): close latest review gaps Co-authored-by: Codex --- lib/storage.ts | 49 +++++++++++++++++++++++++----------- test/storage-flagged.test.ts | 23 +++++++++++++++++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 5adc4a7c..05f916b4 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1417,22 +1417,37 @@ async function loadAccountsInternal( ): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); - let hasIntentionalResetMarker = existsSync(resetMarkerPath); + const hasIntentionalResetMarker = (): boolean => existsSync(resetMarkerPath); + const shouldSuppressForIntentionalReset = (): boolean => + hasIntentionalResetMarker() && !existsSync(path); await cleanupStaleRotatingBackupArtifacts(path); - hasIntentionalResetMarker = existsSync(resetMarkerPath); - if (hasIntentionalResetMarker && !existsSync(path)) { + if (shouldSuppressForIntentionalReset()) { return { ...createEmptyAccountStorage(), restoreEligible: false, restoreReason: "intentional-reset", }; } - const migratedLegacyStorage = !hasIntentionalResetMarker && persistMigration + const migratedLegacyStorage = !hasIntentionalResetMarker() && persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; - const migratedFallbackStorage = !hasIntentionalResetMarker && persistMigration + if (shouldSuppressForIntentionalReset()) { + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } + const migratedFallbackStorage = !hasIntentionalResetMarker() && persistMigration ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration) : null; + if (shouldSuppressForIntentionalReset()) { + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } try { const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); @@ -1495,9 +1510,11 @@ async function loadAccountsInternal( const annotated: AccountStorageWithMetadata = { ...normalized }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = hasIntentionalResetMarker ? false : true; - annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; - } else if (hasIntentionalResetMarker) { + annotated.restoreEligible = hasIntentionalResetMarker() ? false : true; + annotated.restoreReason = hasIntentionalResetMarker() + ? "intentional-reset" + : "empty-storage"; + } else if (hasIntentionalResetMarker()) { annotated.restoreEligible = false; annotated.restoreReason = "intentional-reset"; } @@ -1505,7 +1522,7 @@ async function loadAccountsInternal( return annotated; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (hasIntentionalResetMarker) { + if (hasIntentionalResetMarker()) { return { ...createEmptyAccountStorage(), restoreEligible: false, @@ -1535,8 +1552,10 @@ async function loadAccountsInternal( } const annotated: AccountStorageWithMetadata = { ...recoveredFromWal }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = hasIntentionalResetMarker ? false : true; - annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + annotated.restoreEligible = hasIntentionalResetMarker() ? false : true; + annotated.restoreReason = hasIntentionalResetMarker() + ? "intentional-reset" + : "empty-storage"; } return annotated; } @@ -1566,8 +1585,10 @@ async function loadAccountsInternal( } const annotated: AccountStorageWithMetadata = { ...backup.normalized }; if (annotated.accounts.length === 0) { - annotated.restoreEligible = hasIntentionalResetMarker ? false : true; - annotated.restoreReason = hasIntentionalResetMarker ? "intentional-reset" : "empty-storage"; + annotated.restoreEligible = hasIntentionalResetMarker() ? false : true; + annotated.restoreReason = hasIntentionalResetMarker() + ? "intentional-reset" + : "empty-storage"; } return annotated; } @@ -1980,7 +2001,7 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); - const backupPaths = getAccountsBackupRecoveryCandidates(path); + const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); for (const candidate of [path, ...backupPaths]) { try { await fs.unlink(candidate); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index d378104d..14d2d42d 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -220,6 +220,29 @@ describe("flagged account storage", () => { expect(flagged.accounts).toHaveLength(0); }); + it("clears discovered flagged backup artifacts so manual snapshots cannot revive after clear", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "manual-backup-revive-test", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const manualBackupPath = `${getFlaggedAccountsPath()}.manual-checkpoint`; + await fs.copyFile(getFlaggedAccountsPath(), manualBackupPath); + + await clearFlaggedAccounts(); + + const flagged = await loadFlaggedAccounts(); + expect(existsSync(manualBackupPath)).toBe(false); + expect(flagged.accounts).toHaveLength(0); + }); + it("emits snapshot metadata for flagged account backups", async () => { await saveFlaggedAccounts({ version: 1, From 1d1f8c8a21ce5fd037dec9f910a3c4884f4cef04 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:33:54 +0800 Subject: [PATCH 11/15] fix(storage): normalize fallback path comparisons Co-authored-by: Codex --- lib/storage.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 05f916b4..553e7c9e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,5 +1,5 @@ import { promises as fs, existsSync } from "node:fs"; -import { basename, dirname, join, normalize } from "node:path"; +import { basename, dirname, join, normalize, win32 } from "node:path"; import { createHash } from "node:crypto"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; @@ -253,8 +253,16 @@ function getAccountsWalPath(path: string): string { } function normalizePathForDedup(pathValue: string): string { - const normalized = normalize(pathValue.trim()); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; + const trimmed = pathValue.trim(); + if (process.platform === "win32") { + const normalized = win32.normalize(trimmed); + const root = win32.parse(normalized).root; + const withoutTrailing = + normalized === root ? normalized : normalized.replace(/[\\/]+$/, ""); + return withoutTrailing.toLowerCase(); + } + const normalized = normalize(trimmed); + return normalized === "/" ? "/" : normalized.replace(/\/+$/, ""); } function deduplicatePathList(paths: string[]): string[] { From 384d7efdda543204ce1d95b3c5b56067d5898cbf Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 10:51:35 +0800 Subject: [PATCH 12/15] fix(storage): harden reset and backup recovery Co-authored-by: Codex --- lib/storage.ts | 65 ++++++++++++++++++++----- test/storage-flagged.test.ts | 44 +++++++++++++++++ test/storage-recovery-paths.test.ts | 30 +++++++++++- test/storage.test.ts | 73 ++++++++++++++++++++++++++++- 4 files changed, 198 insertions(+), 14 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 553e7c9e..7fb4ee1d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -310,10 +310,11 @@ function inferHomeFromStoragePath(currentPath: string): string | null { function getFallbackAccountStoragePaths(currentPath: string): string[] { const legacyRoot = getLegacyCodexDir(); + const legacyHome = dirname(legacyRoot); const inferredHome = inferHomeFromStoragePath(currentPath); - const candidateHomes = inferredHome - ? deduplicatePathList([inferredHome, dirname(legacyRoot)]) - : []; + const candidateHomes = deduplicatePathList( + typeof inferredHome === "string" ? [inferredHome, legacyHome] : [legacyHome], + ); const candidates = deduplicatePathList( candidateHomes.flatMap((home) => [ @@ -691,8 +692,20 @@ async function recoverFlaggedAccountsFromBackups(path: string): Promise snapshot.valid); - return firstValid?.path; + let latestValidSnapshot: BackupSnapshotMetadata | undefined; + for (const snapshot of snapshots) { + if (!snapshot.valid) continue; + if (!latestValidSnapshot) { + latestValidSnapshot = snapshot; + continue; + } + const snapshotTime = snapshot.mtimeMs ?? Number.NEGATIVE_INFINITY; + const latestTime = latestValidSnapshot.mtimeMs ?? Number.NEGATIVE_INFINITY; + if (snapshotTime >= latestTime) { + latestValidSnapshot = snapshot; + } + } + return latestValidSnapshot?.path; } function selectSnapshotByPath( @@ -765,7 +778,9 @@ export async function getBackupMetadata(): Promise { async function loadAccountsForRestoreAssessment(path: string): Promise { const resetMarkerPath = getIntentionalResetMarkerPath(path); - const hasIntentionalResetMarker = existsSync(resetMarkerPath); + const hasIntentionalResetMarker = (): boolean => existsSync(resetMarkerPath); + const shouldSuppressForIntentionalReset = (): boolean => + hasIntentionalResetMarker() && !existsSync(path); try { const { normalized, schemaErrors } = await loadAccountsFromPath(path); @@ -780,9 +795,11 @@ async function loadAccountsForRestoreAssessment(path: string): Promise Promise, + options?: { shouldAbortMigration?: () => boolean }, ): Promise { if (currentProjectRoot) { return null; @@ -1027,7 +1045,24 @@ async function migrateFallbackAccountStorageIfNeeded( } try { - await persist(fallbackStorage); + if (persist === saveAccounts && options?.shouldAbortMigration) { + let persisted = false; + await withStorageLock(async () => { + if (options.shouldAbortMigration?.()) { + return; + } + await saveAccountsUnlocked(fallbackStorage); + persisted = true; + }); + if (!persisted) { + return null; + } + } else { + if (options?.shouldAbortMigration?.()) { + return null; + } + await persist(fallbackStorage); + } try { await fs.unlink(candidate); } catch (unlinkError) { @@ -1447,7 +1482,9 @@ async function loadAccountsInternal( }; } const migratedFallbackStorage = !hasIntentionalResetMarker() && persistMigration - ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration) + ? await migrateFallbackAccountStorageIfNeeded(path, persistMigration, { + shouldAbortMigration: shouldSuppressForIntentionalReset, + }) : null; if (shouldSuppressForIntentionalReset()) { return { @@ -1840,6 +1877,9 @@ export async function clearAccounts(): Promise { markerPath, error: String(error), }); + if (targetPath === path) { + throw error; + } } } } @@ -1932,6 +1972,7 @@ export async function loadFlaggedAccounts(): Promise { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { log.error("Failed to load flagged account storage", { path, error: String(error) }); + return empty; } } diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 14d2d42d..cf5037a4 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -220,6 +220,50 @@ describe("flagged account storage", () => { expect(flagged.accounts).toHaveLength(0); }); + it("does not recover flagged backups when the primary file exists but read fails", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "primary-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "backup-flagged", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const flaggedPath = getFlaggedAccountsPath(); + const originalReadFile = fs.readFile.bind(fs); + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === flaggedPath) { + const error = new Error("EPERM flagged read") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReadFile(...args); + }); + + const flagged = await loadFlaggedAccounts(); + expect(flagged.accounts).toHaveLength(0); + expect(existsSync(flaggedPath)).toBe(true); + + readSpy.mockRestore(); + }); + it("clears discovered flagged backup artifacts so manual snapshots cannot revive after clear", async () => { await saveFlaggedAccounts({ version: 1, diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index d20abcfb..071d5771 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -681,12 +681,40 @@ describe("storage recovery paths", () => { const accountSnapshots = metadata.accounts.snapshots; const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache")); expect(cacheEntries).toHaveLength(0); - expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.wal`); + expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.manual-meta-checkpoint`); const discovered = accountSnapshots.find((snapshot) => snapshot.path.endsWith("manual-meta-checkpoint")); expect(discovered?.kind).toBe("accounts-discovered-backup"); expect(discovered?.valid).toBe(true); expect(discovered?.accountCount).toBe(1); expect(metadata.accounts.snapshotCount).toBeGreaterThanOrEqual(4); }); + + it("prefers the newest valid discovered snapshot in backup metadata", async () => { + const olderManualPath = `${storagePath}.manual-older`; + const newerManualPath = `${storagePath}.manual-newer`; + + await fs.writeFile( + olderManualPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "older-refresh", accountId: "older", addedAt: 1, lastUsed: 1 }], + }), + "utf-8", + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + await fs.writeFile( + newerManualPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "newer-refresh", accountId: "newer", addedAt: 2, lastUsed: 2 }], + }), + "utf-8", + ); + + const metadata = await getBackupMetadata(); + expect(metadata.accounts.latestValidPath).toBe(newerManualPath); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 61c9d17e..46535b87 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -779,6 +779,28 @@ describe("storage", () => { writeSpy.mockRestore(); }); + + it("aborts reset when the primary storage file cannot be deleted", async () => { + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(testStoragePath, "{}", "utf-8"); + await fs.writeFile(walPath, "{}", "utf-8"); + + const originalUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi.spyOn(fs, "unlink").mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("EBUSY primary delete") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalUnlink(targetPath); + }); + + await expect(clearAccounts()).rejects.toThrow("EBUSY primary delete"); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(true); + + unlinkSpy.mockRestore(); + }); }); describe("setStoragePath", () => { @@ -907,6 +929,55 @@ describe("storage", () => { expect(result?.accounts).toHaveLength(0); expect(existsSync(globalFallbackPath)).toBe(true); }); + + it("migrates default-home fallback storage into an explicit non-default canonical root", async () => { + const fakeHome = join(testWorkDir, "custom-home"); + const customCanonicalPath = join( + fakeHome, + ".codex-guided", + "multi-auth", + "openai-codex-accounts.json", + ); + const globalFallbackPath = join(fakeHome, ".codex", "openai-codex-accounts.json"); + + await fs.mkdir(dirname(globalFallbackPath), { recursive: true }); + await fs.writeFile( + globalFallbackPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "fallback-refresh", + accountId: "fallback-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf-8", + ); + + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + setStoragePathDirect(customCanonicalPath); + + const result = await loadAccounts(); + + expect(result?.accounts).toHaveLength(1); + expect(result?.accounts[0]?.accountId).toBe("fallback-account"); + expect(existsSync(customCanonicalPath)).toBe(true); + expect(existsSync(globalFallbackPath)).toBe(false); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + } + }); }); describe("normalizeAccountStorage activeKey remapping", () => { @@ -1952,7 +2023,7 @@ describe("storage", () => { Object.assign(new Error("EACCES error"), { code: "EACCES" }) ); - await clearAccounts(); + await expect(clearAccounts()).rejects.toThrow("EACCES error"); expect(unlinkSpy).toHaveBeenCalled(); unlinkSpy.mockRestore(); From da3a701bc8527a226299ee0f837d1d0c01556c41 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 13:20:56 +0800 Subject: [PATCH 13/15] fix(storage): suppress remaining reset recovery races Co-authored-by: Codex --- lib/runtime-paths.ts | 9 ++-- lib/storage.ts | 7 +++ test/runtime-paths.test.ts | 14 ++++++ test/storage-recovery-paths.test.ts | 73 +++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index f2667214..5f0f0c45 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { join, win32 } from "node:path"; +import { join, normalize, win32 } from "node:path"; import { existsSync, readdirSync } from "node:fs"; function firstNonEmpty(values: Array): string | null { @@ -73,7 +73,7 @@ function deduplicatePaths(paths: string[]): string[] { } function pathsEqualNormalized(a: string, b: string): boolean { - const normalize = (value: string): string => { + const normalizePath = (value: string): string => { const trimmed = value.trim(); if (process.platform === "win32") { const normalized = win32.normalize(trimmed); @@ -82,9 +82,10 @@ function pathsEqualNormalized(a: string, b: string): boolean { normalized === root ? normalized : normalized.replace(/[\\/]+$/, ""); return withoutTrailing.toLowerCase(); } - return trimmed === "/" ? "/" : trimmed.replace(/\/+$/, ""); + const normalized = normalize(trimmed); + return normalized === "/" ? "/" : normalized.replace(/\/+$/, ""); }; - return normalize(a) === normalize(b); + return normalizePath(a) === normalizePath(b); } /** diff --git a/lib/storage.ts b/lib/storage.ts index 7fb4ee1d..089c47e3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1585,6 +1585,13 @@ async function loadAccountsInternal( const recoveredFromWal = await loadAccountsFromJournal(path); if (recoveredFromWal) { + if (hasIntentionalResetMarker()) { + return { + ...createEmptyAccountStorage(), + restoreEligible: false, + restoreReason: "intentional-reset", + }; + } if (persistMigration) { try { await persistMigration(recoveredFromWal); diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 3ba7c83d..0064a449 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -154,6 +154,20 @@ describe("runtime-paths", () => { } }); + it("treats normalized POSIX CODEX_HOME variants of the default root as default", async () => { + homedir.mockReturnValue("/home/neil"); + process.env.CODEX_HOME = "/home/neil/./.codex//"; + const fallback = path.join("/home/neil", "DevTools", "config", "codex", "multi-auth"); + + existsSync.mockImplementation((candidate: unknown) => { + if (typeof candidate !== "string") return false; + return candidate === path.join(fallback, "openai-codex-accounts.json"); + }); + + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexMultiAuthDir()).toBe(fallback); + }); + it("prefers USERPROFILE over os.homedir on Windows when CODEX_HOME is unset", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); try { diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 071d5771..1ba8322a 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -185,14 +185,26 @@ describe("storage recovery paths", () => { "utf-8", ); - const recovered = await loadAccounts(); - expect(recovered?.accounts).toHaveLength(1); - expect(recovered?.accounts[0]?.accountId).toBe("from-discovered-backup"); - - const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { - accounts?: Array<{ accountId?: string }>; - }; - expect(persisted.accounts?.[0]?.accountId).toBe("from-discovered-backup"); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = workDir; + process.env.USERPROFILE = workDir; + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("from-discovered-backup"); + + const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ accountId?: string }>; + }; + expect(persisted.accounts?.[0]?.accountId).toBe("from-discovered-backup"); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + } }); it("auto-promotes backup when primary storage matches synthetic fixture pattern", async () => { @@ -542,6 +554,51 @@ describe("storage recovery paths", () => { expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); }); + it("suppresses WAL recovery when a reset marker appears while the WAL is being read", async () => { + const walPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "racing-refresh", + accountId: "racing-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const walContent = JSON.stringify(walPayload); + const walEntry = { + version: 1, + createdAt: Date.now(), + path: storagePath, + checksum: sha256(walContent), + content: walContent, + }; + + await fs.writeFile(`${storagePath}.wal`, JSON.stringify(walEntry), "utf-8"); + + const originalReadFile = fs.readFile.bind(fs); + const originalWriteFile = fs.writeFile.bind(fs); + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === `${storagePath}.wal`) { + await originalWriteFile( + `${storagePath}.reset-intent`, + JSON.stringify({ version: 1, createdAt: Date.now() }), + "utf-8", + ); + } + return originalReadFile(...args); + }); + + const reloaded = await loadAccounts(); + expect(reloaded?.accounts).toHaveLength(0); + expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); + + readSpy.mockRestore(); + }); + it("excludes reset markers from discovered backup metadata", async () => { await saveAccounts({ version: 3, From 94059a11a6bd97f11f73652cd9f5a28602a811f2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 13:27:23 +0800 Subject: [PATCH 14/15] test(storage): restore spy cleanup deterministically Co-authored-by: Codex --- test/storage-recovery-paths.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 1ba8322a..264bf494 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -592,11 +592,13 @@ describe("storage recovery paths", () => { return originalReadFile(...args); }); - const reloaded = await loadAccounts(); - expect(reloaded?.accounts).toHaveLength(0); - expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); - - readSpy.mockRestore(); + try { + const reloaded = await loadAccounts(); + expect(reloaded?.accounts).toHaveLength(0); + expect(getRestoreEligibility(reloaded).restoreReason).toBe("intentional-reset"); + } finally { + readSpy.mockRestore(); + } }); it("excludes reset markers from discovered backup metadata", async () => { From 0b5fbcdf4ee1aa2a6f4209ca480c22169ce5ac32 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 11 Mar 2026 13:29:40 +0800 Subject: [PATCH 15/15] fix(storage): suppress flagged reset revival Co-authored-by: Codex --- lib/storage.ts | 18 ++++++++++++++ test/storage-flagged.test.ts | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/lib/storage.ts b/lib/storage.ts index 089c47e3..d5d9d6fb 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1971,6 +1971,7 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { export async function loadFlaggedAccounts(): Promise { const path = getFlaggedAccountsPath(); + const resetMarkerPath = getIntentionalResetMarkerPath(path); const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; try { @@ -1983,6 +1984,10 @@ export async function loadFlaggedAccounts(): Promise { } } + if (existsSync(resetMarkerPath) && !existsSync(path)) { + return empty; + } + const recovered = storageBackupEnabled ? await recoverFlaggedAccountsFromBackups(path) : null; if (recovered) { return recovered; @@ -2042,6 +2047,7 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await fs.rename(tempPath, path); + await removeIntentionalResetMarker(path); } catch (error) { try { await fs.unlink(tempPath); @@ -2057,6 +2063,18 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); + const markerPath = getIntentionalResetMarkerPath(path); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await writeIntentionalResetMarker(path); + } catch (error) { + log.error("Failed to write flagged reset marker", { + path, + markerPath, + error: String(error), + }); + throw error; + } const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); for (const candidate of [path, ...backupPaths]) { try { diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index cf5037a4..70e8bff9 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -287,6 +287,52 @@ describe("flagged account storage", () => { expect(flagged.accounts).toHaveLength(0); }); + it("suppresses flagged backup revival when clear only partially deletes backup artifacts", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "partial-delete-primary", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "partial-delete-secondary", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const flaggedPath = getFlaggedAccountsPath(); + const backupPath = `${flaggedPath}.bak`; + const originalUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi.spyOn(fs, "unlink").mockImplementation(async (targetPath) => { + if (targetPath === backupPath) { + const error = new Error("EACCES backup delete") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return originalUnlink(targetPath); + }); + + await clearFlaggedAccounts(); + + const flagged = await loadFlaggedAccounts(); + expect(existsSync(backupPath)).toBe(true); + expect(flagged.accounts).toHaveLength(0); + + unlinkSpy.mockRestore(); + }); + it("emits snapshot metadata for flagged account backups", async () => { await saveFlaggedAccounts({ version: 1,