From 35e3a0e5472e3c0927d916c5d88d80f6e08800d2 Mon Sep 17 00:00:00 2001 From: LeoWang331 <134831918+LeoWang331@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:30:01 -0400 Subject: [PATCH 1/5] fix(config): harden preserved rollback snapshots before unlinking the source Verify the copy, apply the same 0600/Windows secret-path hardening as v2 backups, then re-read the source before unlink so a changed rollback file is not deleted. Clean up unverified destinations when the preserved-path read fails. Co-authored-by: Cursor --- src/config.ts | 26 +++- tests/init-backup-cleanup.test.ts | 98 ++++++++++++- tests/openai-provider-option-startup.test.ts | 143 +++++++++++++++++++ 3 files changed, 263 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 703a444d8..2a605ef72 100644 --- a/src/config.ts +++ b/src/config.ts @@ -382,7 +382,7 @@ export class OpenAiTierBackupCollisionError extends Error { } export class OpenAiTierRollbackPreserveError extends Error { - readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted"; + readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted" | "changed"; constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) { super(message, options); this.name = "OpenAiTierRollbackPreserveError"; @@ -570,6 +570,7 @@ export interface OpenAiTierRollbackPreserveIO { exists(path: string): boolean; read(path: string): Uint8Array; copyExclusive(source: string, destination: string): void; + harden(path: string): void; unlink(path: string): void; } @@ -579,6 +580,13 @@ const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + // Same Windows ACL path as v2 migration backup: required:false so a wedged + // icacls on CI temp volumes cannot abort start, but the secret-path helper + // still runs. CopyFile does not copy the source DACL. + if (process.platform === "win32") hardenSecretPath(target, { required: false }); + }, unlink: unlinkSync, }; @@ -587,8 +595,9 @@ const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; /** * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the - * blocking v2 name. The original bytes are copied with no-replace publication; - * the v2 path is removed only after the copy is verified. Shared by startup + * blocking v2 name. Order is copy → verify bytes → harden → re-read source → + * unlink source. The v2 path is removed only after the copy is verified, the + * destination is hardened, and the source still matches. Shared by startup * migration recovery and `ocx init` cleanup so the two paths cannot drift. */ export function preserveOpenAiTierRollbackSnapshot( @@ -615,12 +624,23 @@ export function preserveOpenAiTierRollbackSnapshot( try { copied = io.read(preserved); } catch (error) { + try { io.unlink(preserved); } catch { /* unverified copy cleanup is best-effort; never unlink the source */ } throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" }); } if (!sameBytes(original, copied)) { try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ } throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" }); } + io.harden(preserved); + let sourceNow: Uint8Array; + try { + sourceNow = io.read(backup); + } catch (error) { + throw new OpenAiTierRollbackPreserveError("Failed to re-read OpenAI tier rollback backup before unlink", { cause: error, code: "changed" }); + } + if (!sameBytes(copied, sourceNow)) { + throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup changed after it was copied", { code: "changed" }); + } io.unlink(backup); return preserved; } diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index ff5958971..9bafdc822 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { constants as fsConstants, copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanupOpenAiTierBackupAfterInit } from "../src/cli/init"; @@ -99,6 +99,7 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { exists: existsSync, read: path => readFileSync(path), copyExclusive: () => { throw new Error("copy failed"); }, + harden: () => { throw new Error("harden must not run"); }, unlink: () => { throw new Error("unlink must not run"); }, })).toThrow("copy failed"); expect(readFileSync(backup, "utf8")).toBe(v1); @@ -127,4 +128,99 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { expect(readFileSync(`${configPath}.pre-openai-tiers-v1-rollback.${now}.bak`, "utf8")).toBe("occupied"); expect(readFileSync(`${configPath}.pre-openai-tiers-v1-rollback.${now}-1.bak`, "utf8")).toBe("occupied"); }); + + test("preserveOpenAiTierRollbackSnapshot hardens before unlinking the source", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + const preserved = preserveOpenAiTierRollbackSnapshot(configPath, { + exists: existsSync, + read: path => { + calls.push(path === backup ? "read-source" : "read-preserved"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { + calls.push("copy"); + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: path => { calls.push(`harden:${path}`); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-other"); + if (path !== backup) throw new Error("only the source backup may be unlinked after a verified copy"); + unlinkSync(path); + }, + }); + expect(calls).toEqual(["read-source", "copy", "read-preserved", `harden:${preserved}`, "read-source", "unlink-source"]); + expect(existsSync(backup)).toBe(false); + expect(readFileSync(preserved, "utf8")).toBe(v1); + }); + + test("preserveOpenAiTierRollbackSnapshot harden failure keeps the v2 backup", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: () => { throw new Error("harden failed"); }, + unlink: () => { throw new Error("unlink must not run"); }, + })).toThrow("harden failed"); + expect(readFileSync(backup, "utf8")).toBe(v1); + }); + + test("preserveOpenAiTierRollbackSnapshot does not unlink a source that changed after copy", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, bytesA); + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(source, bytesB); + }, + harden: () => {}, + unlink: () => { throw new Error("unlink must not run"); }, + })).toThrow(OpenAiTierRollbackPreserveError); + expect(readFileSync(backup, "utf8")).toBe(bytesB); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + }); + + test("preserveOpenAiTierRollbackSnapshot removes an unverified copy when read(preserved) fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const unlinks: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { + exists: existsSync, + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: () => { throw new Error("harden must not run"); }, + unlink: path => { + unlinks.push(path); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + })).toThrow("Failed to read preserved rollback snapshot"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); }); diff --git a/tests/openai-provider-option-startup.test.ts b/tests/openai-provider-option-startup.test.ts index 9a281da11..51de3682b 100644 --- a/tests/openai-provider-option-startup.test.ts +++ b/tests/openai-provider-option-startup.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, + constants as fsConstants, + copyFileSync, existsSync, linkSync, mkdtempSync, @@ -677,6 +679,7 @@ describe("OpenAI provider option startup coordinator", () => { exists: existsSync, read: path => readFileSync(path), copyExclusive: () => { throw new Error("copy failed"); }, + harden: () => { throw new Error("harden must not run"); }, unlink: unlinkSync, }; @@ -710,4 +713,144 @@ describe("OpenAI provider option startup coordinator", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("startup does not save when rollback harden fails before source unlink (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-hardenfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const calls: string[] = []; + const failingIo: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + calls.push("copy"); + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => { calls.push("harden"); throw new Error("harden failed"); }, + unlink: path => { + calls.push(`unlink:${path}`); + if (path === v2Backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { calls.push("save"); }, + })).toThrow("harden failed"); + + expect(calls).toEqual(["copy", "harden"]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + expect(calls.includes("save")).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when the rollback source changes after copy (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-srcchange-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const saves: number[] = []; + const changingIo: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(source, bytesB); + }, + harden: () => {}, + unlink: () => { throw new Error("source unlink must not run"); }, + }; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, changingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when read(preserved) fails and still keeps the source (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-readfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const unlinks: string[] = []; + const saves: number[] = []; + const failingIo: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: () => { throw new Error("harden must not run"); }, + unlink: path => { + unlinks.push(path); + if (path === v2Backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow("Failed to read preserved rollback snapshot"); + + expect(saves).toEqual([]); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 0031c0662575cb1e9f4ea1a7ffc06c876c490b5a Mon Sep 17 00:00:00 2001 From: LeoWang331 <134831918+LeoWang331@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:27:29 -0400 Subject: [PATCH 2/5] fix(config): fail closed on rollback snapshot cleanup Co-authored-by: Cursor --- src/config.ts | 92 ++++++-- tests/init-backup-cleanup.test.ts | 209 ++++++++++++++++--- tests/openai-provider-option-startup.test.ts | 118 ++++++++++- 3 files changed, 370 insertions(+), 49 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2a605ef72..fd170689f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -390,6 +390,20 @@ export class OpenAiTierRollbackPreserveError extends Error { } } +export class OpenAiTierRollbackPreserveSecretResidualError extends Error { + constructor(readonly preservedPath: string, options?: ErrorOptions) { + super("OpenAI tier rollback preserve could not scrub or remove an unverified snapshot", options); + this.name = "OpenAiTierRollbackPreserveSecretResidualError"; + } +} + +export class OpenAiTierRollbackPreserveCleanupError extends Error { + constructor(readonly preservedPath: string, readonly restricted = false, options?: ErrorOptions) { + super("OpenAI tier rollback preserve could not remove a scrubbed unverified snapshot", options); + this.name = "OpenAiTierRollbackPreserveCleanupError"; + } +} + export class OpenAiTierBackupSecretResidualError extends Error { constructor(readonly tempPath: string, options?: ErrorOptions) { super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options); @@ -571,6 +585,8 @@ export interface OpenAiTierRollbackPreserveIO { read(path: string): Uint8Array; copyExclusive(source: string, destination: string): void; harden(path: string): void; + truncate(path: string): void; + write(path: string, bytes: Uint8Array): void; unlink(path: string): void; } @@ -581,12 +597,15 @@ const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - // Same Windows ACL path as v2 migration backup: required:false so a wedged - // icacls on CI temp volumes cannot abort start, but the secret-path helper - // still runs. CopyFile does not copy the source DACL. - if (process.platform === "win32") hardenSecretPath(target, { required: false }); + // Fail closed: chmod errors propagate. Windows ACL uses required:true so a + // failed icacls cannot continue into source unlink. The v2 migration backup + // path keeps required:false; this callback is only for preserved rollback + // snapshots. CopyFile does not copy the source DACL. + chmodSync(target, 0o600); + if (process.platform === "win32") hardenSecretPath(target, { required: true }); }, + truncate: target => truncateSync(target, 0), + write: (target, bytes) => writeFileSync(target, bytes), unlink: unlinkSync, }; @@ -597,8 +616,10 @@ const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the * blocking v2 name. Order is copy → verify bytes → harden → re-read source → * unlink source. The v2 path is removed only after the copy is verified, the - * destination is hardened, and the source still matches. Shared by startup - * migration recovery and `ocx init` cleanup so the two paths cannot drift. + * destination is hardened, and the source still matches. Pre-harden failures + * scrub and remove the unverified destination; they never unlink the source. + * Shared by startup migration recovery and `ocx init` cleanup so the two paths + * cannot drift. */ export function preserveOpenAiTierRollbackSnapshot( configPath = getConfigPath(), @@ -612,6 +633,42 @@ export function preserveOpenAiTierRollbackSnapshot( if (classifyOpenAiTierBackup(original) !== "rollback") { throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" }); } + + const failUnverifiedCopy = (preserved: string, cause: unknown): never => { + let scrubbed = false; + try { + io.truncate(preserved); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { io.write(preserved, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + io.unlink(preserved); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { io.unlink(preserved); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + let restricted = false; + if (!removed) { + try { io.harden(preserved); restricted = true; } catch { /* leftover restriction is best-effort */ } + } + if (!removed && !scrubbed) { + throw new OpenAiTierRollbackPreserveSecretResidualError(preserved, { cause }); + } + if (!removed) { + throw new OpenAiTierRollbackPreserveCleanupError(preserved, restricted, { cause }); + } + throw cause; + }; + for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; try { @@ -620,18 +677,21 @@ export function preserveOpenAiTierRollbackSnapshot( if (isAlreadyExistsError(error)) continue; throw error; } - let copied: Uint8Array; + const copied = (() => { + try { + return io.read(preserved); + } catch (error) { + return failUnverifiedCopy(preserved, new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" })); + } + })(); + if (!sameBytes(original, copied)) { + failUnverifiedCopy(preserved, new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" })); + } try { - copied = io.read(preserved); + io.harden(preserved); } catch (error) { - try { io.unlink(preserved); } catch { /* unverified copy cleanup is best-effort; never unlink the source */ } - throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" }); - } - if (!sameBytes(original, copied)) { - try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ } - throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" }); + failUnverifiedCopy(preserved, error); } - io.harden(preserved); let sourceNow: Uint8Array; try { sourceNow = io.read(backup); diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index 9bafdc822..0eb554bfd 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -1,9 +1,44 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { constants as fsConstants, copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { constants as fsConstants, copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanupOpenAiTierBackupAfterInit } from "../src/cli/init"; -import { classifyOpenAiTierBackup, OpenAiTierRollbackPreserveError, preserveOpenAiTierRollbackSnapshot } from "../src/config"; +import { + classifyOpenAiTierBackup, + OpenAiTierRollbackPreserveCleanupError, + OpenAiTierRollbackPreserveError, + OpenAiTierRollbackPreserveSecretResidualError, + preserveOpenAiTierRollbackSnapshot, + type OpenAiTierRollbackPreserveIO, +} from "../src/config"; + +function preserveIo( + backup: string, + overrides: Partial = {}, + options: { allowSourceUnlink?: boolean } = {}, +): OpenAiTierRollbackPreserveIO { + return { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => {}, + truncate: path => { + if (path === backup) throw new Error("source truncate must not run"); + truncateSync(path, 0); + }, + write: (path, bytes) => { + if (path === backup) throw new Error("source write must not run"); + writeFileSync(path, bytes); + }, + unlink: path => { + if (path === backup && !options.allowSourceUnlink) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + ...overrides, + }; +} describe("cleanupOpenAiTierBackupAfterInit", () => { const dirs: string[] = []; @@ -95,13 +130,13 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const backup = `${configPath}.pre-openai-tiers-v2.bak`; const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); writeFileSync(backup, v1); - expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, - read: path => readFileSync(path), + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { copyExclusive: () => { throw new Error("copy failed"); }, harden: () => { throw new Error("harden must not run"); }, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, unlink: () => { throw new Error("unlink must not run"); }, - })).toThrow("copy failed"); + }))).toThrow("copy failed"); expect(readFileSync(backup, "utf8")).toBe(v1); expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); }); @@ -136,8 +171,7 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); writeFileSync(backup, v1); const calls: string[] = []; - const preserved = preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, + const preserved = preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { read: path => { calls.push(path === backup ? "read-source" : "read-preserved"); return readFileSync(path); @@ -147,12 +181,14 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: path => { calls.push(`harden:${path}`); }, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, unlink: path => { calls.push(path === backup ? "unlink-source" : "unlink-other"); if (path !== backup) throw new Error("only the source backup may be unlinked after a verified copy"); unlinkSync(path); }, - }); + }, { allowSourceUnlink: true })); expect(calls).toEqual(["read-source", "copy", "read-preserved", `harden:${preserved}`, "read-source", "unlink-source"]); expect(existsSync(backup)).toBe(false); expect(readFileSync(preserved, "utf8")).toBe(v1); @@ -164,14 +200,20 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const backup = `${configPath}.pre-openai-tiers-v2.bak`; const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); writeFileSync(backup, v1); - expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, - read: path => readFileSync(path), - copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { harden: () => { throw new Error("harden failed"); }, - unlink: () => { throw new Error("unlink must not run"); }, - })).toThrow("harden failed"); + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("harden failed"); expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); }); test("preserveOpenAiTierRollbackSnapshot does not unlink a source that changed after copy", () => { @@ -181,20 +223,21 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); writeFileSync(backup, bytesA); - expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, - read: path => readFileSync(path), + const hardened: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); writeFileSync(source, bytesB); }, - harden: () => {}, - unlink: () => { throw new Error("unlink must not run"); }, - })).toThrow(OpenAiTierRollbackPreserveError); + harden: path => { hardened.push(path); }, + truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, + write: () => { throw new Error("verified hardened copy must not be overwritten"); }, + }))).toThrow(OpenAiTierRollbackPreserveError); expect(readFileSync(backup, "utf8")).toBe(bytesB); const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); expect(preserved).toHaveLength(1); expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened).toEqual([join(dir, preserved[0]!)]); }); test("preserveOpenAiTierRollbackSnapshot removes an unverified copy when read(preserved) fails", () => { @@ -203,24 +246,132 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const backup = `${configPath}.pre-openai-tiers-v2.bak`; const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); writeFileSync(backup, v1); - const unlinks: string[] = []; - expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: () => { throw new Error("harden must not run"); }, + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("Failed to read preserved rollback snapshot"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot scrubs a byte-mismatched copy", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(destination, "tampered-secret"); + }, + harden: () => { throw new Error("harden must not run"); }, + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("Preserved rollback snapshot does not match source bytes"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot retries unlink once after the first cleanup unlink fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + let preservedUnlinks = 0; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { read: path => { if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); return readFileSync(path); }, - copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: () => { throw new Error("harden must not run"); }, unlink: path => { - unlinks.push(path); if (path === backup) throw new Error("source unlink must not run"); + preservedUnlinks += 1; + if (preservedUnlinks === 1) throw new Error("first unlink failed"); unlinkSync(path); }, - })).toThrow("Failed to read preserved rollback snapshot"); + }))).toThrow("Failed to read preserved rollback snapshot"); + expect(preservedUnlinks).toBe(2); expect(readFileSync(backup, "utf8")).toBe(v1); - expect(unlinks).toHaveLength(1); - expect(unlinks[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); }); + + test("preserveOpenAiTierRollbackSnapshot does not leave plaintext when unlink keeps failing after a successful scrub", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const leftoverHarden: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: path => { leftoverHarden.push(path); }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + }))).toThrow(OpenAiTierRollbackPreserveCleanupError); + expect(readFileSync(backup, "utf8")).toBe(v1); + const leftover = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(leftover).toHaveLength(1); + expect(readFileSync(join(dir, leftover[0]!), "utf8")).toBe(""); + expect(leftoverHarden).toEqual([join(dir, leftover[0]!)]); + }); + + test("preserveOpenAiTierRollbackSnapshot reports a residual-secret error when scrub and unlink both fail", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const leftoverHarden: string[] = []; + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: path => { leftoverHarden.push(path); }, + truncate: () => { throw new Error("truncate failed"); }, + write: () => { throw new Error("write failed"); }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + })); + throw new Error("expected residual-secret failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveSecretResidualError); + const residual = error as OpenAiTierRollbackPreserveSecretResidualError; + expect(residual.preservedPath.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(existsSync(residual.preservedPath)).toBe(true); + expect(readFileSync(residual.preservedPath, "utf8")).toBe(v1); + expect(leftoverHarden).toEqual([residual.preservedPath]); + } + expect(readFileSync(backup, "utf8")).toBe(v1); + }); }); diff --git a/tests/openai-provider-option-startup.test.ts b/tests/openai-provider-option-startup.test.ts index 51de3682b..376f99e6b 100644 --- a/tests/openai-provider-option-startup.test.ts +++ b/tests/openai-provider-option-startup.test.ts @@ -25,7 +25,9 @@ import { OpenAiTierBackupCollisionError, OpenAiTierBackupRollbackError, OpenAiTierBackupSecretResidualError, + OpenAiTierRollbackPreserveCleanupError, OpenAiTierRollbackPreserveError, + OpenAiTierRollbackPreserveSecretResidualError, preserveOpenAiTierRollbackSnapshot, type OpenAiTierBackupIO, type OpenAiTierRollbackPreserveIO, @@ -680,7 +682,9 @@ describe("OpenAI provider option startup coordinator", () => { read: path => readFileSync(path), copyExclusive: () => { throw new Error("copy failed"); }, harden: () => { throw new Error("harden must not run"); }, - unlink: unlinkSync, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, + unlink: () => { throw new Error("unlink must not run"); }, }; expect(() => runOpenAiTierStartupMigration(currentConfig, { @@ -737,8 +741,10 @@ describe("OpenAI provider option startup coordinator", () => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: () => { calls.push("harden"); throw new Error("harden failed"); }, + truncate: path => { calls.push("truncate"); truncateSync(path, 0); }, + write: () => { throw new Error("write must not run"); }, unlink: path => { - calls.push(`unlink:${path}`); + calls.push(path === v2Backup ? "unlink-source" : "unlink-preserved"); if (path === v2Backup) throw new Error("source unlink must not run"); unlinkSync(path); }, @@ -751,10 +757,11 @@ describe("OpenAI provider option startup coordinator", () => { save: () => { calls.push("save"); }, })).toThrow("harden failed"); - expect(calls).toEqual(["copy", "harden"]); + expect(calls).toEqual(["copy", "harden", "truncate", "unlink-preserved"]); expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); expect(readFileSync(configPath, "utf8")).toBe(currentBytes); expect(calls.includes("save")).toBe(false); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -776,6 +783,7 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(configPath, currentBytes); writeFileSync(v2Backup, bytesA); const saves: number[] = []; + const hardened: string[] = []; const changingIo: OpenAiTierRollbackPreserveIO = { exists: existsSync, read: path => readFileSync(path), @@ -783,7 +791,9 @@ describe("OpenAI provider option startup coordinator", () => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); writeFileSync(source, bytesB); }, - harden: () => {}, + harden: path => { hardened.push(path); }, + truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, + write: () => { throw new Error("verified hardened copy must not be overwritten"); }, unlink: () => { throw new Error("source unlink must not run"); }, }; @@ -800,6 +810,7 @@ describe("OpenAI provider option startup coordinator", () => { const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); expect(preserved).toHaveLength(1); expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened).toEqual([join(dir, preserved[0]!)]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -829,6 +840,8 @@ describe("OpenAI provider option startup coordinator", () => { }, copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: () => { throw new Error("harden must not run"); }, + truncate: path => truncateSync(path, 0), + write: () => { throw new Error("write must not run"); }, unlink: path => { unlinks.push(path); if (path === v2Backup) throw new Error("source unlink must not run"); @@ -853,4 +866,101 @@ describe("OpenAI provider option startup coordinator", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("startup does not save when preserved cleanup cannot unlink a scrubbed copy (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-cleanupfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const saves: number[] = []; + const leftoverHarden: string[] = []; + const failingIo: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: path => { leftoverHarden.push(path); }, + truncate: path => truncateSync(path, 0), + write: () => { throw new Error("write must not run"); }, + unlink: path => { + if (path === v2Backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + }; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveCleanupError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const leftover = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(leftover).toHaveLength(1); + expect(readFileSync(join(dir, leftover[0]!), "utf8")).toBe(""); + expect(leftoverHarden).toEqual([join(dir, leftover[0]!)]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup propagates residual-secret errors without saving or deleting the v2 backup (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-residual-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const saves: number[] = []; + const failingIo: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: () => {}, + truncate: () => { throw new Error("truncate failed"); }, + write: () => { throw new Error("write failed"); }, + unlink: path => { + if (path === v2Backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + }; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveSecretResidualError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From e07f2e645e17495929dc0ae16b22fcf509da5914 Mon Sep 17 00:00:00 2001 From: LeoWang331 <134831918+LeoWang331@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:09:53 -0400 Subject: [PATCH 3/5] fix(config): claim rollback backups by inode before discard Co-authored-by: Cursor --- src/config.ts | 97 +++++++++++-- tests/init-backup-cleanup.test.ts | 74 ++++++++-- tests/openai-provider-option-startup.test.ts | 144 +++++++++++++++---- 3 files changed, 261 insertions(+), 54 deletions(-) diff --git a/src/config.ts b/src/config.ts index fd170689f..dfc85e47d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -404,6 +404,13 @@ export class OpenAiTierRollbackPreserveCleanupError extends Error { } } +export class OpenAiTierRollbackPreserveClaimError extends Error { + constructor(readonly claimedPath: string, options?: ErrorOptions) { + super("OpenAI tier rollback backup was replaced during preserve; the claimed snapshot was kept", options); + this.name = "OpenAiTierRollbackPreserveClaimError"; + } +} + export class OpenAiTierBackupSecretResidualError extends Error { constructor(readonly tempPath: string, options?: ErrorOptions) { super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options); @@ -588,6 +595,10 @@ export interface OpenAiTierRollbackPreserveIO { truncate(path: string): void; write(path: string, bytes: Uint8Array): void; unlink(path: string): void; + mkdirExclusive(path: string): void; + claimExclusive(source: string, destination: string): void; + linkExclusive(source: string, destination: string): void; + rmdir(path: string): void; } const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { @@ -607,19 +618,23 @@ const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { truncate: target => truncateSync(target, 0), write: (target, bytes) => writeFileSync(target, bytes), unlink: unlinkSync, + mkdirExclusive: target => { mkdirSync(target, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: target => { rmdirSync(target); }, }; const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; /** * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique - * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the - * blocking v2 name. Order is copy → verify bytes → harden → re-read source → - * unlink source. The v2 path is removed only after the copy is verified, the - * destination is hardened, and the source still matches. Pre-harden failures - * scrub and remove the unverified destination; they never unlink the source. - * Shared by startup migration recovery and `ocx init` cleanup so the two paths - * cannot drift. + * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then atomically + * claim the blocking v2 name into a private directory. Order is copy → verify + * bytes → harden preserved → mkdir claim dir → rename source onto the unique + * claim path → read the claimed inode → unlink only the claim. The original + * backup path is never unlinked, so a replacement that appears after the claim + * stays. Pre-harden failures scrub the unverified destination and never claim + * the source. Shared by startup migration recovery and `ocx init` cleanup. */ export function preserveOpenAiTierRollbackSnapshot( configPath = getConfigPath(), @@ -669,6 +684,15 @@ export function preserveOpenAiTierRollbackSnapshot( throw cause; }; + const restoreClaimedIfVacant = (claimedPath: string): void => { + try { + io.linkExclusive(claimedPath, backup); + } catch (error) { + if (isAlreadyExistsError(error)) return; + throw error; + } + }; + for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; try { @@ -692,16 +716,61 @@ export function preserveOpenAiTierRollbackSnapshot( } catch (error) { failUnverifiedCopy(preserved, error); } - let sourceNow: Uint8Array; + + let claimedDir = ""; + let claimedPath = ""; + for (let claimAttempt = 0; claimAttempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; claimAttempt++) { + const candidateDir = `${configPath}.pre-openai-tiers-v2-claim.${Date.now()}${claimAttempt ? `-${claimAttempt}` : ""}`; + try { + io.mkdirExclusive(candidateDir); + } catch (error) { + if (isAlreadyExistsError(error)) continue; + throw error; + } + const candidatePath = join(candidateDir, "claimed.bak"); + try { + io.claimExclusive(backup, candidatePath); + } catch (error) { + try { io.rmdir(candidateDir); } catch { /* empty claim dir leftover is not secret-bearing */ } + throw error; + } + claimedDir = candidateDir; + claimedPath = candidatePath; + break; + } + if (!claimedPath) { + throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback claim path", { code: "exhausted" }); + } + + const claimedBytes = (() => { + try { + return io.read(claimedPath); + } catch (error) { + throw new OpenAiTierRollbackPreserveError("Failed to read claimed rollback backup", { cause: error, code: "changed" }); + } + })(); + if (!sameBytes(copied, claimedBytes)) { + try { io.harden(claimedPath); } catch { /* claimed leftover must remain inspectable */ } + try { + restoreClaimedIfVacant(claimedPath); + } catch (error) { + throw new OpenAiTierRollbackPreserveClaimError(claimedPath, { cause: error }); + } + throw new OpenAiTierRollbackPreserveClaimError(claimedPath); + } + try { - sourceNow = io.read(backup); + io.unlink(claimedPath); } catch (error) { - throw new OpenAiTierRollbackPreserveError("Failed to re-read OpenAI tier rollback backup before unlink", { cause: error, code: "changed" }); + if (!isMissingPathError(error)) { + throw new OpenAiTierRollbackPreserveCleanupError(claimedPath, false, { cause: error }); + } } - if (!sameBytes(copied, sourceNow)) { - throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup changed after it was copied", { code: "changed" }); + try { + io.rmdir(claimedDir); + } catch (error) { + throw new OpenAiTierRollbackPreserveCleanupError(claimedDir, true, { cause: error }); } - io.unlink(backup); return preserved; } throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" }); diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index 0eb554bfd..2f91f6361 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { constants as fsConstants, copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanupOpenAiTierBackupAfterInit } from "../src/cli/init"; import { classifyOpenAiTierBackup, + OpenAiTierRollbackPreserveClaimError, OpenAiTierRollbackPreserveCleanupError, OpenAiTierRollbackPreserveError, OpenAiTierRollbackPreserveSecretResidualError, @@ -15,7 +16,6 @@ import { function preserveIo( backup: string, overrides: Partial = {}, - options: { allowSourceUnlink?: boolean } = {}, ): OpenAiTierRollbackPreserveIO { return { exists: existsSync, @@ -33,9 +33,13 @@ function preserveIo( writeFileSync(path, bytes); }, unlink: path => { - if (path === backup && !options.allowSourceUnlink) throw new Error("source unlink must not run"); + if (path === backup) throw new Error("source unlink must not run"); unlinkSync(path); }, + mkdirExclusive: path => { mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: path => { rmdirSync(path); }, ...overrides, }; } @@ -136,6 +140,10 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { truncate: () => { throw new Error("truncate must not run"); }, write: () => { throw new Error("write must not run"); }, unlink: () => { throw new Error("unlink must not run"); }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + linkExclusive: () => { throw new Error("link must not run"); }, + rmdir: () => { throw new Error("rmdir must not run"); }, }))).toThrow("copy failed"); expect(readFileSync(backup, "utf8")).toBe(v1); expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); @@ -164,7 +172,7 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { expect(readFileSync(`${configPath}.pre-openai-tiers-v1-rollback.${now}-1.bak`, "utf8")).toBe("occupied"); }); - test("preserveOpenAiTierRollbackSnapshot hardens before unlinking the source", () => { + test("preserveOpenAiTierRollbackSnapshot hardens before claiming the source", () => { const dir = makeDir(); const configPath = join(dir, "config.json"); const backup = `${configPath}.pre-openai-tiers-v2.bak`; @@ -173,7 +181,7 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const calls: string[] = []; const preserved = preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { read: path => { - calls.push(path === backup ? "read-source" : "read-preserved"); + calls.push(path === backup ? "read-source" : path.endsWith("claimed.bak") ? "read-claimed" : "read-preserved"); return readFileSync(path); }, copyExclusive: (source, destination) => { @@ -184,12 +192,28 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { truncate: () => { throw new Error("truncate must not run"); }, write: () => { throw new Error("write must not run"); }, unlink: path => { - calls.push(path === backup ? "unlink-source" : "unlink-other"); - if (path !== backup) throw new Error("only the source backup may be unlinked after a verified copy"); + calls.push(path === backup ? "unlink-source" : "unlink-claimed"); + if (path === backup) throw new Error("source unlink must not run"); unlinkSync(path); }, - }, { allowSourceUnlink: true })); - expect(calls).toEqual(["read-source", "copy", "read-preserved", `harden:${preserved}`, "read-source", "unlink-source"]); + mkdirExclusive: path => { calls.push("mkdir-claim"); mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { + calls.push("claim"); + renameSync(source, destination); + }, + rmdir: path => { calls.push("rmdir-claim"); rmdirSync(path); }, + })); + expect(calls).toEqual([ + "read-source", + "copy", + "read-preserved", + `harden:${preserved}`, + "mkdir-claim", + "claim", + "read-claimed", + "unlink-claimed", + "rmdir-claim", + ]); expect(existsSync(backup)).toBe(false); expect(readFileSync(preserved, "utf8")).toBe(v1); }); @@ -232,12 +256,14 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { harden: path => { hardened.push(path); }, truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, write: () => { throw new Error("verified hardened copy must not be overwritten"); }, - }))).toThrow(OpenAiTierRollbackPreserveError); + unlink: () => { throw new Error("claimed mismatch must not delete either snapshot"); }, + }))).toThrow(OpenAiTierRollbackPreserveClaimError); expect(readFileSync(backup, "utf8")).toBe(bytesB); const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); expect(preserved).toHaveLength(1); expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); - expect(hardened).toEqual([join(dir, preserved[0]!)]); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + expect(hardened.some(path => path.endsWith("claimed.bak"))).toBe(true); }); test("preserveOpenAiTierRollbackSnapshot removes an unverified copy when read(preserved) fails", () => { @@ -374,4 +400,30 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { } expect(readFileSync(backup, "utf8")).toBe(v1); }); + + test("preserveOpenAiTierRollbackSnapshot claims A and leaves a replacement B at the v2 path", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, bytesA); + const unlinks: string[] = []; + const preserved = preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + if (path === backup) throw new Error("replacement B must not be unlinked"); + unlinkSync(path); + }, + })); + expect(readFileSync(backup, "utf8")).toBe(bytesB); + expect(readFileSync(preserved, "utf8")).toBe(bytesA); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.endsWith("claimed.bak")).toBe(true); + expect(existsSync(unlinks[0]!)).toBe(false); + }); }); diff --git a/tests/openai-provider-option-startup.test.ts b/tests/openai-provider-option-startup.test.ts index 376f99e6b..ca6a6240a 100644 --- a/tests/openai-provider-option-startup.test.ts +++ b/tests/openai-provider-option-startup.test.ts @@ -5,9 +5,12 @@ import { copyFileSync, existsSync, linkSync, + mkdirSync, mkdtempSync, readFileSync, readdirSync, + renameSync, + rmdirSync, rmSync, truncateSync, unlinkSync, @@ -25,6 +28,7 @@ import { OpenAiTierBackupCollisionError, OpenAiTierBackupRollbackError, OpenAiTierBackupSecretResidualError, + OpenAiTierRollbackPreserveClaimError, OpenAiTierRollbackPreserveCleanupError, OpenAiTierRollbackPreserveError, OpenAiTierRollbackPreserveSecretResidualError, @@ -43,6 +47,37 @@ const config: OcxConfig = { providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, }; +function preserveIo( + backup: string, + overrides: Partial = {}, +): OpenAiTierRollbackPreserveIO { + return { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => {}, + truncate: path => { + if (path === backup) throw new Error("source truncate must not run"); + truncateSync(path, 0); + }, + write: (path, bytes) => { + if (path === backup) throw new Error("source write must not run"); + writeFileSync(path, bytes); + }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: path => { mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: path => { rmdirSync(path); }, + ...overrides, + }; +} + function virtualBackupIO(initial: Record, fail: { publish?: Error; tempUnlink?: number; @@ -677,15 +712,15 @@ describe("OpenAI provider option startup coordinator", () => { const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); writeFileSync(configPath, currentBytes); writeFileSync(v2Backup, rollbackBytes); - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, - read: path => readFileSync(path), + const failingIo = preserveIo(v2Backup, { copyExclusive: () => { throw new Error("copy failed"); }, harden: () => { throw new Error("harden must not run"); }, truncate: () => { throw new Error("truncate must not run"); }, write: () => { throw new Error("write must not run"); }, unlink: () => { throw new Error("unlink must not run"); }, - }; + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -733,9 +768,7 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(configPath, currentBytes); writeFileSync(v2Backup, rollbackBytes); const calls: string[] = []; - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, - read: path => readFileSync(path), + const failingIo = preserveIo(v2Backup, { copyExclusive: (source, destination) => { calls.push("copy"); copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); @@ -748,7 +781,9 @@ describe("OpenAI provider option startup coordinator", () => { if (path === v2Backup) throw new Error("source unlink must not run"); unlinkSync(path); }, - }; + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -784,9 +819,7 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(v2Backup, bytesA); const saves: number[] = []; const hardened: string[] = []; - const changingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, - read: path => readFileSync(path), + const changingIo = preserveIo(v2Backup, { copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); writeFileSync(source, bytesB); @@ -794,15 +827,15 @@ describe("OpenAI provider option startup coordinator", () => { harden: path => { hardened.push(path); }, truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, write: () => { throw new Error("verified hardened copy must not be overwritten"); }, - unlink: () => { throw new Error("source unlink must not run"); }, - }; + unlink: () => { throw new Error("claimed mismatch must not delete either snapshot"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, backup: () => backupConfigBeforeOpenAiTierMigration(configPath), preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, changingIo); }, save: () => { saves.push(1); }, - })).toThrow(OpenAiTierRollbackPreserveError); + })).toThrow(OpenAiTierRollbackPreserveClaimError); expect(saves).toEqual([]); expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); @@ -810,7 +843,7 @@ describe("OpenAI provider option startup coordinator", () => { const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); expect(preserved).toHaveLength(1); expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); - expect(hardened).toEqual([join(dir, preserved[0]!)]); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -832,22 +865,21 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(v2Backup, rollbackBytes); const unlinks: string[] = []; const saves: number[] = []; - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, + const failingIo = preserveIo(v2Backup, { read: path => { if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); return readFileSync(path); }, - copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: () => { throw new Error("harden must not run"); }, - truncate: path => truncateSync(path, 0), write: () => { throw new Error("write must not run"); }, unlink: path => { unlinks.push(path); if (path === v2Backup) throw new Error("source unlink must not run"); unlinkSync(path); }, - }; + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -883,21 +915,20 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(v2Backup, rollbackBytes); const saves: number[] = []; const leftoverHarden: string[] = []; - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, + const failingIo = preserveIo(v2Backup, { read: path => { if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); return readFileSync(path); }, - copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: path => { leftoverHarden.push(path); }, - truncate: path => truncateSync(path, 0), write: () => { throw new Error("write must not run"); }, unlink: path => { if (path === v2Backup) throw new Error("source unlink must not run"); throw new Error("unlink failed"); }, - }; + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -933,13 +964,11 @@ describe("OpenAI provider option startup coordinator", () => { writeFileSync(configPath, currentBytes); writeFileSync(v2Backup, rollbackBytes); const saves: number[] = []; - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, + const failingIo = preserveIo(v2Backup, { read: path => { if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); return readFileSync(path); }, - copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, harden: () => {}, truncate: () => { throw new Error("truncate failed"); }, write: () => { throw new Error("write failed"); }, @@ -947,7 +976,9 @@ describe("OpenAI provider option startup coordinator", () => { if (path === v2Backup) throw new Error("source unlink must not run"); throw new Error("unlink failed"); }, - }; + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -963,4 +994,59 @@ describe("OpenAI provider option startup coordinator", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("startup does not save when a replacement backup appears during preserve claim (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-claimrace-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const backups: string[] = []; + const saves: number[] = []; + const unlinks: string[] = []; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => { + backups.push(readFileSync(v2Backup, "utf8")); + backupConfigBeforeOpenAiTierMigration(configPath); + }, + preserveRollback: () => { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(v2Backup, { + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + if (path === v2Backup) throw new Error("replacement B must not be unlinked"); + unlinkSync(path); + }, + })); + }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierBackupCollisionError); + + expect(backups).toEqual([bytesA, bytesB]); + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.endsWith("claimed.bak")).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From ac24514d47932a33a3a7624e5cdd243647bd0731 Mon Sep 17 00:00:00 2001 From: LeoWang331 <134831918+LeoWang331@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:40:52 -0400 Subject: [PATCH 4/5] fix(config): recover claimed snapshots after claimed-read failure Harden and no-replace-restore independently when read(claimedPath) fails, then throw OpenAiTierRollbackPreserveClaimError with claimedPath so the leftover secret snapshot stays locatable. Co-authored-by: Cursor --- src/config.ts | 24 ++++--- tests/init-backup-cleanup.test.ts | 68 ++++++++++++++++++ tests/openai-provider-option-startup.test.ts | 72 ++++++++++++++++++++ 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/src/config.ts b/src/config.ts index dfc85e47d..65a553297 100644 --- a/src/config.ts +++ b/src/config.ts @@ -633,8 +633,12 @@ const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; * bytes → harden preserved → mkdir claim dir → rename source onto the unique * claim path → read the claimed inode → unlink only the claim. The original * backup path is never unlinked, so a replacement that appears after the claim - * stays. Pre-harden failures scrub the unverified destination and never claim - * the source. Shared by startup migration recovery and `ocx init` cleanup. + * stays. After a successful claim, a claimed-read or claimed-byte failure never + * unlinks the claimed path: it independently hardens that leftover, independently + * restores the original directory entry when vacant (EEXIST keeps a replacement), + * and throws `OpenAiTierRollbackPreserveClaimError` with `claimedPath`. Pre-harden + * failures scrub the unverified destination and never claim the source. Shared by + * startup migration recovery and `ocx init` cleanup. */ export function preserveOpenAiTierRollbackSnapshot( configPath = getConfigPath(), @@ -693,6 +697,12 @@ export function preserveOpenAiTierRollbackSnapshot( } }; + const failClaimedSnapshot = (claimedPath: string, cause?: unknown): never => { + try { io.harden(claimedPath); } catch { /* claimed leftover must remain inspectable; restore still runs */ } + try { restoreClaimedIfVacant(claimedPath); } catch { /* EEXIST keeps B; other restore failures must not hide claimedPath */ } + throw new OpenAiTierRollbackPreserveClaimError(claimedPath, cause === undefined ? undefined : { cause }); + }; + for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; try { @@ -746,17 +756,11 @@ export function preserveOpenAiTierRollbackSnapshot( try { return io.read(claimedPath); } catch (error) { - throw new OpenAiTierRollbackPreserveError("Failed to read claimed rollback backup", { cause: error, code: "changed" }); + return failClaimedSnapshot(claimedPath, error); } })(); if (!sameBytes(copied, claimedBytes)) { - try { io.harden(claimedPath); } catch { /* claimed leftover must remain inspectable */ } - try { - restoreClaimedIfVacant(claimedPath); - } catch (error) { - throw new OpenAiTierRollbackPreserveClaimError(claimedPath, { cause: error }); - } - throw new OpenAiTierRollbackPreserveClaimError(claimedPath); + failClaimedSnapshot(claimedPath); } try { diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index 2f91f6361..8c50cb24b 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -426,4 +426,72 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { expect(unlinks[0]!.endsWith("claimed.bak")).toBe(true); expect(existsSync(unlinks[0]!)).toBe(false); }); + + test("preserveOpenAiTierRollbackSnapshot restores a claimed snapshot when claimed-read fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(backup, bytesA); + const hardened: string[] = []; + const unlinks: string[] = []; + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { hardened.push(path); }, + unlink: path => { + unlinks.push(path); + throw new Error("claimed-read failure must not unlink"); + }, + })); + throw new Error("expected claimed-read failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = error as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect(claimed.cause).toBeInstanceOf(Error); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(hardened).toContain(claimed.claimedPath); + } + expect(readFileSync(backup, "utf8")).toBe(bytesA); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + expect(unlinks).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot keeps claimedPath when claimed harden and restore fail", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(backup, bytesA); + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { + if (path.endsWith("claimed.bak")) throw new Error("claimed harden failed"); + }, + linkExclusive: () => { throw new Error("restore failed"); }, + unlink: () => { throw new Error("claimed-read failure must not unlink"); }, + })); + throw new Error("expected claimed-read failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = error as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + } + }); }); diff --git a/tests/openai-provider-option-startup.test.ts b/tests/openai-provider-option-startup.test.ts index ca6a6240a..f8595dc01 100644 --- a/tests/openai-provider-option-startup.test.ts +++ b/tests/openai-provider-option-startup.test.ts @@ -1049,4 +1049,76 @@ describe("OpenAI provider option startup coordinator", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("startup does not save when claimed-read fails after a replacement backup appears (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-claimedread-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const backups: string[] = []; + const saves: number[] = []; + const hardened: string[] = []; + const unlinks: string[] = []; + + let thrown: unknown; + try { + runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => { + backups.push(readFileSync(v2Backup, "utf8")); + backupConfigBeforeOpenAiTierMigration(configPath); + }, + preserveRollback: () => { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(v2Backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { hardened.push(path); }, + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + throw new Error("claimed-read failure must not unlink"); + }, + })); + }, + save: () => { saves.push(1); }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = thrown as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(hardened).toContain(claimed.claimedPath); + expect(backups).toEqual([bytesA]); + expect(saves).toEqual([]); + expect(unlinks).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From b43ba8649c427be9dba5478d66e03b11acd1717f Mon Sep 17 00:00:00 2001 From: LeoWang331 <134831918+LeoWang331@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:06:28 -0400 Subject: [PATCH 5/5] test(config): pin vacant rollback path after restore failure Co-authored-by: Cursor --- tests/init-backup-cleanup.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index 8c50cb24b..54d4d1de7 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -492,6 +492,7 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { expect(existsSync(claimed.claimedPath)).toBe(true); expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(existsSync(backup)).toBe(false); } }); });