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..e4bca9c8ad 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, 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"; @@ -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,222 @@ 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 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; + harden(path: string): void; + publishNoReplace(temp: string, target: string): void; + truncate(path: string): void; + unlink(path: string): void; + close?(): void; +} + +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(); + const target = resolveWriteTarget(configPath); + assertNotRealHomeUnderTest(dirname(target)); + 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 */ } } + } + io.close?.(); + 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) { + 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; + } + published = true; + try { io.unlink(temp); io.close?.(); forgetEphemeralSecretPath(temp); } + catch (firstError) { + if (isMissingPathError(firstError)) { io.close?.(); forgetEphemeralSecretPath(temp); } + else { + try { io.unlink(temp); io.close?.(); forgetEphemeralSecretPath(temp); } + catch (secondError) { + if (isMissingPathError(secondError)) { io.close?.(); forgetEphemeralSecretPath(temp); } + else { + 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) { + cleanupAttempted = true; + try { io.unlink(target); } + 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?.(); + } + throw new PersistedConfigInitializationCleanupError({ cause: secondError }); + } + } + } + } + // 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) { + if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); + throw cause; + } +} + +function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO { + let descriptor: number | undefined; + return { + 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 { + 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) => { + if (descriptor !== undefined) { + const opened = fstatSync(descriptor); + const linked = lstatSync(temp); + if (opened.dev !== linked.dev || opened.ino !== linked.ino) { + throw new Error("atomic initialization temporary file identity changed before publication"); + } + } + try { + linkSync(temp, target); + const hook = persistedConfigInitializationAfterPublishForTests; + persistedConfigInitializationAfterPublishForTests = null; + hook?.(); + if (descriptor !== undefined) { + const published = lstatSync(target); + const opened = fstatSync(descriptor); + if (opened.dev !== published.dev || opened.ino !== published.ino) { + 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; + } + }, + truncate: target => { + if (descriptor !== undefined) ftruncateSync(descriptor, 0); + else truncateSync(target, 0); + }, + unlink: unlinkSync, + close: () => { if (descriptor !== undefined) { closeSync(descriptor); descriptor = undefined; } }, + }; +} + +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); + if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); + 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..81755ed947 --- /dev/null +++ b/tests/config/config-initialize-if-missing.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, unlinkSync, writeFileSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { + AtomicWriteSecretResidualError, + getConfigPath, + initializePersistedConfigIfMissing, + loadConfig, + deleteConfigTopLevelKey, + 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"; + +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); + setPersistedConfigInitializationAfterPublishForTests(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'; + // 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", () => { + 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" }), + 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); +}); + +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); +}); + +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/); + expect(readFileSync(getConfigPath(), "utf8")).toBe("other-bytes"); + expect(readdirSync(root).filter(name => name.endsWith(".tmp"))).toEqual([]); +}); + +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/); +}); + +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"); +}); + +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); +}); + +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); +}); + +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); +}); 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",