From ec46b2a90786c74c508beaf8cd2e7157c56c24e7 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:31:23 -0600 Subject: [PATCH 1/8] feat(config): add exclusive initialize-if-missing primitive --- scripts/test-layout/layout.json | 1 + src/config.ts | 138 ++++++++++++++++++ .../config-initialize-if-missing.test.ts | 68 +++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 208 insertions(+) create mode 100644 tests/config/config-initialize-if-missing.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fbe7cf746..0b47f511e4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -496,6 +496,7 @@ "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", "config-load-degrade.test.ts": "config", + "config-initialize-if-missing.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", diff --git a/src/config.ts b/src/config.ts index 728b3969ea..0a904409fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -106,9 +106,12 @@ import { } from "./lib/app-owned-memory"; import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; import { + AtomicWriteResidualTempError, + AtomicWriteSecretResidualError, atomicWriteFile, isMissingPathError, nextAtomicTempSequence, + resolveWriteTarget, } from "./config/atomic-write"; export { AtomicWriteResidualTempError, @@ -3011,6 +3014,141 @@ export function observeConfigGeneration(): ConfigGenerationObservation { return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME)); } +export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; + +export class PersistedConfigInitializationCleanupError extends Error { + constructor(options?: ErrorOptions) { + super("Initial config publication cleanup failed after rollback", options); + this.name = "PersistedConfigInitializationCleanupError"; + } +} + +export class PersistedConfigInitializationRollbackError extends Error { + constructor(options?: ErrorOptions) { + super("Initial config publication rollback failed", options); + this.name = "PersistedConfigInitializationRollbackError"; + } +} + +export interface PersistedConfigInitializationIO { + createExclusive(path: string): void; + write(path: string, bytes: string): void; + harden(path: string): void; + publishNoReplace(temp: string, target: string): void; + truncate(path: string): void; + unlink(path: string): void; +} + +let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null; + +export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { + persistedConfigInitializationBeforePublishForTests = hook; +} + +function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigInitializationIO): boolean { + const configPath = getConfigPath(); + const target = resolveWriteTarget(configPath); + assertNotRealHomeUnderTest(dirname(target)); + recordOwnedConfigPath(getConfigDir(), configPath); + const persisted = projectConfigRebaseProvenance(config); + const bytes = JSON.stringify(persisted, null, 2) + "\n"; + const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let staged = false; + let hardened = false; + let published = false; + let cleanupAttempted = false; + + const scrubUnpublishedTemp = (cause?: unknown): void => { + cleanupAttempted = true; + let scrubbed = false; + try { io.truncate(temp); scrubbed = true; } + catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ } } + } + let removed = false; + try { io.unlink(temp); removed = true; } + catch (error) { + if (isMissingPathError(error)) removed = true; + else { try { io.unlink(temp); removed = true; } catch (retryError) { if (isMissingPathError(retryError)) removed = true; } } + } + if (removed) forgetEphemeralSecretPath(temp); + if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(temp, { cause }); + if (!removed) throw new AtomicWriteResidualTempError(temp, hardened, { cause }); + }; + + try { + io.createExclusive(temp); staged = true; + io.write(temp, bytes); io.harden(temp); hardened = true; + const hook = persistedConfigInitializationBeforePublishForTests; + persistedConfigInitializationBeforePublishForTests = null; + hook?.(); + try { io.publishNoReplace(temp, target); } + catch (cause) { + if (!isAlreadyExistsError(cause)) throw cause; + scrubUnpublishedTemp(cause); + return false; + } + published = true; + try { io.unlink(temp); forgetEphemeralSecretPath(temp); } + catch (firstError) { + if (isMissingPathError(firstError)) forgetEphemeralSecretPath(temp); + else { + try { io.unlink(temp); forgetEphemeralSecretPath(temp); } + catch (secondError) { + if (isMissingPathError(secondError)) forgetEphemeralSecretPath(temp); + else { + try { io.unlink(target); } + catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } + published = false; + scrubUnpublishedTemp(secondError); + throw new PersistedConfigInitializationCleanupError({ cause: secondError }); + } + } + } + } + refreshUserCostOverlays(persisted); + return true; + } catch (cause) { + if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); + throw cause; + } +} + +function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO { + return { + createExclusive: target => writeFileSync(target, "", { flag: "wx", mode: 0o600 }), + write: (target, bytes) => writeFileSync(target, bytes), + harden: target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath }); + }, + publishNoReplace: (temp, target) => linkSync(temp, target), + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + }; +} + +export function initializePersistedConfigIfMissing( + config: OcxConfig, + io = defaultPersistedConfigInitializationIO(getConfigPath()), +): PersistedConfigInitializationOutcome { + assertNotRealHomeUnderTest(getConfigDir()); + return withConfigMutationLockSync(() => { + const snapshot = readConfigFileSnapshot(); + if (snapshot.diagnostics.source === "file") return "exists"; + if (snapshot.diagnostics.source !== "default") return "invalid"; + const projected = projectCustomModelCatalogMigration(readRawConfigJson(), projectConfigRebaseProvenance(config)); + if (!publishInitialConfigNoReplace(projected, io)) { + const winner = readConfigFileSnapshot(); + return winner.diagnostics.source === "file" ? "exists" : "invalid"; + } + bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, projected); + return "created"; + }); +} + /** * Read the generation from the transaction that is open RIGHT NOW. * diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts new file mode 100644 index 0000000000..31ffac59c2 --- /dev/null +++ b/tests/config/config-initialize-if-missing.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + getConfigPath, + initializePersistedConfigIfMissing, + loadConfig, + setPersistedConfigInitializationBeforePublishForTests, +} from "../../src/config"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let previous: string | undefined; +const config = (port = 10100): OcxConfig => ({ port, providers: {}, defaultProvider: "openai" }); + +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(import.meta.dir, ".tmp-config-initialize-")); + process.env.OPENCODEX_HOME = root; +}); + +afterEach(() => { + setPersistedConfigInitializationBeforePublishForTests(null); + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); +}); + +test("creates a missing config exclusively", () => { + expect(initializePersistedConfigIfMissing(config(12000))).toBe("created"); + expect(loadConfig().port).toBe(12000); + expect(initializePersistedConfigIfMissing(config(13000))).toBe("exists"); + expect(loadConfig().port).toBe(12000); +}); + +test("preserves valid and malformed existing bytes", () => { + const valid = '{"port": 14000, "providers": {}, "defaultProvider": "openai"}\n'; + writeFileSync(getConfigPath(), valid); + expect(initializePersistedConfigIfMissing(config(15000))).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(valid); + + const malformed = "not-json\n"; + writeFileSync(getConfigPath(), malformed); + expect(initializePersistedConfigIfMissing(config(16000))).toBe("invalid"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(malformed); +}); + +test("a competing creator wins and losing staged bytes are scrubbed", () => { + const winner = '{"port": 17000, "providers": {}, "defaultProvider": "openai"}\n'; + setPersistedConfigInitializationBeforePublishForTests(() => writeFileSync(getConfigPath(), winner)); + expect(initializePersistedConfigIfMissing(config(18000))).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winner); + expect(readdirSync(root).filter(name => name.includes(".ocx.") && name.endsWith(".tmp"))).toEqual([]); +}); + +test("surfaces publication cleanup failure", () => { + const io = { + createExclusive: (path: string) => writeFileSync(path, "", { flag: "wx" }), + write: (path: string, bytes: string) => writeFileSync(path, bytes), + harden: () => {}, + publishNoReplace: () => {}, + truncate: () => { throw new Error("truncate failed"); }, + unlink: (() => { throw new Error("unlink failed"); }) as (path: string) => void, + }; + expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(); + expect(existsSync(getConfigPath())).toBe(false); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index db2583b00b..5edcfb9707 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -331,6 +331,7 @@ "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", "config-load-degrade.test.ts": "config", + "config-initialize-if-missing.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", From e636e1659667ccf814aaecf93d9a857979ef35df Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:38:14 -0600 Subject: [PATCH 2/8] fix(config): verify staged initializer identity --- src/config.ts | 31 ++++++++++++++++--- .../config-initialize-if-missing.test.ts | 28 ++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0a904409fb..41b1cf93e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fchmodSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -3037,6 +3037,7 @@ export interface PersistedConfigInitializationIO { publishNoReplace(temp: string, target: string): void; truncate(path: string): void; unlink(path: string): void; + close?(): void; } let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null; @@ -3060,6 +3061,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni const scrubUnpublishedTemp = (cause?: unknown): void => { cleanupAttempted = true; + io.close?.(); let scrubbed = false; try { io.truncate(temp); scrubbed = true; } catch (error) { @@ -3116,16 +3118,35 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni } function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO { + let descriptor: number | undefined; return { - createExclusive: target => writeFileSync(target, "", { flag: "wx", mode: 0o600 }), - write: (target, bytes) => writeFileSync(target, bytes), + createExclusive: target => { descriptor = openSync(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); }, + write: (target, bytes) => { + if (descriptor === undefined) writeFileSync(target, bytes); + else writeFileSync(descriptor, bytes, { encoding: "utf-8" }); + }, harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + try { + if (descriptor === undefined) chmodSync(target, 0o600); + else if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + } catch { /* platform may ignore chmod */ } if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath }); }, - publishNoReplace: (temp, target) => linkSync(temp, target), + publishNoReplace: (temp, target) => { + if (descriptor !== undefined) { + const opened = fstatSync(descriptor); + const linked = lstatSync(temp); + if (opened.dev !== linked.dev || opened.ino !== linked.ino) { + closeSync(descriptor); descriptor = undefined; + throw new Error("atomic initialization temporary file identity changed before publication"); + } + closeSync(descriptor); descriptor = undefined; + } + linkSync(temp, target); + }, truncate: target => truncateSync(target, 0), unlink: unlinkSync, + close: () => { if (descriptor !== undefined) { closeSync(descriptor); descriptor = undefined; } }, }; } diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 31ffac59c2..f4320c5dfd 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { + AtomicWriteSecretResidualError, getConfigPath, initializePersistedConfigIfMissing, loadConfig, @@ -54,6 +55,18 @@ test("a competing creator wins and losing staged bytes are scrubbed", () => { expect(readdirSync(root).filter(name => name.includes(".ocx.") && name.endsWith(".tmp"))).toEqual([]); }); +test("rejects pathname replacement of the staged temporary file", () => { + setPersistedConfigInitializationBeforePublishForTests(() => { + const staged = readdirSync(root).find(name => name.endsWith(".tmp")); + if (!staged) throw new Error("staged temp not found"); + const path = join(root, staged); + unlinkSync(path); + writeFileSync(path, "attacker-bytes"); + }); + expect(() => initializePersistedConfigIfMissing(config(19000))).toThrow(/identity changed/); + expect(existsSync(getConfigPath())).toBe(false); +}); + test("surfaces publication cleanup failure", () => { const io = { createExclusive: (path: string) => writeFileSync(path, "", { flag: "wx" }), @@ -66,3 +79,16 @@ test("surfaces publication cleanup failure", () => { expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(); expect(existsSync(getConfigPath())).toBe(false); }); + +test("reports secret residual when staged bytes cannot be scrubbed or removed", () => { + let writes = 0; + const io = { + createExclusive: (path: string) => writeFileSync(path, "", { flag: "wx" }), + write: (path: string, bytes: string) => { writes += 1; if (writes > 1) throw new Error("write blocked"); writeFileSync(path, bytes); }, + harden: () => {}, + publishNoReplace: () => { throw Object.assign(new Error("race"), { code: "EEXIST" }); }, + truncate: () => { throw new Error("truncate blocked"); }, + unlink: (() => { throw new Error("unlink blocked"); }) as (path: string) => void, + }; + expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(AtomicWriteSecretResidualError); +}); From 783f16715d7e09eb14f33c4eaa0dc5ca85d56ce6 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:47:38 -0600 Subject: [PATCH 3/8] fix(config): verify initializer publication identity --- src/config.ts | 41 ++++++++++++++++++- .../config-initialize-if-missing.test.ts | 25 ++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 41b1cf93e0..f55f4cc1ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3030,6 +3030,14 @@ export class PersistedConfigInitializationRollbackError extends Error { } } +export class PersistedConfigInitializationHardLinkUnavailableError extends Error { + readonly code = "CONFIG_INITIALIZATION_HARDLINK_UNAVAILABLE"; + constructor(options?: ErrorOptions) { + super("Initial config publication requires hard-link support; no-replace semantics unavailable", options); + this.name = "PersistedConfigInitializationHardLinkUnavailableError"; + } +} + export interface PersistedConfigInitializationIO { createExclusive(path: string): void; write(path: string, bytes: string): void; @@ -3041,10 +3049,14 @@ export interface PersistedConfigInitializationIO { } let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null; +let persistedConfigInitializationAfterPublishForTests: (() => void) | null = null; export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { persistedConfigInitializationBeforePublishForTests = hook; } +export function setPersistedConfigInitializationAfterPublishForTests(hook: (() => void) | null): void { + persistedConfigInitializationAfterPublishForTests = hook; +} function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigInitializationIO): boolean { const configPath = getConfigPath(); @@ -3087,6 +3099,11 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni hook?.(); try { io.publishNoReplace(temp, target); } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) : ""; + if (code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM") { + throw new PersistedConfigInitializationHardLinkUnavailableError({ cause }); + } if (!isAlreadyExistsError(cause)) throw cause; scrubUnpublishedTemp(cause); return false; @@ -3140,9 +3157,29 @@ function defaultPersistedConfigInitializationIO(configPath: string): PersistedCo closeSync(descriptor); descriptor = undefined; throw new Error("atomic initialization temporary file identity changed before publication"); } - closeSync(descriptor); descriptor = undefined; } - linkSync(temp, target); + try { + linkSync(temp, target); + persistedConfigInitializationAfterPublishForTests?.(); + persistedConfigInitializationAfterPublishForTests = null; + if (descriptor !== undefined) { + const published = lstatSync(target); + const opened = fstatSync(descriptor); + if (opened.dev !== published.dev || opened.ino !== published.ino) { + try { unlinkSync(target); } catch { /* preserve the original identity failure */ } + throw new Error("atomic initialization published target identity changed"); + } + } + } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) : ""; + if (code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM") { + throw new PersistedConfigInitializationHardLinkUnavailableError({ cause }); + } + throw cause; + } finally { + if (descriptor !== undefined) { closeSync(descriptor); descriptor = undefined; } + } }, truncate: target => truncateSync(target, 0), unlink: unlinkSync, diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index f4320c5dfd..7bd0a7017d 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { AtomicWriteSecretResidualError, @@ -7,6 +7,7 @@ import { initializePersistedConfigIfMissing, loadConfig, setPersistedConfigInitializationBeforePublishForTests, + setPersistedConfigInitializationAfterPublishForTests, } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -23,6 +24,7 @@ beforeEach(() => { afterEach(() => { setPersistedConfigInitializationBeforePublishForTests(null); + setPersistedConfigInitializationAfterPublishForTests(null); if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; removeTreeWithRetry(root); @@ -92,3 +94,24 @@ test("reports secret residual when staged bytes cannot be scrubbed or removed", }; expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(AtomicWriteSecretResidualError); }); + +test("rejects a post-link target identity swap", () => { + setPersistedConfigInitializationAfterPublishForTests(() => { + const replacement = join(root, "replacement"); + writeFileSync(replacement, "other-bytes"); + renameSync(replacement, getConfigPath()); + }); + expect(() => initializePersistedConfigIfMissing(config(20000))).toThrow(/published target identity changed/); +}); + +test("classifies unavailable hard-link publication", () => { + const io = { + createExclusive: (path: string) => writeFileSync(path, "", { flag: "wx" }), + write: (path: string, bytes: string) => writeFileSync(path, bytes), + harden: () => {}, + publishNoReplace: () => { throw Object.assign(new Error("unsupported"), { code: "EOPNOTSUPP" }); }, + truncate: (path: string) => writeFileSync(path, ""), + unlink: unlinkSync, + }; + expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(/hard-link support/); +}); From 50d140c53cf4c12170daadef4183b0c64d23d9db Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:51:19 -0600 Subject: [PATCH 4/8] fix(config): guard initializer rollback cleanup --- src/config.ts | 17 +++++++++++++---- .../config/config-initialize-if-missing.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index f55f4cc1ea..6bd1a2e3b8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3069,6 +3069,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni let staged = false; let hardened = false; let published = false; + let publicationAttempted = false; let cleanupAttempted = false; const scrubUnpublishedTemp = (cause?: unknown): void => { @@ -3097,6 +3098,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni const hook = persistedConfigInitializationBeforePublishForTests; persistedConfigInitializationBeforePublishForTests = null; hook?.(); + publicationAttempted = true; try { io.publishNoReplace(temp, target); } catch (cause) { const code = cause && typeof cause === "object" && "code" in cause @@ -3117,8 +3119,16 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni catch (secondError) { if (isMissingPathError(secondError)) forgetEphemeralSecretPath(temp); else { - try { io.unlink(target); } - catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } + let samePublishedInode = false; + try { + const stagedStat = lstatSync(temp); + const targetStat = lstatSync(target); + samePublishedInode = stagedStat.dev === targetStat.dev && stagedStat.ino === targetStat.ino; + } catch { /* leave target untouched when identity cannot be proven */ } + if (samePublishedInode) { + try { io.unlink(target); } + catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } + } published = false; scrubUnpublishedTemp(secondError); throw new PersistedConfigInitializationCleanupError({ cause: secondError }); @@ -3129,7 +3139,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni refreshUserCostOverlays(persisted); return true; } catch (cause) { - if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); + if (staged && (!published || publicationAttempted) && !cleanupAttempted) scrubUnpublishedTemp(cause); throw cause; } } @@ -3166,7 +3176,6 @@ function defaultPersistedConfigInitializationIO(configPath: string): PersistedCo const published = lstatSync(target); const opened = fstatSync(descriptor); if (opened.dev !== published.dev || opened.ino !== published.ino) { - try { unlinkSync(target); } catch { /* preserve the original identity failure */ } throw new Error("atomic initialization published target identity changed"); } } diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 7bd0a7017d..2865c1112e 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -102,6 +102,8 @@ test("rejects a post-link target identity swap", () => { renameSync(replacement, getConfigPath()); }); expect(() => initializePersistedConfigIfMissing(config(20000))).toThrow(/published target identity changed/); + expect(readFileSync(getConfigPath(), "utf8")).toBe("other-bytes"); + expect(readdirSync(root).filter(name => name.endsWith(".tmp"))).toEqual([]); }); test("classifies unavailable hard-link publication", () => { @@ -115,3 +117,17 @@ test("classifies unavailable hard-link publication", () => { }; expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(/hard-link support/); }); + +test("does not remove a concurrent target during cleanup rollback", () => { + let temp = ""; + const io = { + createExclusive: (path: string) => { temp = path; writeFileSync(path, "", { flag: "wx" }); }, + write: (path: string, bytes: string) => writeFileSync(path, bytes), + harden: () => {}, + publishNoReplace: (_temp: string, target: string) => writeFileSync(target, "winner"), + truncate: () => {}, + unlink: (path: string) => { if (path === temp) throw new Error("temp unlink blocked"); unlinkSync(path); }, + }; + expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(); + expect(readFileSync(getConfigPath(), "utf8")).toBe("winner"); +}); From 55400b400824ac8822397112b23b7d469b560c7c Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:33:46 -0600 Subject: [PATCH 5/8] Fix config ownership claim on publication collision --- src/config.ts | 5 ++++- tests/config/config-initialize-if-missing.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 6bd1a2e3b8..d3e0f30ddf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3062,7 +3062,6 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni const configPath = getConfigPath(); const target = resolveWriteTarget(configPath); assertNotRealHomeUnderTest(dirname(target)); - recordOwnedConfigPath(getConfigDir(), configPath); const persisted = projectConfigRebaseProvenance(config); const bytes = JSON.stringify(persisted, null, 2) + "\n"; const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; @@ -3136,6 +3135,10 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni } } } + // Claim the config path only after no-replace publication and its identity + // checks have completed successfully. A losing initializer must not leave + // ownership metadata claiming a winner's file after an EEXIST collision. + recordOwnedConfigPath(getConfigDir(), configPath); refreshUserCostOverlays(persisted); return true; } catch (cause) { diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 2865c1112e..2577fe89dd 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { @@ -9,6 +10,7 @@ import { setPersistedConfigInitializationBeforePublishForTests, setPersistedConfigInitializationAfterPublishForTests, } from "../../src/config"; +import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST } from "../../src/lib/config-ownership"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -51,10 +53,17 @@ test("preserves valid and malformed existing bytes", () => { test("a competing creator wins and losing staged bytes are scrubbed", () => { const winner = '{"port": 17000, "providers": {}, "defaultProvider": "openai"}\n'; + // Seed valid ownership without the initial config path so this assertion can + // distinguish a losing claim from the manifest's default path set. + const ownerId = randomUUID(); + writeFileSync(join(root, CONFIG_OWNER_FILE), `${JSON.stringify({ version: 1, ownerId, root })}\n`); + writeFileSync(join(root, CONFIG_UNINSTALL_MANIFEST), `${JSON.stringify({ version: 1, ownerId, root, paths: [] })}\n`); setPersistedConfigInitializationBeforePublishForTests(() => writeFileSync(getConfigPath(), winner)); expect(initializePersistedConfigIfMissing(config(18000))).toBe("exists"); expect(readFileSync(getConfigPath(), "utf8")).toBe(winner); expect(readdirSync(root).filter(name => name.includes(".ocx.") && name.endsWith(".tmp"))).toEqual([]); + const manifest = JSON.parse(readFileSync(join(root, CONFIG_UNINSTALL_MANIFEST), "utf8")) as { paths: string[] }; + expect(manifest.paths).not.toContain("config.json"); }); test("rejects pathname replacement of the staged temporary file", () => { From e6cf10b25766a14ddcbed84dc336b6cbacff74f1 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 18:49:03 -0600 Subject: [PATCH 6/8] fix(config): harden initializer cleanup hooks --- src/config.ts | 7 ++- .../config-initialize-if-missing.test.ts | 43 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index d3e0f30ddf..91ca0ca52a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3125,6 +3125,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni samePublishedInode = stagedStat.dev === targetStat.dev && stagedStat.ino === targetStat.ino; } catch { /* leave target untouched when identity cannot be proven */ } if (samePublishedInode) { + cleanupAttempted = true; try { io.unlink(target); } catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } } @@ -3173,8 +3174,9 @@ function defaultPersistedConfigInitializationIO(configPath: string): PersistedCo } try { linkSync(temp, target); - persistedConfigInitializationAfterPublishForTests?.(); + const hook = persistedConfigInitializationAfterPublishForTests; persistedConfigInitializationAfterPublishForTests = null; + hook?.(); if (descriptor !== undefined) { const published = lstatSync(target); const opened = fstatSync(descriptor); @@ -3215,6 +3217,9 @@ export function initializePersistedConfigIfMissing( } bumpGenerationForCooperatingConfigWrite(); adoptCustomModelCatalogMigration(config, projected); + if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); return "created"; }); } diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 2577fe89dd..9d3a4a5c6c 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; +import { existsSync, linkSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { AtomicWriteSecretResidualError, getConfigPath, initializePersistedConfigIfMissing, loadConfig, + deleteConfigTopLevelKey, setPersistedConfigInitializationBeforePublishForTests, setPersistedConfigInitializationAfterPublishForTests, } from "../../src/config"; @@ -140,3 +141,43 @@ test("does not remove a concurrent target during cleanup rollback", () => { expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(); expect(readFileSync(getConfigPath(), "utf8")).toBe("winner"); }); + +test("does not scrub the published inode when rollback unlink fails", () => { + let temp = ""; + let truncateCalled = false; + let unlinkCalls = 0; + const io = { + createExclusive: (path: string) => { temp = path; writeFileSync(path, "", { flag: "wx" }); }, + write: (path: string, bytes: string) => writeFileSync(path, bytes), + harden: () => {}, + publishNoReplace: (staged: string, target: string) => linkSync(staged, target), + truncate: () => { truncateCalled = true; }, + unlink: (path: string) => { + unlinkCalls += 1; + if (path === temp) throw new Error("temp unlink blocked"); + throw new Error("rollback unlink blocked"); + }, + }; + expect(() => initializePersistedConfigIfMissing(config(), io)).toThrow(/rollback failed/); + expect(unlinkCalls).toBe(3); + expect(truncateCalled).toBe(false); + expect(readFileSync(getConfigPath(), "utf8")).toContain('"port": 10100'); +}); + +test("clears a throwing after-publish hook before the next publication", () => { + let calls = 0; + setPersistedConfigInitializationAfterPublishForTests(() => { calls += 1; throw new Error("after hook failed"); }); + expect(() => initializePersistedConfigIfMissing(config(21000))).toThrow("after hook failed"); + unlinkSync(getConfigPath()); + expect(initializePersistedConfigIfMissing(config(22000))).toBe("created"); + expect(calls).toBe(1); +}); + +test("synchronizes provenance and clears pending deletions after creation", () => { + const cfg = config(23000); + deleteConfigTopLevelKey(cfg, "hostname"); + expect(initializePersistedConfigIfMissing(cfg)).toBe("created"); + expect(cfg.configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["hostname"] }); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.configRebaseProvenance).toEqual(cfg.configRebaseProvenance); +}); From 11efd4cbfdbef3380c20454a20ece73a42bbf343 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sun, 6 Sep 2026 01:38:53 -0600 Subject: [PATCH 7/8] fix(config): scrub staged data through open descriptor Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/config.ts | 13 ++++++------ .../config-initialize-if-missing.test.ts | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/config.ts b/src/config.ts index 91ca0ca52a..c7a9e3ac32 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fchmodSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, linkSync, mkdirSync, openSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -3073,13 +3073,13 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni const scrubUnpublishedTemp = (cause?: unknown): void => { cleanupAttempted = true; - io.close?.(); let scrubbed = false; try { io.truncate(temp); scrubbed = true; } catch (error) { if (isMissingPathError(error)) scrubbed = true; else { try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ } } } + io.close?.(); let removed = false; try { io.unlink(temp); removed = true; } catch (error) { @@ -3110,6 +3110,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni return false; } published = true; + io.close?.(); try { io.unlink(temp); forgetEphemeralSecretPath(temp); } catch (firstError) { if (isMissingPathError(firstError)) forgetEphemeralSecretPath(temp); @@ -3168,7 +3169,6 @@ function defaultPersistedConfigInitializationIO(configPath: string): PersistedCo const opened = fstatSync(descriptor); const linked = lstatSync(temp); if (opened.dev !== linked.dev || opened.ino !== linked.ino) { - closeSync(descriptor); descriptor = undefined; throw new Error("atomic initialization temporary file identity changed before publication"); } } @@ -3191,11 +3191,12 @@ function defaultPersistedConfigInitializationIO(configPath: string): PersistedCo throw new PersistedConfigInitializationHardLinkUnavailableError({ cause }); } throw cause; - } finally { - if (descriptor !== undefined) { closeSync(descriptor); descriptor = undefined; } } }, - truncate: target => truncateSync(target, 0), + truncate: target => { + if (descriptor !== undefined) ftruncateSync(descriptor, 0); + else truncateSync(target, 0); + }, unlink: unlinkSync, close: () => { if (descriptor !== undefined) { closeSync(descriptor); descriptor = undefined; } }, }; diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 9d3a4a5c6c..955d8d738d 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { existsSync, linkSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; +import { existsSync, linkSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { AtomicWriteSecretResidualError, @@ -181,3 +181,21 @@ test("synchronizes provenance and clears pending deletions after creation", () = const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; expect(persisted.configRebaseProvenance).toEqual(cfg.configRebaseProvenance); }); + +test("replacement of the staged temporary file with a symlink does not truncate the symlink target", () => { + const victimPath = join(root, "victim-file"); + const victimContent = "critical user data that must not be truncated"; + writeFileSync(victimPath, victimContent); + + setPersistedConfigInitializationBeforePublishForTests(() => { + const staged = readdirSync(root).find(name => name.endsWith(".tmp")); + if (!staged) throw new Error("staged temp not found"); + const path = join(root, staged); + unlinkSync(path); + symlinkSync(victimPath, path, "file"); + }); + + expect(() => initializePersistedConfigIfMissing(config(24000))).toThrow(/identity changed/); + expect(existsSync(getConfigPath())).toBe(false); + expect(readFileSync(victimPath, "utf8")).toBe(victimContent); +}); From 830b09b64fc8a1b74147477950e374d960fc7090 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sun, 6 Sep 2026 01:59:56 -0600 Subject: [PATCH 8/8] fix(config): avoid untrusted post-publish scrub paths Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/config.ts | 27 +++++++++++-------- .../config-initialize-if-missing.test.ts | 26 ++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index c7a9e3ac32..e4bca9c8ad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3068,7 +3068,6 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni let staged = false; let hardened = false; let published = false; - let publicationAttempted = false; let cleanupAttempted = false; const scrubUnpublishedTemp = (cause?: unknown): void => { @@ -3097,7 +3096,6 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni const hook = persistedConfigInitializationBeforePublishForTests; persistedConfigInitializationBeforePublishForTests = null; hook?.(); - publicationAttempted = true; try { io.publishNoReplace(temp, target); } catch (cause) { const code = cause && typeof cause === "object" && "code" in cause @@ -3110,14 +3108,13 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni return false; } published = true; - io.close?.(); - try { io.unlink(temp); forgetEphemeralSecretPath(temp); } + try { io.unlink(temp); io.close?.(); forgetEphemeralSecretPath(temp); } catch (firstError) { - if (isMissingPathError(firstError)) forgetEphemeralSecretPath(temp); + if (isMissingPathError(firstError)) { io.close?.(); forgetEphemeralSecretPath(temp); } else { - try { io.unlink(temp); forgetEphemeralSecretPath(temp); } + try { io.unlink(temp); io.close?.(); forgetEphemeralSecretPath(temp); } catch (secondError) { - if (isMissingPathError(secondError)) forgetEphemeralSecretPath(temp); + if (isMissingPathError(secondError)) { io.close?.(); forgetEphemeralSecretPath(temp); } else { let samePublishedInode = false; try { @@ -3128,10 +3125,18 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni if (samePublishedInode) { cleanupAttempted = true; try { io.unlink(target); } - catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } + catch (cause) { + io.close?.(); + throw new PersistedConfigInitializationRollbackError({ cause }); + } + published = false; + scrubUnpublishedTemp(secondError); + } else { + // Publication succeeded, but the temporary pathname no longer + // identifies its inode. Do not follow it for scrubbing. + cleanupAttempted = true; + io.close?.(); } - published = false; - scrubUnpublishedTemp(secondError); throw new PersistedConfigInitializationCleanupError({ cause: secondError }); } } @@ -3144,7 +3149,7 @@ function publishInitialConfigNoReplace(config: OcxConfig, io: PersistedConfigIni refreshUserCostOverlays(persisted); return true; } catch (cause) { - if (staged && (!published || publicationAttempted) && !cleanupAttempted) scrubUnpublishedTemp(cause); + if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); throw cause; } } diff --git a/tests/config/config-initialize-if-missing.test.ts b/tests/config/config-initialize-if-missing.test.ts index 955d8d738d..81755ed947 100644 --- a/tests/config/config-initialize-if-missing.test.ts +++ b/tests/config/config-initialize-if-missing.test.ts @@ -199,3 +199,29 @@ test("replacement of the staged temporary file with a symlink does not truncate expect(existsSync(getConfigPath())).toBe(false); expect(readFileSync(victimPath, "utf8")).toBe(victimContent); }); + +test("post-publication cleanup never scrubs a replaced temporary pathname", () => { + const victimPath = join(root, "post-publish-victim"); + const victimContent = "critical post-publication data"; + writeFileSync(victimPath, victimContent); + let temp = ""; + const io = { + createExclusive: (path: string) => { temp = path; writeFileSync(path, "", { flag: "wx" }); }, + write: (path: string, bytes: string) => writeFileSync(path, bytes), + harden: () => {}, + publishNoReplace: (staged: string, target: string) => { + linkSync(staged, target); + unlinkSync(staged); + symlinkSync(victimPath, staged, "file"); + }, + truncate: (path: string) => writeFileSync(path, ""), + unlink: (path: string) => { + if (path === temp) throw new Error("temp unlink blocked"); + unlinkSync(path); + }, + }; + + expect(() => initializePersistedConfigIfMissing(config(25000), io)).toThrow(/cleanup failed/); + expect(readFileSync(getConfigPath(), "utf8")).toContain('"port": 25000'); + expect(readFileSync(victimPath, "utf8")).toBe(victimContent); +});