Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/destructive-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
loadFlaggedAccounts,
saveAccounts,
saveFlaggedAccounts,
snapshotAccountStorage,
} from "./storage.js";

export const DESTRUCTIVE_ACTION_COPY = {
Expand Down Expand Up @@ -111,6 +112,7 @@ export async function deleteAccountAtIndex(options: {
* Removes the accounts WAL and backups via the underlying storage helper.
*/
export async function deleteSavedAccounts(): Promise<void> {
await snapshotAccountStorage({ reason: "delete-saved-accounts" });
await clearAccounts();
}

Expand All @@ -119,6 +121,7 @@ export async function deleteSavedAccounts(): Promise<void> {
* Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared.
*/
export async function resetLocalState(): Promise<void> {
await snapshotAccountStorage({ reason: "reset-local-state" });
await clearAccounts();
await clearFlaggedAccounts();
await clearQuotaCache();
Expand Down
69 changes: 69 additions & 0 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,21 @@ export interface ActionableNamedBackupRecoveries {
totalBackups: number;
}

type AccountSnapshotReason =
| "delete-saved-accounts"
| "reset-local-state"
| "import-accounts";

export type AccountSnapshotFailurePolicy = "warn" | "error";

export interface AccountSnapshotOptions {
reason: AccountSnapshotReason;
now?: number;
force?: boolean;
failurePolicy?: AccountSnapshotFailurePolicy;
createBackup?: typeof createNamedBackup;
}

export function getLastAccountsSaveTimestamp(): number {
return lastAccountsSaveTimestamp;
}
Expand Down Expand Up @@ -1532,6 +1547,58 @@ export async function createNamedBackup(
return buildNamedBackupMetadata(normalizedName, backupPath, { candidate });
}

function formatTimestampForSnapshot(timestamp: number): string {
const date = new Date(timestamp);
const pad = (value: number): string => value.toString().padStart(2, "0");
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hours = pad(date.getUTCHours());
const minutes = pad(date.getUTCMinutes());
const seconds = pad(date.getUTCSeconds());
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
}

function buildAccountSnapshotName(
reason: AccountSnapshotReason,
timestamp: number,
): string {
return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`;
}

export async function snapshotAccountStorage(
options: AccountSnapshotOptions,
): Promise<NamedBackupMetadata | null> {
const {
reason,
now = Date.now(),
force = true,
failurePolicy = "warn",
createBackup = createNamedBackup,
} = options;

const currentStorage = await loadAccounts();
if (!currentStorage || currentStorage.accounts.length === 0) {
return null;
}

const backupName = buildAccountSnapshotName(reason, now);

try {
return await createBackup(backupName, { force });
} catch (error) {
if (failurePolicy === "error") {
throw error;
}
log.warn("Failed to create account storage snapshot", {
reason,
backupName,
error: String(error),
});
return null;
}
}
Comment on lines +1569 to +1600
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timestamp collision + silent overwrite on concurrent calls

now defaults to Date.now(), which has millisecond resolution, but formatTimestampForSnapshot truncates to seconds. two snapshot calls within the same second (e.g. rapid automated ops, or a second import right after the first) produce the identical filename. because force = true is the hard-coded default, the second createNamedBackup call silently overwrites the first snapshot with no error.

on windows, an antivirus scanner can hold the file open between the two writes. the first write succeeds, the av locks the file, the second exportAccounts call throws — and since failurePolicy = "warn" is the default in all three call sites in this PR, the error is swallowed and the user proceeds with a destructive action with zero guaranteed checkpoint. there is no regression test that reproduces this race.

options:

  • use millisecond precision in formatTimestampForSnapshot (add _${date.getUTCMilliseconds().toString().padStart(3,"0")} suffix)
  • or derive the backup name from a monotonically-increasing counter / uuid suffix so names are always unique
  • add a vitest test with two back-to-back snapshot calls sharing the same second timestamp and assert both backups survive
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 1569-1600

Comment:
**timestamp collision + silent overwrite on concurrent calls**

`now` defaults to `Date.now()`, which has millisecond resolution, but `formatTimestampForSnapshot` truncates to seconds. two snapshot calls within the same second (e.g. rapid automated ops, or a second import right after the first) produce the **identical** filename. because `force = true` is the hard-coded default, the second `createNamedBackup` call silently overwrites the first snapshot with no error.

on windows, an antivirus scanner can hold the file open between the two writes. the first write succeeds, the av locks the file, the second `exportAccounts` call throws — and since `failurePolicy = "warn"` is the default in all three call sites in this PR, the error is swallowed and the user proceeds with a destructive action with **zero** guaranteed checkpoint. there is no regression test that reproduces this race.

options:
- use millisecond precision in `formatTimestampForSnapshot` (add `_${date.getUTCMilliseconds().toString().padStart(3,"0")}` suffix)
- or derive the backup name from a monotonically-increasing counter / uuid suffix so names are always unique
- add a vitest test with two back-to-back snapshot calls sharing the same second timestamp and assert both backups survive

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex


export async function assessNamedBackupRestore(
name: string,
options: { currentStorage?: AccountStorageV3 | null } = {},
Expand Down Expand Up @@ -2285,6 +2352,8 @@ export async function importAccounts(
throw new Error("Invalid account storage format");
}

await snapshotAccountStorage({ reason: "import-accounts" });

const {
imported: importedCount,
total,
Expand Down
221 changes: 221 additions & 0 deletions test/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { existsSync, promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSavedAccounts,
resetLocalState,
} from "../lib/destructive-actions.js";
import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js";
import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js";
import {
Expand All @@ -12,6 +16,7 @@ import {
deduplicateAccountsByEmail,
exportAccounts,
formatStorageErrorHint,
getNamedBackupsDirectoryPath,
getStoragePath,
importAccounts,
listNamedBackups,
Expand All @@ -23,6 +28,7 @@ import {
saveAccounts,
setStoragePath,
setStoragePathDirect,
snapshotAccountStorage,
withAccountStorageTransaction,
} from "../lib/storage.js";

Expand Down Expand Up @@ -656,6 +662,221 @@ describe("storage", () => {
});
});

describe("account storage snapshots", () => {
const testWorkDir = join(
tmpdir(),
"codex-snapshot-" + Math.random().toString(36).slice(2),
);
let testStoragePath = "";

beforeEach(async () => {
await fs.mkdir(testWorkDir, { recursive: true });
testStoragePath = join(testWorkDir, "openai-codex-accounts.json");
setStoragePathDirect(testStoragePath);
});

afterEach(async () => {
setStoragePathDirect(null);
vi.restoreAllMocks();
await fs.rm(testWorkDir, { recursive: true, force: true });
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid bare fs.rm in test cleanup; add retry handling for transient Windows lock errors (EBUSY/EPERM/ENOTEMPTY).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/storage.test.ts, line 681:

<comment>Avoid bare `fs.rm` in test cleanup; add retry handling for transient Windows lock errors (`EBUSY`/`EPERM`/`ENOTEMPTY`).</comment>

<file context>
@@ -656,6 +662,221 @@ describe("storage", () => {
+		afterEach(async () => {
+			setStoragePathDirect(null);
+			vi.restoreAllMocks();
+			await fs.rm(testWorkDir, { recursive: true, force: true });
+		});
+
</file context>
Suggested change
await fs.rm(testWorkDir, { recursive: true, force: true });
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await fs.rm(testWorkDir, { recursive: true, force: true });
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (!code || !["EBUSY", "EPERM", "ENOTEMPTY"].includes(code) || attempt === 4) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
}
}
Fix with Cubic

});

it("creates deterministic named backup when accounts exist", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "primary",
refreshToken: "ref-primary",
addedAt: 1,
lastUsed: 1,
},
],
});

const fixedNow = Date.UTC(2024, 0, 2, 3, 4, 5);
const snapshot = await snapshotAccountStorage({
reason: "delete-saved-accounts",
now: fixedNow,
});

expect(snapshot?.name).toBe(
"accounts-delete-saved-accounts-snapshot-2024-01-02_03-04-05",
);
expect(snapshot?.path && existsSync(snapshot.path)).toBe(true);
});

it("skips snapshot when no accounts exist", async () => {
const snapshot = await snapshotAccountStorage({
reason: "delete-saved-accounts",
});
expect(snapshot).toBeNull();
expect(existsSync(getNamedBackupsDirectoryPath())).toBe(false);
});

it("warn failure policy returns null when snapshot creation fails", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "primary",
refreshToken: "ref-primary",
addedAt: 1,
lastUsed: 1,
},
],
});

const failingBackup = vi
.fn()
.mockRejectedValue(new Error("snapshot failed"));

await expect(
snapshotAccountStorage({
reason: "reset-local-state",
createBackup: failingBackup,
}),
).resolves.toBeNull();
});

it("propagates when failure policy is error", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "primary",
refreshToken: "ref-primary",
addedAt: 1,
lastUsed: 1,
},
],
});

const failingBackup = vi
.fn()
.mockRejectedValue(new Error("snapshot failed"));

await expect(
snapshotAccountStorage({
reason: "reset-local-state",
failurePolicy: "error",
createBackup: failingBackup,
}),
).rejects.toThrow(/snapshot failed/);
});

it("creates snapshot before deleteSavedAccounts and preserves named backup", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "primary",
refreshToken: "ref-primary",
addedAt: 1,
lastUsed: 1,
},
],
});

await deleteSavedAccounts();

const backupsDir = getNamedBackupsDirectoryPath();
const entries = existsSync(backupsDir)
? await fs.readdir(backupsDir)
: [];
expect(
entries.some((name) =>
name.startsWith("accounts-delete-saved-accounts-snapshot-"),
),
).toBe(true);
expect(await loadAccounts()).toBeNull();
});

it("creates snapshot before resetLocalState", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "primary",
refreshToken: "ref-primary",
addedAt: 1,
lastUsed: 1,
},
],
});

await resetLocalState();

const backupsDir = getNamedBackupsDirectoryPath();
const entries = existsSync(backupsDir)
? await fs.readdir(backupsDir)
: [];
expect(
entries.some((name) =>
name.startsWith("accounts-reset-local-state-snapshot-"),
),
).toBe(true);
expect(await loadAccounts()).toBeNull();
});

it("captures snapshot before importAccounts when accounts already exist", async () => {
const importPath = join(testWorkDir, "import.json");

await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "existing",
refreshToken: "ref-existing",
addedAt: 1,
lastUsed: 1,
},
],
});

await fs.writeFile(
importPath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "imported",
refreshToken: "ref-import",
addedAt: 2,
lastUsed: 2,
},
],
}),
"utf-8",
);

await importAccounts(importPath);

const backupsDir = getNamedBackupsDirectoryPath();
const entries = existsSync(backupsDir)
? await fs.readdir(backupsDir)
: [];
expect(
entries.some((name) =>
name.startsWith("accounts-import-accounts-snapshot-"),
),
).toBe(true);

const loaded = await loadAccounts();
expect(loaded?.accounts).toHaveLength(2);
expect(loaded?.accounts.map((account) => account.accountId)).toEqual(
expect.arrayContaining(["existing", "imported"]),
);
});
});

describe("filename migration (TDD)", () => {
it("should migrate from old filename to new filename", async () => {
// This test is tricky because it depends on the internal state of getStoragePath()
Expand Down