Skip to content

Commit e407350

Browse files
committed
fix(storage): harden opencode import retention and redaction
1 parent a9d6a84 commit e407350

4 files changed

Lines changed: 369 additions & 32 deletions

File tree

lib/codex-manager.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4468,8 +4468,9 @@ async function runAuthLogin(): Promise<number> {
44684468
continue;
44694469
}
44704470
if (!assessment.backup.valid || !assessment.eligibleForRestore) {
4471-
const assessmentErrorLabel =
4472-
assessment.error || "OpenCode account pool is not importable.";
4471+
const assessmentErrorLabel = formatRedactedFilesystemError(
4472+
assessment.error || "OpenCode account pool is not importable.",
4473+
);
44734474
console.log(assessmentErrorLabel);
44744475
continue;
44754476
}

lib/storage.ts

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ const ROTATING_BACKUP_STALE_ARTIFACT_MAX_AGE_MS = 60_000;
5757
export const NAMED_BACKUP_LIST_CONCURRENCY = 8;
5858
export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3;
5959
const RESET_MARKER_SUFFIX = ".reset-intent";
60+
const AUTO_SNAPSHOT_MARKER_SUFFIX = ".snapshot-auto";
6061
let storageBackupEnabled = true;
6162
let lastAccountsSaveTimestamp = 0;
63+
const retentionEnforcementInFlight = new Set<string>();
6264

6365
export interface FlaggedAccountMetadataV1 extends AccountMetadataV3 {
6466
flaggedAt: number;
@@ -1880,8 +1882,9 @@ export async function getRestoreAssessment(): Promise<RestoreAssessment> {
18801882
};
18811883
}
18821884

