diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index afc72410c8..8aa1a6d28f 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -114,7 +114,7 @@ export function selectDriftHealCatalogPath( /** Repair only the missing config roots; catalog convergence remains the later tick step. */ async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number): Promise<"none" | "healed" | "not-healed"> { - const [{ codexConfigDrift }, { JOURNAL_PATH, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl }, { shouldSyncCodexOnStart }, { injectCodexConfig }, { DEFAULT_CATALOG_PATH, resolveCodexConfigPath }, { loadConfig }] = + const [{ codexConfigDrift }, { JOURNAL_PATH, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl }, { shouldSyncCodexOnStart }, { injectCodexConfig }, { DEFAULT_CATALOG_PATH, resolveCodexConfigPath }, { loadConfig }, { inspectNativeCodexOwnership }] = await Promise.all([ import("./config-drift-heal"), import("./journal"), @@ -122,6 +122,7 @@ async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number): import("./inject"), import("./paths"), import("../config"), + import("../integrations/native/ownership-preflight"), ]); const capturedSettings = JSON.stringify(config); const current = () => entryGeneration === generation && JSON.stringify(loadConfig()) === capturedSettings; @@ -142,11 +143,17 @@ async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number): // Keep the missing routing roots visible to the next tick. Catalog-only convergence below // may recreate the file now, but it does not re-inject config.toml in this tick. if (catalogPath === null) return "not-healed"; + // Ownership is probed directly, not through admission: admission can refuse on the + // config or generation authority before it ever inspects native ownership, and a + // veto that rides that result would let those refusals pass on a foreign home. + const serviceHomeOwned = () => inspectNativeCodexOwnership().ownership === "owned"; + if (!serviceHomeOwned()) return "not-healed"; await injectCodexConfig(runtime.port, config, { catalogPath, lockTimeoutMs: TICK_DEADLINE_MS, beforeClientWrite: () => { if (!current()) throw new Error("Catalog drift heal tick is stale"); + if (!serviceHomeOwned()) throw new Error("Catalog drift heal lost service-home ownership"); }, }).catch(() => null); if (!current()) return "none"; diff --git a/structure/config.md b/structure/config.md index 5391e7addf..1a9e580023 100644 --- a/structure/config.md +++ b/structure/config.md @@ -265,7 +265,7 @@ each app-managed rewrite writes `model_provider = "custom"` plus such a table, s injected root keys alongside), which re-injects cleanly because the injector strips a root `model_provider` line first. Recovery is drift detection (`src/codex/config-drift-heal.ts`): the auto-refresh tick re-injects the config when a root key the journal says was injected is missing on disk (presence only; a present key with another value is left alone). -The heal calls the injector directly (no provider discovery or catalog write), waits at most the tick's 1-second commit-lock deadline, and its `beforeClientWrite` guard refuses the write once the timer generation changed or the persisted config no longer matches the tick's snapshot; the catalog path is a bounded read-only lookup of the journal's `injectedCatalogPath` (a regular, parseable catalog) falling back to the default; with no usable catalog the heal is deferred to a later tick, and "healed" is reported only after the keys are observed on disk. +The heal calls the injector directly (no provider discovery or catalog write), but first requires the same positive service-home ownership used by unattended sync. It waits at most the tick's 1-second commit-lock deadline, and its `beforeClientWrite` guard rechecks that ownership and refuses the write once it is lost, the timer generation changed, or the persisted config no longer matches the tick's snapshot; the catalog path is a bounded read-only lookup of the journal's `injectedCatalogPath` (a regular, parseable catalog) falling back to the default; with no usable catalog the heal is deferred to a later tick, and "healed" is reported only after the keys are observed on disk. `ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore diff --git a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts index 0024f78a87..38953a3285 100644 --- a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts +++ b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts @@ -264,6 +264,7 @@ describe("catalog auto-refresh drift heal", () => { const desired = await import("../../src/codex/desired-state"); const processState = await import("../../src/config/process-state"); const inject = await import("../../src/codex/inject"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); writeCatalogAutoRefreshConfig({ enabled: true, intervalMinutes: 60 }); const fixture = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; fixture.apiKeys = [{ key: "fixture-key", name: "fixture", createdAt: "2026-01-01T00:00:00.000Z" }]; @@ -288,6 +289,7 @@ describe("catalog auto-refresh drift heal", () => { spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true), spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }), spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ ownership: "owned", reason: "fixture" }), spyOn(inject, "injectCodexConfig").mockImplementation((async (_port, _config, options) => { injectorCalls += 1; if (editBeforeWrite) { @@ -327,6 +329,7 @@ describe("catalog auto-refresh drift heal", () => { const processState = await import("../../src/config/process-state"); const sync = await import("../../src/codex/sync"); const inject = await import("../../src/codex/inject"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); const configPath = join(openCodexHome, "healed-config.toml"); const injected: Array<{ port: number; lockTimeoutMs: number | undefined }> = []; const info: string[] = []; @@ -337,6 +340,7 @@ describe("catalog auto-refresh drift heal", () => { configPath, )), spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ ownership: "owned", reason: "fixture" }), spyOn(sync, "syncModelsToCodex").mockImplementation((async () => { throw new Error("full sync must not run"); }) as never), spyOn(inject, "injectCodexConfig").mockImplementation((async (port, _config, options) => { options?.beforeClientWrite?.(); @@ -369,12 +373,104 @@ describe("catalog auto-refresh drift heal", () => { expect(ceded.info.some(line => line.includes("not re-injected this tick"))).toBe(true); }); + test("a foreign service home prevents the drift healer from reaching the injector", async () => { + const drift = await import("../../src/codex/config-drift-heal"); + const desired = await import("../../src/codex/desired-state"); + const processState = await import("../../src/config/process-state"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); + const inject = await import("../../src/codex/inject"); + const spies = [ + spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true), + spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }), + spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ + ownership: "foreign", + reason: "fixture foreign install", + }), + spyOn(inject, "injectCodexConfig"), + spyOn(console, "info").mockImplementation(() => {}), + ]; + try { + writeCatalogAutoRefreshConfig({ enabled: true, intervalMinutes: 60 }); + await runCatalogAutoRefreshTickForTests(); + expect(inject.injectCodexConfig).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + test.each(["config", "generation"] as const)("admission refused on %s does not bypass the ownership veto on a foreign home", async (authority) => { + const drift = await import("../../src/codex/config-drift-heal"); + const desired = await import("../../src/codex/desired-state"); + const processState = await import("../../src/config/process-state"); + const admission = await import("../../src/codex/admission"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); + const inject = await import("../../src/codex/inject"); + // A write guard that derives veto from the admission result would pass here: + // the refusal authority is not service-home. Ownership must veto on its own. + const spies = [ + spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true), + spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }), + spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + spyOn(admission, "admitCodexWrite").mockReturnValue({ + kind: "refused", + authority, + message: "fixture refusal", + }), + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ + ownership: "foreign", + reason: "fixture foreign install", + }), + spyOn(inject, "injectCodexConfig"), + spyOn(console, "info").mockImplementation(() => {}), + ]; + try { + writeCatalogAutoRefreshConfig({ enabled: true, intervalMinutes: 60 }); + await runCatalogAutoRefreshTickForTests(); + expect(inject.injectCodexConfig).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + test("service-home ownership is rechecked at the injector write boundary", async () => { + const drift = await import("../../src/codex/config-drift-heal"); + const desired = await import("../../src/codex/desired-state"); + const processState = await import("../../src/config/process-state"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); + const inject = await import("../../src/codex/inject"); + const own = spyOn(ownership, "inspectNativeCodexOwnership") + .mockReturnValueOnce({ ownership: "owned", reason: "fixture" }) + .mockReturnValue({ ownership: "foreign", reason: "fixture foreign install" }); + const injector = spyOn(inject, "injectCodexConfig").mockImplementation((async (_port, _config, options) => { + options?.beforeClientWrite?.(); + return { success: true, message: "fixture" }; + }) as typeof inject.injectCodexConfig); + const spies = [ + spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true), + spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }), + spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + own, + injector, + spyOn(console, "info").mockImplementation(() => {}), + ]; + try { + writeCatalogAutoRefreshConfig({ enabled: true, intervalMinutes: 60 }); + await runCatalogAutoRefreshTickForTests(); + expect(own).toHaveBeenCalledTimes(2); + expect(injector).toHaveBeenCalledTimes(1); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + test.each(["stop", "restart", "settings", "off"])("a deferred injector cannot write or log success after %s", async (change) => { const drift = await import("../../src/codex/config-drift-heal"); const desired = await import("../../src/codex/desired-state"); const processState = await import("../../src/config/process-state"); const sync = await import("../../src/codex/sync"); const inject = await import("../../src/codex/inject"); + const ownership = await import("../../src/integrations/native/ownership-preflight"); let entered!: () => void; const enteredInjector = new Promise(resolve => { entered = resolve; }); let release!: () => void; @@ -385,6 +481,7 @@ describe("catalog auto-refresh drift heal", () => { spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true), spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }), spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43_210 } as never), + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ ownership: "owned", reason: "fixture" }), spyOn(sync, "syncModelsToCodex").mockImplementation((async () => { entered(); await released; @@ -450,6 +547,8 @@ describe("catalog auto-refresh drift heal", () => { const syncPath = fileURLToPath(new URL("../../src/codex/sync.ts", import.meta.url)); const injectPath = fileURLToPath(new URL("../../src/codex/inject.ts", import.meta.url)); const configPath = fileURLToPath(new URL("../../src/config.ts", import.meta.url)); + const admissionPath = fileURLToPath(new URL("../../src/codex/admission.ts", import.meta.url)); + const ownershipPath = fileURLToPath(new URL("../../src/integrations/native/ownership-preflight.ts", import.meta.url)); const script = ` const { spyOn } = require("bun:test"); const fs = require("node:fs"); @@ -461,6 +560,8 @@ describe("catalog auto-refresh drift heal", () => { const processState = require(${JSON.stringify(processStatePath)}); const sync = require(${JSON.stringify(syncPath)}); const inject = require(${JSON.stringify(injectPath)}); + const admission = require(${JSON.stringify(admissionPath)}); + const ownership = require(${JSON.stringify(ownershipPath)}); const catalogPath = path.join(process.env.CODEX_HOME, "alternate-catalog.json"); fs.writeFileSync(catalogPath, JSON.stringify({ models: [{ slug: "alternate" }] })); fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model = "gpt-5"\\n'); @@ -469,6 +570,8 @@ describe("catalog auto-refresh drift heal", () => { spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true); spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }); spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43210 }); + spyOn(admission, "admitCodexWrite").mockReturnValue({ kind: "admitted" }); + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ ownership: "owned", reason: "fixture" }); spyOn(sync, "syncModelsToCodex").mockImplementation(async () => { throw new Error("full sync called"); }); let received = null; spyOn(inject, "injectCodexConfig").mockImplementation(async (_port, _config, options) => { received = options.catalogPath; options.beforeClientWrite(); return { success: true, message: "fixture" }; }); @@ -509,12 +612,16 @@ describe("catalog auto-refresh drift heal", () => { const processState = require(${JSON.stringify(source("config/process-state.ts"))}); const inject = require(${JSON.stringify(source("codex/inject.ts"))}); const management = require(${JSON.stringify(source("codex/management-convergence.ts"))}); + const admission = require(${JSON.stringify(source("codex/admission.ts"))}); + const ownership = require(${JSON.stringify(source("integrations/native/ownership-preflight.ts"))}); const catalog = path.join(process.env.CODEX_HOME, "opencodex-catalog.json"); fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model = "gpt-5"\\n'); fs.writeFileSync(config.getConfigPath(), JSON.stringify({ ...config.getDefaultConfig(), defaultProvider: "xai", providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" } }, catalogAutoRefresh: { enabled: true, intervalMinutes: 60 } })); spyOn(desired, "shouldSyncCodexOnStart").mockReturnValue(true); spyOn(drift, "codexConfigDrift").mockReturnValue({ drifted: true, missingKeys: ["openai_base_url"] }); spyOn(processState, "readRuntimePort").mockReturnValue({ pid: process.pid, port: 43210 }); + spyOn(admission, "admitCodexWrite").mockReturnValue({ kind: "admitted" }); + spyOn(ownership, "inspectNativeCodexOwnership").mockReturnValue({ ownership: "owned", reason: "fixture" }); const received = []; spyOn(inject, "injectCodexConfig").mockImplementation(async (_port, _config, options) => { received.push(options.catalogPath); return { success: true, message: "fixture" }; }); let converges = 0;