1883-
async function scanNamedBackups(): Promise<NamedBackupScanResult> {
1884-
const backupRoot = getNamedBackupRoot(getStoragePath());
1885+
async function scanNamedBackups(
1886+
backupRoot = getNamedBackupRoot(getStoragePath()),
1887+
): Promise<NamedBackupScanResult> {
18851888
try {
18861889
const entries = await retryTransientFilesystemOperation(() =>
18871890
fs.readdir(backupRoot, { withFileTypes: true }),
@@ -1949,8 +1952,9 @@ async function scanNamedBackups(): Promise<NamedBackupScanResult> {
19491952
}
19501953
}
19511954

1952-
async function listNamedBackupsWithoutLoading(): Promise<NamedBackupMetadataListingResult> {
1953-
const backupRoot = getNamedBackupRoot(getStoragePath());
1955+
async function listNamedBackupsWithoutLoading(
1956+
backupRoot = getNamedBackupRoot(getStoragePath()),
1957+
): Promise<NamedBackupMetadataListingResult> {
19541958
try {
19551959
const entries = await retryTransientFilesystemOperation(() =>
19561960
fs.readdir(backupRoot, { withFileTypes: true }),
@@ -2088,7 +2092,12 @@ export function detectOpencodeAccountPoolPath(): string | null {
20882092
const explicit = process.env.CODEX_OPENCODE_POOL_PATH;
20892093
if (explicit?.trim()) {
20902094
const explicitPath = resolvePath(explicit.trim());
2091-
return existsSync(explicitPath) ? explicitPath : null;
2095+
if (!existsSync(explicitPath)) {
2096+
throw new Error(
2097+
`CODEX_OPENCODE_POOL_PATH points to a file that does not exist: ${basename(explicitPath)}`,
2098+
);
2099+
}
2100+
return explicitPath;
20922101
}
20932102

20942103
const appDataBases = [process.env.LOCALAPPDATA, process.env.APPDATA].filter(
@@ -2322,6 +2331,43 @@ export function isAccountSnapshotName(name: string): boolean {
23222331
);
23232332
}
23242333

2334+
function getAutoSnapshotMarkerPath(backupPath: string): string {
2335+
return `${backupPath}${AUTO_SNAPSHOT_MARKER_SUFFIX}`;
2336+
}
2337+
2338+
function normalizeStorageComparisonKey(pathValue: string): string {
2339+
const resolvedPath = resolvePath(pathValue);
2340+
return process.platform === "win32"
2341+
? resolvedPath.toLowerCase()
2342+
: resolvedPath;
2343+
}
2344+
2345+
async function writeAutoSnapshotMarker(
2346+
backupPath: string,
2347+
reason: AccountSnapshotReason,
2348+
): Promise<void> {
2349+
const markerPath = getAutoSnapshotMarkerPath(backupPath);
2350+
await retryTransientFilesystemOperation(() =>
2351+
fs.writeFile(markerPath, reason, { encoding: "utf-8", mode: 0o600 }),
2352+
);
2353+
}
2354+
2355+
async function deleteAutoSnapshotMarker(backupPath: string): Promise<void> {
2356+
const markerPath = getAutoSnapshotMarkerPath(backupPath);
2357+
try {
2358+
await unlinkWithRetry(markerPath);
2359+
} catch (error) {
2360+
const code = (error as NodeJS.ErrnoException).code;
2361+
if (code !== "ENOENT") {
2362+
throw error;
2363+
}
2364+
}
2365+
}
2366+
2367+
function isMarkedAutoSnapshot(backupPath: string): boolean {
2368+
return existsSync(getAutoSnapshotMarkerPath(backupPath));
2369+
}
2370+
23252371
function getAccountSnapshotReason(name: string): string | null {
23262372
const match = name.match(
23272373
/^accounts-(?<reason>[a-z0-9-]+)-snapshot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{3}$/i,
@@ -2389,6 +2435,7 @@ export interface AutoSnapshotPruneOptions {
23892435
backups?: NamedBackupMetadata[];
23902436
preserveNames?: Iterable<string>;
23912437
keepLatestPerReason?: number;
2438+
storagePath?: string;
23922439
}
23932440

23942441
export interface AutoSnapshotPruneResult {
@@ -2399,7 +2446,13 @@ export interface AutoSnapshotPruneResult {
23992446
export async function pruneAutoGeneratedSnapshots(
24002447
options: AutoSnapshotPruneOptions = {},
24012448
): Promise<AutoSnapshotPruneResult> {
2402-
const backups = options.backups ?? (await listNamedBackups());
2449+
const backups =
2450+
options.backups ??
2451+
(
2452+
await scanNamedBackups(
2453+
getNamedBackupRoot(options.storagePath ?? getStoragePath()),
2454+
)
2455+
).backups.map((entry) => entry.backup);
24032456
const keepLatestPerReason = Math.max(
24042457
1,
24052458
options.keepLatestPerReason ?? 1,
@@ -2410,7 +2463,12 @@ export async function pruneAutoGeneratedSnapshots(
24102463
}
24112464

24122465
const autoSnapshots = backups
2413-
.map((backup) => parseAutoSnapshot(backup))
2466+
.map((backup) => {
2467+
if (!isMarkedAutoSnapshot(backup.path)) {
2468+
return null;
2469+
}
2470+
return parseAutoSnapshot(backup);
2471+
})
24142472
.filter((snapshot): snapshot is AutoSnapshotDetails => snapshot !== null);
24152473
if (autoSnapshots.length === 0) {
24162474
return { pruned: [], kept: [] };
@@ -2439,6 +2497,7 @@ export async function pruneAutoGeneratedSnapshots(
24392497
}
24402498
try {
24412499
await unlinkWithRetry(snapshot.backup.path);
2500+
await deleteAutoSnapshotMarker(snapshot.backup.path);
24422501
pruned.push(snapshot.backup);
24432502
} catch (error) {
24442503
keptNames.add(snapshot.name);
@@ -2484,23 +2543,25 @@ export function formatRedactedFilesystemError(error: unknown): string {
24842543
}
24852544

24862545
function formatSnapshotErrorForLog(error: unknown): string {
2487-
const code =
2488-
typeof (error as NodeJS.ErrnoException | undefined)?.code === "string"
2489-
? (error as NodeJS.ErrnoException).code
2490-
: undefined;
2491-
const rawMessage =
2492-
error instanceof Error ? error.message : String(error ?? "unknown error");
2493-
const redactedMessage = redactFilesystemDetails(rawMessage);
2494-
if (code && !redactedMessage.includes(code)) {
2495-
return `${code}: ${redactedMessage}`;
2496-
}
2497-
return redactedMessage;
2546+
return formatRedactedFilesystemError(error);
24982547
}
24992548

2500-
async function enforceSnapshotRetention(): Promise<void> {
2501-
await pruneAutoGeneratedSnapshots({
2502-
keepLatestPerReason: ACCOUNT_SNAPSHOT_RETENTION_PER_REASON,
2503-
});
2549+
async function enforceSnapshotRetention(storagePath?: string): Promise<void> {
2550+
const resolvedStoragePath = normalizeStorageComparisonKey(
2551+
storagePath ?? getStoragePath(),
2552+
);
2553+
if (retentionEnforcementInFlight.has(resolvedStoragePath)) {
2554+
return;
2555+
}
2556+
retentionEnforcementInFlight.add(resolvedStoragePath);
2557+
try {
2558+
await pruneAutoGeneratedSnapshots({
2559+
keepLatestPerReason: ACCOUNT_SNAPSHOT_RETENTION_PER_REASON,
2560+
storagePath: resolvedStoragePath,
2561+
});
2562+
} finally {
2563+
retentionEnforcementInFlight.delete(resolvedStoragePath);
2564+
}
25042565
}
25052566

25062567
export async function snapshotAccountStorage(
@@ -2545,7 +2606,17 @@ export async function snapshotAccountStorage(
25452606
}
25462607

25472608
try {
2548-
await enforceSnapshotRetention();
2609+
await writeAutoSnapshotMarker(snapshot.path, reason);
2610+
} catch (error) {
2611+
log.warn("Failed to mark account storage snapshot for retention", {
2612+
reason,
2613+
backupName,
2614+
error: formatSnapshotErrorForLog(error),
2615+
});
2616+
}
2617+
2618+
try {
2619+
await enforceSnapshotRetention(resolvedStoragePath);
25492620
} catch (error) {
25502621
log.warn("Failed to enforce account snapshot retention", {
25512622
reason,
@@ -3067,9 +3138,11 @@ function equalsNamedBackupEntry(left: string, right: string): boolean {
30673138
}
30683139

30693140
function equalsResolvedStoragePath(left: string, right: string): boolean {
3141+
const normalizedLeft = resolvePath(left);
3142+
const normalizedRight = resolvePath(right);
30703143
return process.platform === "win32"
3071-
? left.toLowerCase() === right.toLowerCase()
3072-
: left === right;
3144+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
3145+
: normalizedLeft === normalizedRight;
30733146
}
30743147

30753148
function stripNamedBackupJsonExtension(name: string): string {

test/codex-manager-cli.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,58 @@ describe("codex manager cli commands", () => {
11251125
);
11261126
});
11271127

1128+
it("skips OpenCode import when the detected pool resolves to the active storage file", async () => {
1129+
setInteractiveTTY(true);
1130+
loadAccountsMock.mockResolvedValue({
1131+
version: 3,
1132+
activeIndex: 0,
1133+
activeIndexByFamily: { codex: 0 },
1134+
accounts: [],
1135+
});
1136+
assessOpencodeAccountPoolMock.mockResolvedValue({
1137+
backup: {
1138+
name: "openai-codex-accounts.json",
1139+
path: "C:\\mock\\openai-codex-accounts.json",
1140+
createdAt: null,
1141+
updatedAt: Date.now(),
1142+
sizeBytes: 128,
1143+
version: 3,
1144+
accountCount: 1,
1145+
schemaErrors: [],
1146+
valid: true,
1147+
loadError: "",
1148+
},
1149+
currentAccountCount: 0,
1150+
mergedAccountCount: null,
1151+
imported: null,
1152+
skipped: null,
1153+
wouldExceedLimit: false,
1154+
eligibleForRestore: false,
1155+
nextActiveIndex: null,
1156+
nextActiveEmail: undefined,
1157+
nextActiveAccountId: undefined,
1158+
activeAccountChanged: false,
1159+
error: "Import source cannot be the active storage file.",
1160+
});
1161+
getStoragePathMock.mockReturnValue("c:/mock/openai-codex-accounts.json");
1162+
promptLoginModeMock
1163+
.mockResolvedValueOnce({ mode: "import-opencode" })
1164+
.mockResolvedValueOnce({ mode: "cancel" });
1165+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
1166+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
1167+
1168+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
1169+
1170+
expect(exitCode).toBe(0);
1171+
expect(assessOpencodeAccountPoolMock).toHaveBeenCalledTimes(1);
1172+
expect(confirmMock).not.toHaveBeenCalled();
1173+
expect(importAccountsMock).not.toHaveBeenCalled();
1174+
expect(logSpy).toHaveBeenCalledWith(
1175+
"Import source cannot be the active storage file.",
1176+
);
1177+
logSpy.mockRestore();
1178+
});
1179+
11281180
it("returns a non-zero exit code when the direct restore-backup command fails", async () => {
11291181
setInteractiveTTY(true);
11301182
const now = Date.now();
@@ -1488,7 +1540,8 @@ describe("codex manager cli commands", () => {
14881540
accountCount: 0,
14891541
schemaErrors: ["invalid"],
14901542
valid: false,
1491-
loadError: "ENOENT: openai-codex-accounts.json",
1543+
loadError:
1544+
"ENOENT: C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json",
14921545
},
14931546
currentAccountCount: 0,
14941547
mergedAccountCount: null,
@@ -1500,7 +1553,8 @@ describe("codex manager cli commands", () => {
15001553
nextActiveEmail: undefined,
15011554
nextActiveAccountId: undefined,
15021555
activeAccountChanged: false,
1503-
error: "ENOENT: openai-codex-accounts.json",
1556+
error:
1557+
"ENOENT: C:\\Users\\alice\\AppData\\Local\\OpenCode\\openai-codex-accounts.json",
15041558
});
15051559
promptLoginModeMock
15061560
.mockResolvedValueOnce({ mode: "import-opencode" })
@@ -1516,6 +1570,11 @@ describe("codex manager cli commands", () => {
15161570
expect(logSpy).toHaveBeenCalledWith(
15171571
"ENOENT: openai-codex-accounts.json",
15181572
);
1573+
expect(
1574+
logSpy.mock.calls.some(([message]) =>
1575+
String(message).includes("C:\\Users\\alice\\AppData\\Local\\OpenCode"),
1576+
),
1577+
).toBe(false);
15191578
logSpy.mockRestore();
15201579
});
15211580
it("runs restore preview before applying a replace-only named backup", async () => {

0 commit comments

Comments
 (0)