From ad85a80fed4744ce14bb2b6313267acb4acf29e7 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:49:16 +0900 Subject: [PATCH 01/10] fix(cli): prove cross-home ownership before deferring to a hinted port --- src/cli/cross-home-owner.ts | 24 ++++++-- structure/codex-home.md | 2 +- tests/cli/sibling-home-client-sync.test.ts | 65 ++++++++++++++++++---- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 78555e3af25..29050ff6d1b 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -13,7 +13,9 @@ import { markSiblingStart, siblingOfLivePort } from "../codex/sibling-start"; import { readClientConnectionState } from "../client/state"; import { findManagedRegion, resolveGrokHome } from "../grok/inject"; import { providerTableString } from "../codex/injected-marker"; -import { probePortOwner, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; +import { isLocalAttestationSecret } from "../lib/local-management-attestation"; +import { probePortOwner, proveLiveProxyOwnedByHome, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; +import type { RuntimePortState } from "../config/process-state"; const MAX_HINT_BYTES = 256 * 1024; // Far above any real Grok config; discovery must not stall startup when Grok sync is off. @@ -62,14 +64,21 @@ function loopbackPort(raw: string | null): number | null { /** Returns only a different process with an identity-checked /healthz response. */ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Promise { const candidates = new Set(); + let defaultRuntime: RuntimePortState | null = null; const defaultHome = join(options.homeDir ?? homedir(), ".opencodex"); if (resolve(getConfigDir()) !== resolve(defaultHome)) { const raw = readBoundedRegularFile(join(defaultHome, "runtime-port.json"), MAX_HINT_BYTES); if (raw) { try { const record: unknown = JSON.parse(raw); - if (record && typeof record === "object" && validPort((record as { port?: unknown }).port)) { - candidates.add((record as { port: number }).port); + if (record && typeof record === "object") { + const state = record as Record; + if (Number.isSafeInteger(state.pid) && Number(state.pid) > 0 && validPort(state.port) + && isLocalAttestationSecret(state.attestationSecret) + && (state.hostname === undefined || typeof state.hostname === "string")) { + defaultRuntime = state as RuntimePortState; + candidates.add(defaultRuntime.port); + } } } catch { /* stale or malformed hint */ } } @@ -97,8 +106,15 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr } catch { /* an absent or invalid client home is not owner evidence */ } for (const port of candidates) { + // A managed client URL is only a location hint. The default home's protected runtime + // record supplies the identity and proof key that make it ownership evidence. + if (!defaultRuntime || defaultRuntime.port !== port || defaultRuntime.pid === process.pid) continue; const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS); - if (owner && Number.isSafeInteger(owner.pid) && owner.pid! > 0 && owner.pid !== process.pid) return port; + if (owner?.pid !== defaultRuntime.pid) continue; + if (await proveLiveProxyOwnedByHome( + { ...owner, port, source: "runtime" }, + { readRuntimeFn: () => defaultRuntime }, + )) return port; } return null; } diff --git a/structure/codex-home.md b/structure/codex-home.md index e396045da40..98aa67b3642 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -155,7 +155,7 @@ A sibling instance is `ocx start --port ` while a live proxy serves the c lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and the launchd domain with the live owner. `handleStart` marks the process through `src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's -lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's runtime record only for a custom home, plus managed Grok and Codex loopback URLs. It accepts only an identity-checked positive PID different from this process; a sole custom-home start still syncs. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record only for a custom home, plus managed Grok and Codex loopback URLs as location hints. It accepts a different process only when the listener's PID matches that record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason `sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index 163861d20f6..04a8cbfe8d2 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -5,6 +5,11 @@ import { join } from "node:path"; import { findCrossHomeOwner, markLiveHomeSibling } from "../../src/cli/cross-home-owner"; import { resetSiblingStartForTests, siblingOfLivePort } from "../../src/codex/sibling-start"; import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../../src/lib/local-management-attestation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -13,6 +18,7 @@ const roots: string[] = []; const servers: Array> = []; const children: Array> = []; const detachedPids: number[] = []; +const TEST_ATTESTATION_SECRET = "A".repeat(43); function fixture() { const root = mkdtempSync(join(tmpdir(), "ocx-cross-home-")); @@ -33,14 +39,30 @@ function fixture() { } function healthServer(pid: number | null, service = "opencodex") { + let port = 0; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, - fetch: () => Response.json({ service, status: "ok", version: "0.0.0", uptime: 1, pid }), + fetch: req => { + const headers = new Headers(); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + const proof = challenge && pid !== null + ? createLocalAttestationProof(TEST_ATTESTATION_SECRET, challenge, pid, port) + : null; + if (proof) headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + return Response.json({ service, status: "ok", version: "0.0.0", uptime: 1, pid }, { headers }); + }, }); + port = server.port; servers.push(server); return server.port; } +function defaultRuntime(fx: ReturnType, pid: number, port: number) { + writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ + pid, port, attestationSecret: TEST_ATTESTATION_SECRET, + })); +} + function grokFence(port: number | string) { return `# user content\n# >>> opencodex managed block — do not edit (removed by \`ocx stop\`) >>>\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:${port}/v1"\n# <<< opencodex managed block <<<\n`; } @@ -116,19 +138,22 @@ test("cross-home discovery marks only a live other-process owner", async () => { }; expect(await probe()).toEqual({ marked: false, port: null }); const ownerPort = healthServer(process.pid); + defaultRuntime(fx, process.pid, ownerPort); writeFileSync(path, grokFence(ownerPort)); expect(await probe()).toEqual({ marked: true, port: ownerPort }); }); -test("large managed Grok and Codex configs still reveal their owner", async () => { +test("large managed configs do not hide an attested default-home owner", async () => { const fx = fixture(); - const port = healthServer(process.pid + 1); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + defaultRuntime(fx, ownerPid, port); const grokPath = join(fx.grok, "config.toml"); const codexPath = join(fx.codex, "config.toml"); writeFileSync(grokPath, `${"# padding\n".repeat(30_000)}${grokFence(port)}`); expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); writeFileSync(grokPath, `${grokFence(port)}${"#".repeat(16 * 1024 * 1024)}`); - expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); writeFileSync(grokPath, "# no managed fence\n"); writeFileSync(codexPath, `${"# padding\n".repeat(30_000)}${codexRouting(port)}`); expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); @@ -136,7 +161,9 @@ test("large managed Grok and Codex configs still reveal their owner", async () = test("Design B marker-owned root routing reveals the owner port", async () => { const fx = fixture(); - const port = healthServer(process.pid + 1); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + defaultRuntime(fx, ownerPid, port); writeFileSync(join(fx.codex, "config.toml"), [ OCX_ROUTING_MARKER_LINE, `openai_base_url = "http://127.0.0.1:${port}/v1"`, @@ -164,10 +191,10 @@ test("only a distinct live identity in the default-home record counts", async () const fx = fixture(); const port = healthServer(process.pid + 1); const record = join(fx.home, ".opencodex", "runtime-port.json"); - writeFileSync(record, JSON.stringify({ pid: process.pid + 1, port })); + writeFileSync(record, JSON.stringify({ pid: process.pid + 1, port, attestationSecret: TEST_ATTESTATION_SECRET })); expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); - writeFileSync(record, JSON.stringify({ pid: process.pid, port })); - expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); // the responder, not a stale record, owns the port + writeFileSync(record, JSON.stringify({ pid: process.pid, port, attestationSecret: TEST_ATTESTATION_SECRET })); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); }); test.skipIf(process.platform === "win32")("a FIFO in place of a hint file cannot stall discovery", async () => { @@ -180,9 +207,11 @@ test.skipIf(process.platform === "win32")("a FIFO in place of a hint file cannot expect(performance.now() - started).toBeLessThan(2_000); }, 5_000); -test("managed Grok and Codex hints accept only a different positive PID", async () => { +test("malformed managed hints do not override an attested default-home owner", async () => { const fx = fixture(); - const port = healthServer(process.pid + 1); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + defaultRuntime(fx, ownerPid, port); const grokPath = join(fx.grok, "config.toml"); const codexPath = join(fx.codex, "config.toml"); writeFileSync(grokPath, grokFence(port)); @@ -191,7 +220,7 @@ test("managed Grok and Codex hints accept only a different positive PID", async writeFileSync(codexPath, codexRouting(port)); expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); writeFileSync(codexPath, codexRouting("invalid")); - expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); }); test("same PID, null PID, foreign, stale and remote hints grant no sibling ownership", async () => { @@ -218,6 +247,17 @@ test("same PID, null PID, foreign, stale and remote hints grant no sibling owner expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); }); +test("a forged health identity without the default home's attestation grants no ownership", async () => { + const fx = fixture(); + const forgedPid = 1_000_000_000; + const port = healthServer(forgedPid); + writeFileSync(join(fx.grok, "config.toml"), grokFence(port)); + writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ + pid: forgedPid, port, attestationSecret: "B".repeat(43), + })); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); +}); + test("a secondary start preserves shared client bytes and records the sibling owner", async () => { const fx = fixture(); const fakeOwnerPid = 1_000_000_000; @@ -225,7 +265,7 @@ test("a secondary start preserves shared client bytes and records the sibling ow const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); const secondaryPort = reservation.port; reservation.stop(true); - writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ pid: fakeOwnerPid, port: ownerPort })); + defaultRuntime(fx, fakeOwnerPid, ownerPort); const grokPath = join(fx.grok, "config.toml"); const codexPath = join(fx.codex, "config.toml"); const claudePath = join(fx.claude, "agents", "ocx-existing.md"); @@ -254,6 +294,7 @@ test("a secondary start preserves shared client bytes and records the sibling ow test("a secondary ensure parent preserves shared Grok, Codex and Claude agent bytes", async () => { const fx = fixture(); const ownerPort = healthServer(process.pid); + defaultRuntime(fx, process.pid, ownerPort); const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); const secondaryPort = reservation.port; reservation.stop(true); From 6299fc077826cb6b49b6c1872f3f5444928e63bc Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:35:16 +0900 Subject: [PATCH 02/10] fix(cli): probe every loopback family for the recorded home owner --- src/cli/cross-home-owner.ts | 20 ++++++---- tests/cli/sibling-home-client-sync.test.ts | 44 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 29050ff6d1b..84353d9406e 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -14,7 +14,7 @@ import { readClientConnectionState } from "../client/state"; import { findManagedRegion, resolveGrokHome } from "../grok/inject"; import { providerTableString } from "../codex/injected-marker"; import { isLocalAttestationSecret } from "../lib/local-management-attestation"; -import { probePortOwner, proveLiveProxyOwnedByHome, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; +import { loopbackProbeHosts, proveLiveProxyOwnedByHome, proxyIdentityAt, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; import type { RuntimePortState } from "../config/process-state"; const MAX_HINT_BYTES = 256 * 1024; @@ -109,12 +109,18 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr // A managed client URL is only a location hint. The default home's protected runtime // record supplies the identity and proof key that make it ownership evidence. if (!defaultRuntime || defaultRuntime.port !== port || defaultRuntime.pid === process.pid) continue; - const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS); - if (owner?.pid !== defaultRuntime.pid) continue; - if (await proveLiveProxyOwnedByHome( - { ...owner, port, source: "runtime" }, - { readRuntimeFn: () => defaultRuntime }, - )) return port; + // IPv4 and IPv6 loopback listeners are independent on this port, so a pid mismatch + // (or a dead answer) on one family does not prove the recorded owner absent. The + // recorded hostname is tried first and every loopback candidate gets an identity + // and attestation check before the port reports no owner. + for (const hostname of loopbackProbeHosts(defaultRuntime.hostname)) { + const identity = await proxyIdentityAt(port, { hostname, expectedPid: defaultRuntime.pid }, START_OWNERSHIP_LIVENESS); + if (identity?.pid !== defaultRuntime.pid) continue; + if (await proveLiveProxyOwnedByHome( + { ...identity, hostname, port, source: "runtime" }, + { readRuntimeFn: () => defaultRuntime }, + )) return port; + } } return null; } diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index 04a8cbfe8d2..e989f759e2a 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -38,10 +38,10 @@ function fixture() { return { root, home, ocx, codex, grok, claude }; } -function healthServer(pid: number | null, service = "opencodex") { +function healthServer(pid: number | null, service = "opencodex", listen: { hostname?: string; port?: number } = {}) { let port = 0; const server = Bun.serve({ - hostname: "127.0.0.1", port: 0, + hostname: listen.hostname ?? "127.0.0.1", port: listen.port ?? 0, fetch: req => { const headers = new Headers(); const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); @@ -197,6 +197,46 @@ test("only a distinct live identity in the default-home record counts", async () expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); }); +test("the recorded ::1 owner is found beside an IPv4 listener on the same port", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + let v6: ReturnType; + try { + v6 = Bun.serve({ + hostname: "::1", port: 0, + fetch: req => { + const headers = new Headers(); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + const proof = challenge + ? createLocalAttestationProof(TEST_ATTESTATION_SECRET, challenge, ownerPid, v6.port) + : null; + if (proof) headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + return Response.json({ service: "opencodex", status: "ok", version: "0.0.0", uptime: 1, pid: ownerPid }, { headers }); + }, + }); + } catch { + return; // IPv6 loopback is unavailable on this host. + } + servers.push(v6); + const port = v6.port; + // A different opencodex-looking process holds only the IPv4 loopback of the same + // port. Its pid mismatch must not mask the recorded ::1 owner. + try { + healthServer(ownerPid + 1, "opencodex", { hostname: "127.0.0.1", port }); + } catch { /* the IPv6 bind is dual-stack on this host; the owner still answers */ } + const record = join(fx.home, ".opencodex", "runtime-port.json"); + const writeRecord = (hostname?: string) => writeFileSync(record, JSON.stringify({ + pid: ownerPid, port, attestationSecret: TEST_ATTESTATION_SECRET, + ...(hostname === undefined ? {} : { hostname }), + })); + writeRecord("::1"); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); + // A record without a hostname keeps trying every loopback family instead of + // stopping at the first IPv4 answer. + writeRecord(); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); +}); + test.skipIf(process.platform === "win32")("a FIFO in place of a hint file cannot stall discovery", async () => { const fx = fixture(); const record = join(fx.home, ".opencodex", "runtime-port.json"); From 89be657cb413eaa63a5223cc6ce4cb65488d985e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:34:27 +0900 Subject: [PATCH 03/10] fix(cli): retry transient fenced-identity attestation failures (#6198) --- src/cli/cross-home-owner.ts | 2 +- src/server/proxy-liveness.ts | 40 +++++++++++----- .../proxy-liveness-package-tree-fence.test.ts | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 84353d9406e..cdcf254bd02 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -118,7 +118,7 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr if (identity?.pid !== defaultRuntime.pid) continue; if (await proveLiveProxyOwnedByHome( { ...identity, hostname, port, source: "runtime" }, - { readRuntimeFn: () => defaultRuntime }, + { ...START_OWNERSHIP_LIVENESS, readRuntimeFn: () => defaultRuntime }, )) return port; } } diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index a7b027b736e..eecb6ce1cf6 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -270,19 +270,35 @@ async function attestFencedIdentity( const secret: unknown = record ? Reflect.get(record, "attestationSecret") : undefined; if (!record || record.pid !== pid || record.port !== port || typeof secret !== "string") return false; const challenge = (io.createChallengeFn ?? createLocalAttestationChallenge)(); - try { - const res = await fetchFn(url, { - headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, - signal: AbortSignal.timeout(timeoutMs), - }); - const body = (await res.json().catch(() => null)) as HealthzIdentity | null; - // The second answer must still be the same fenced (or by now healthy) process. - if (!isOpencodexHealthz(body) && !isPackageTreeFencedHealthz(body)) return false; - if (body?.pid !== pid) return false; - return verifyLocalAttestationProof(secret, challenge, pid, port, res.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)); - } catch { - return false; + // One proof failure is definitive and never retried; a transport failure only means + // the listener did not answer yet, so it gets the same bounded retry the identity + // probe uses ??"did not answer" is not "not ours" (#6198). The challenge is minted + // once: a retried attempt proves the same fresh nonce, not a replayed proof. + const sleepFn = io.sleepFn ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); + const nowFn = io.nowFn ?? Date.now; + const requestedAttempts = Math.trunc(io.attempts ?? 1); + const attempts = Number.isNaN(requestedAttempts) + ? 1 + : Math.max(1, Math.min(requestedAttempts, 5)); + for (let attempt = 1; attempt <= attempts; attempt++) { + const remainingMs = io.deadlineAt === undefined ? timeoutMs : Math.min(timeoutMs, io.deadlineAt - nowFn()); + if (remainingMs <= 0) return false; + try { + const res = await fetchFn(url, { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + signal: AbortSignal.timeout(remainingMs), + }); + const body = (await res.json().catch(() => null)) as HealthzIdentity | null; + // The second answer must still be the same fenced (or by now healthy) process. + if (!isOpencodexHealthz(body) && !isPackageTreeFencedHealthz(body)) return false; + if (body?.pid !== pid) return false; + return verifyLocalAttestationProof(secret, challenge, pid, port, res.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)); + } catch { + if (attempt >= attempts) return false; + await sleepFn(100); + } } + return false; } /** A bounded version string safe to carry beyond the untrusted health response. */ diff --git a/tests/server/proxy-liveness-package-tree-fence.test.ts b/tests/server/proxy-liveness-package-tree-fence.test.ts index db35375acf8..49b13de4962 100644 --- a/tests/server/proxy-liveness-package-tree-fence.test.ts +++ b/tests/server/proxy-liveness-package-tree-fence.test.ts @@ -156,3 +156,51 @@ describe("package-tree fenced liveness (#5496)", () => { } }); }); + +describe("fenced-identity transport retries (#6198)", () => { + test("a transient fetch failure retries the challenge instead of reporting no owner", async () => { + const secret = createLocalAttestationSecret(); + const listener = fencedListener(secret, fencedBody(), 200); + let calls = 0; + const flakyFetch = (async (input: string | URL | Request, init?: RequestInit) => { + calls += 1; + if (calls < 3) throw new TypeError("fetch failed"); + return listener.fetchFn(input, init); + }) as typeof fetch; + const io = ownedIo(secret, flakyFetch, { attempts: 3, sleepFn: () => Promise.resolve() }); + const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; + expect(await proveLiveProxyOwnedByHome(live, io)).toBe(true); + expect(calls).toBe(3); + }); + + test("the owner stays absent only after the bounded attempts run out", async () => { + const secret = createLocalAttestationSecret(); + const deadFetch = (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch; + const io = ownedIo(secret, deadFetch, { attempts: 3, sleepFn: () => Promise.resolve() }); + const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; + expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + }); + + test("a definitive proof failure is not retried", async () => { + const listener = fencedListener(createLocalAttestationSecret(), fencedBody(), 200); + const io = ownedIo(createLocalAttestationSecret(), listener.fetchFn, { attempts: 5, sleepFn: () => Promise.resolve() }); + const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; + expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + expect(listener.seen.challenged).toBe(1); + }); + + test("attempts are clamped to five even when more are requested", async () => { + const secret = createLocalAttestationSecret(); + let calls = 0; + const deadFetch = (async () => { + calls += 1; + throw new TypeError("fetch failed"); + }) as typeof fetch; + const io = ownedIo(secret, deadFetch, { attempts: 42, sleepFn: () => Promise.resolve() }); + const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; + expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + expect(calls).toBe(5); + }); +}); From 49ed7172133b971643e386acaf4b24966c0bfa03 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:02:23 +0900 Subject: [PATCH 04/10] fix(cli): prove cross-home owners via the shared registry and fail closed on the unverifiable (#6198) --- src/cli/cross-home-owner.ts | 249 +++++++++++++++--- src/cli/index.ts | 2 +- src/config/owner-registry.ts | 105 ++++++++ src/config/process-state.ts | 5 + src/server/proxy-liveness.ts | 37 ++- tests/cli/cli-dispatch.test.ts | 2 +- tests/cli/sibling-home-client-sync.test.ts | 108 +++++++- .../proxy-liveness-package-tree-fence.test.ts | 16 +- 8 files changed, 459 insertions(+), 65 deletions(-) create mode 100644 src/config/owner-registry.ts diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index cdcf254bd02..3afcd995214 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -14,13 +14,79 @@ import { readClientConnectionState } from "../client/state"; import { findManagedRegion, resolveGrokHome } from "../grok/inject"; import { providerTableString } from "../codex/injected-marker"; import { isLocalAttestationSecret } from "../lib/local-management-attestation"; -import { loopbackProbeHosts, proveLiveProxyOwnedByHome, proxyIdentityAt, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; +import { readOwnerRegistryHomes } from "../config/owner-registry"; +import { + classifyHealthz, + loopbackProbeHosts, + proveLiveProxyOwnedByHome, + proxyIdentityAt, + START_OWNERSHIP_LIVENESS, + type LivenessIo, +} from "../server/proxy-liveness"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import type { RuntimePortState } from "../config/process-state"; const MAX_HINT_BYTES = 256 * 1024; // Far above any real Grok config; discovery must not stall startup when Grok sync is off. const MAX_GROK_CONFIG_BYTES = 16 * 1024 * 1024; +/** + * One registered home's runtime record as discovery sees it. + * + * 'attestable' distinguishes the two shapes a well-formed record can take: a current + * record carries the attestation secret another home can challenge, while a legacy + * record (written before secrets existed, or with the secret stripped) can only name + * a pid/port pair. A live legacy record is never "no owner" - it is an owner the + * reader cannot verify, and a shared-write decision must fail closed on it. + */ +type CandidateRecord = { + home: string; + pid: number; + port: number; + hostname?: string; + siblingOfPort?: number; + attestable: boolean; + record: RuntimePortState; +}; + +function parseCandidateRecord(home: string, raw: string): CandidateRecord | null { + try { + const record: unknown = JSON.parse(raw); + if (!record || typeof record !== "object") return null; + const state = record as Record; + if (!Number.isSafeInteger(state.pid) || Number(state.pid) <= 0 || !validPort(state.port)) return null; + if (state.hostname !== undefined && typeof state.hostname !== "string") return null; + if (state.siblingOfPort !== undefined + && !(Number.isInteger(state.siblingOfPort) && Number(state.siblingOfPort) > 0 && Number(state.siblingOfPort) <= 65535)) { + return null; + } + const attestable = isLocalAttestationSecret(state.attestationSecret); + return { + home, + pid: Number(state.pid), + port: Number(state.port), + hostname: typeof state.hostname === "string" ? state.hostname : undefined, + siblingOfPort: typeof state.siblingOfPort === "number" ? state.siblingOfPort : undefined, + attestable, + record: state as RuntimePortState, + }; + } catch { + return null; + } +} + +/** + * The discovery verdict. "owner" names the port a sibling defers to. "indeterminate" + * means something on the shared clients' path could be an owner the reader could not + * verify - a live listener no record attests, a legacy record, or an unreadable + * transport - and a shared-write caller must fail closed on it. 'port' carries the + * best location hint for the sibling marker. + */ +export type CrossHomeOwnerVerdict = + | { kind: "owner"; port: number } + | { kind: "indeterminate"; port: number | null; reason: string } + | { kind: "none" }; + /** Nonblocking open (a FIFO cannot stall startup), regular files only, capped at maxBytes. */ function readBoundedRegularFile(path: string, maxBytes: number): string | null { let fd: number | undefined; @@ -62,27 +128,42 @@ function loopbackPort(raw: string | null): number | null { } /** Returns only a different process with an identity-checked /healthz response. */ -export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Promise { +export async function findCrossHomeOwner(options: { homeDir?: string; io?: LivenessIo } = {}): Promise { + const verdict = await findCrossHomeOwnerDetailed(options); + return verdict.kind === "owner" ? verdict.port : null; +} + +export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io?: LivenessIo } = {}): Promise { + const io = options.io ?? {}; + const nowFn = io.nowFn ?? Date.now; + const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500; + const attempts = io.attempts ?? START_OWNERSHIP_LIVENESS.attempts ?? 3; + // One deadline bounds the whole discovery: every identity probe, attestation and + // fallback classification shares it, so a pile of stale hints cannot stretch the + // start path the way per-candidate budgets did (CodeRabbit on #6198). + const deadlineAt = io.deadlineAt ?? nowFn() + timeoutMs * attempts * 4; + const probeIo: LivenessIo = { ...io, timeoutMs, attempts, deadlineAt }; + const candidates = new Set(); - let defaultRuntime: RuntimePortState | null = null; + const records = new Map(); + const ownHome = resolve(getConfigDir()); + const recordHome = (home: string): void => { + if (resolve(home) === ownHome) return; + const raw = readBoundedRegularFile(join(home, "runtime-port.json"), MAX_HINT_BYTES); + if (!raw) return; + const parsed = parseCandidateRecord(home, raw); + if (!parsed) return; + candidates.add(parsed.port); + const list = records.get(parsed.port) ?? []; + list.push(parsed); + records.set(parsed.port, list); + }; + const defaultHome = join(options.homeDir ?? homedir(), ".opencodex"); - if (resolve(getConfigDir()) !== resolve(defaultHome)) { - const raw = readBoundedRegularFile(join(defaultHome, "runtime-port.json"), MAX_HINT_BYTES); - if (raw) { - try { - const record: unknown = JSON.parse(raw); - if (record && typeof record === "object") { - const state = record as Record; - if (Number.isSafeInteger(state.pid) && Number(state.pid) > 0 && validPort(state.port) - && isLocalAttestationSecret(state.attestationSecret) - && (state.hostname === undefined || typeof state.hostname === "string")) { - defaultRuntime = state as RuntimePortState; - candidates.add(defaultRuntime.port); - } - } - } catch { /* stale or malformed hint */ } - } - } + recordHome(defaultHome); + // The shared registry is what lets one custom home find another: every runtime + // that published a runtime record registered its home beside the shared clients. + for (const home of readOwnerRegistryHomes()) recordHome(home); // Grok's writer reads its config in full; cap discovery separately so startup stays bounded. const grok = readBoundedRegularFile(join(resolveGrokHome(), "config.toml"), MAX_GROK_CONFIG_BYTES); @@ -105,30 +186,116 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr } } catch { /* an absent or invalid client home is not owner evidence */ } - for (const port of candidates) { - // A managed client URL is only a location hint. The default home's protected runtime - // record supplies the identity and proof key that make it ownership evidence. - if (!defaultRuntime || defaultRuntime.port !== port || defaultRuntime.pid === process.pid) continue; - // IPv4 and IPv6 loopback listeners are independent on this port, so a pid mismatch - // (or a dead answer) on one family does not prove the recorded owner absent. The - // recorded hostname is tried first and every loopback candidate gets an identity - // and attestation check before the port reports no owner. - for (const hostname of loopbackProbeHosts(defaultRuntime.hostname)) { - const identity = await proxyIdentityAt(port, { hostname, expectedPid: defaultRuntime.pid }, START_OWNERSHIP_LIVENESS); - if (identity?.pid !== defaultRuntime.pid) continue; - if (await proveLiveProxyOwnedByHome( - { ...identity, hostname, port, source: "runtime" }, - { ...START_OWNERSHIP_LIVENESS, readRuntimeFn: () => defaultRuntime }, - )) return port; + let indeterminatePort: number | null = null; + let indeterminateReason: string | null = null; + const noteIndeterminate = (port: number | null, reason: string): void => { + if (indeterminateReason === null) { + indeterminatePort = port; + indeterminateReason = reason; } + }; + + const queue = [...candidates]; + const probed = new Set(); + for (const port of queue) { + if (!probed.add(port)) continue; + const portRecords = records.get(port) ?? []; + // The recorded hostnames are tried first; every remaining loopback family is a + // candidate too, because IPv4 and IPv6 listeners on one port are independent. + const hosts: string[] = []; + for (const rec of portRecords) { + for (const host of loopbackProbeHosts(rec.hostname)) { + if (!hosts.includes(host)) hosts.push(host); + } + } + if (hosts.length === 0) hosts.push(...loopbackProbeHosts(undefined)); + + for (const hostname of hosts) { + if (deadlineAt - nowFn() <= 0) { + noteIndeterminate(port, "ownership discovery ran out of its shared time budget"); + break; + } + const identity = await proxyIdentityAt(port, { hostname }, probeIo); + if (identity === null) { + // A null identity is not "free": distinguish a unanimous connect refusal + // (the family is genuinely empty) from a transport or shape that stayed + // unreadable. On a managed port an unreadable answer is owner evidence the + // caller cannot dismiss. + const remainingMs = deadlineAt - nowFn(); + if (remainingMs <= 0) { + noteIndeterminate(port, "ownership discovery ran out of its shared time budget"); + break; + } + const classification = await classifyHealthz( + "http://" + hostname + ":" + port + "/healthz", + probeIo.fetchFn ?? directLocalHttpFetch, + Math.min(timeoutMs, remainingMs), + ); + if (classification === "dead") continue; + noteIndeterminate(port, "a managed client port answered but its listener could not be classified"); + continue; + } + if (identity.pid === null) { + noteIndeterminate(port, "a live opencodex listener reported no pid to attest"); + continue; + } + if (identity.pid === process.pid) continue; + + const matching = portRecords.filter(rec => rec.pid === identity.pid); + if (matching.length === 0) { + // Our opencodex answers on a managed port but no registered record names it: + // it may be an owner whose home never registered (an install predating the + // registry, or a CODEX_HOME the writer could not reach). Unverifiable is not + // absent - fail closed instead of starting a second owner beside it. + noteIndeterminate(port, "a live opencodex listener on a managed port matches no registered owner record"); + continue; + } + for (const rec of matching) { + if (rec.siblingOfPort !== undefined) { + // The attested process is itself a sibling; its runtime record names the + // real owner's port. Follow it instead of deferring to the sibling. + if (validPort(rec.siblingOfPort)) queue.push(rec.siblingOfPort); + continue; + } + if (!rec.attestable) { + noteIndeterminate(port, "a live listener matches a runtime record that carries no attestation secret"); + continue; + } + const proof = await proveLiveProxyOwnedByHome( + { ...identity, hostname, port, source: "runtime" }, + { ...probeIo, readRuntimeFn: () => rec.record }, + ); + if (proof === "proven") return { kind: "owner", port }; + if (proof === "indeterminate") { + noteIndeterminate(port, "the recorded owner's attestation could not be completed"); + } + // "refuted" means this listener definitively is not the recorded owner on + // this record; other records and hosts still get their turn. + } + } + } + + if (indeterminateReason !== null) { + return { kind: "indeterminate", port: indeterminatePort, reason: indeterminateReason }; } - return null; + return { kind: "none" }; } /** Mark this process before any shared-client write when another home owns the clients. */ export async function markCrossHomeSibling(): Promise { - const port = await findCrossHomeOwner(); - if (port === null) return false; + const verdict = await findCrossHomeOwnerDetailed(); + if (verdict.kind === "none") return false; + if (verdict.kind === "indeterminate") { + // Fail closed: an owner the reader could not verify still owns the shared + // writes. Marking the best port hint keeps every sibling gate engaged even + // while the answer stays unproven (#6198). + console.warn( + "A shared-client owner could not be verified (" + verdict.reason + "); " + + "treating this instance as a sibling so Codex, Grok and Claude configs are left alone.", + ); + } + const port = verdict.port; + if (port === null) return true; markSiblingStart(port); return true; } @@ -141,9 +308,9 @@ export async function markLiveHomeSibling(live: { pid: number | null; port: numb markSiblingStart(runtime.siblingOfPort); return true; } - const otherPort = await findCrossHomeOwner(); - if (otherPort === null || otherPort === live.port) return false; - markSiblingStart(otherPort); + const verdict = await findCrossHomeOwnerDetailed(); + if (verdict.kind === "none" || verdict.port === live.port) return false; + if (verdict.port !== null) markSiblingStart(verdict.port); return true; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6610cb3d2e6..3d2ee50b832 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1290,7 +1290,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { if (siblingStopFoundOwner(siblingOfPort, live)) { record.proxy = "not-running"; console.log(`The sibling instance is already gone; the proxy on port ${siblingOfPort} was left running.`); - } else if (live?.pid && !(await proveLiveProxyOwnedByHome(live))) { + } else if (live?.pid && (await proveLiveProxyOwnedByHome(live)) !== "proven") { stopFailed = true; ownershipBlocked = true; record.proxy = "ownership-refused"; diff --git a/src/config/owner-registry.ts b/src/config/owner-registry.ts new file mode 100644 index 00000000000..49efb81e66a --- /dev/null +++ b/src/config/owner-registry.ts @@ -0,0 +1,105 @@ +/** + * The cross-home owner registry. + * + * findCrossHomeOwner proves an owner through a home's protected runtime-port.json, + * but it used to know exactly one home: the default ~/.opencodex. A custom-home + * owner therefore stayed invisible to every other home (#6198): a second custom home + * saw the shared clients' managed URL, could not prove the answering process, and + * started as a competing owner that re-pointed shared Codex/Grok/Claude routing. + * + * This registry is the protected stable locator for those homes. Every runtime that + * publishes runtime-port.json also drops one tiny pointer file here so another home + * can find the record to attest against. The entries carry the home path only - the + * attestation secret stays inside the home's own record - so a forged or stale entry + * can never grant ownership; it can only send the reader to a record that still has + * to prove itself. + * + * The anchor is CODEX_HOME because that is the shared state this protects: the + * managed Codex client config lives there, and a sibling exists to leave that write + * alone. Entries are written atomically and never read back as truth - they only + * nominate a home for the caller's own record + liveness verification. + */ + +import { createHash } from "node:crypto"; +import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { atomicWriteFile } from "./atomic-write"; +import { getConfigDir } from "./paths"; +import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; + +const REGISTRY_DIR_NAME = "ocx-homes"; +const REGISTRY_ENTRY_SUFFIX = ".json"; +const MAX_REGISTRY_ENTRIES = 64; +const MAX_ENTRY_BYTES = 4096; +const HOME_KEY_LENGTH = 24; + +function registryBaseDir(): string { + const raw = process.env.CODEX_HOME?.trim(); + // Without an env override the Codex client home defaults beside the user profile, + // the same location the managed config writers use. + return raw ? resolve(raw) : join(homedir(), ".codex"); +} + +/** The shared directory the registry lives under. */ +export function ownerRegistryDir(): string { + return join(registryBaseDir(), REGISTRY_DIR_NAME); +} + +function registryEntryPath(dir: string, home: string): string { + const key = createHash("sha256").update(resolve(home)).digest("hex").slice(0, HOME_KEY_LENGTH); + return join(dir, key + REGISTRY_ENTRY_SUFFIX); +} + +/** + * Record 'home' in the shared registry. Best-effort and never throws: a failed write + * only degrades cross-home discovery, it must not break the publish that owns state. + */ +export function registerOwnerRegistryHome(home: string): void { + try { + assertNotRealCodexHomeUnderTest(registryBaseDir()); + const dir = ownerRegistryDir(); + mkdirSync(dir, { recursive: true }); + atomicWriteFile( + registryEntryPath(dir, home), + JSON.stringify({ home: resolve(home), v: 1 }) + "\n", + ); + } catch { /* discovery aid only */ } +} + +/** + * Every registered home path, including this process's own (the caller filters it + * out). Entries are pointers, not facts: malformed, oversized, or unreadable entries + * are skipped rather than trusted. Bounded so a cluttered directory cannot stall + * startup discovery. + */ +export function readOwnerRegistryHomes(): string[] { + const dir = ownerRegistryDir(); + let names: string[]; + try { + names = readdirSync(dir) + .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX)) + .slice(0, MAX_REGISTRY_ENTRIES); + } catch { + return []; + } + const homes: string[] = []; + for (const name of names) { + const path = join(dir, name); + try { + const stat = statSync(path); + if (!stat.isFile() || stat.size === 0 || stat.size > MAX_ENTRY_BYTES) continue; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + const home = parsed && typeof parsed === "object" + ? (parsed as Record).home + : undefined; + if (typeof home === "string" && home.length > 0) homes.push(home); + } catch { /* a bad entry names nothing */ } + } + return homes; +} + +/** Register this process's own home after its runtime record is published. */ +export function registerOwnHome(): void { + registerOwnerRegistryHome(getConfigDir()); +} diff --git a/src/config/process-state.ts b/src/config/process-state.ts index cb98672acd4..2134e64c072 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -9,6 +9,7 @@ import { } from "../lib/windows-elevation"; import { atomicWriteFile } from "./atomic-write"; import { getConfigDir, hardenConfigDir } from "./paths"; +import { registerOwnHome } from "./owner-registry"; export function getPidPath(): string { return join(getConfigDir(), "ocx.pid"); @@ -64,6 +65,10 @@ function isValidRuntimePortState(value: unknown): value is RuntimePortState { export function writeRuntimePort(state: RuntimePortState): void { ensureProcessStateDir(); atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n"); + // The record proves this home's owner only to a reader that knows where it lives. + // One pointer in the shared registry makes the record findable from every home; + // it is best-effort because ownership never depends on the registry write landing. + registerOwnHome(); } export function parsePidFile(raw: string): number | null { diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index eecb6ce1cf6..ea0079c0ab1 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -185,9 +185,17 @@ export interface LiveProxy { /** * A /healthz identity proves only that a proxy holds the port. Before a destructive orphan stop, * require that listener to prove possession of this home's runtime-record secret as well. + * + * The verdict is three-valued on purpose. "proven" is the only answer that authorizes the + * caller's action; "refuted" means the answer was definitive (no record, a pid/port + * mismatch, or a failed proof); "indeterminate" means transport, deadline, or an + * unattestable record left the question open, which a shared-write decision must treat + * the same as a live owner it simply could not verify (#6198). */ -export async function proveLiveProxyOwnedByHome(live: LiveProxy, io: LivenessIo = {}): Promise { - if (live.pid === null) return false; +export type HomeOwnershipProof = "proven" | "refuted" | "indeterminate"; + +export async function proveLiveProxyOwnedByHome(live: LiveProxy, io: LivenessIo = {}): Promise { + if (live.pid === null) return "indeterminate"; return attestFencedIdentity( `http://${probeHostname(live.hostname)}:${live.port}/healthz`, live.port, @@ -258,17 +266,18 @@ async function attestFencedIdentity( io: LivenessIo, fetchFn: LivenessFetch, timeoutMs: number, -): Promise { +): Promise { const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort; let record: ReturnType>; try { record = readRuntimeFn(pid); } catch { - return false; + return "indeterminate"; } // The typed seam omits the secret; the production record (readRuntimePort) carries it. const secret: unknown = record ? Reflect.get(record, "attestationSecret") : undefined; - if (!record || record.pid !== pid || record.port !== port || typeof secret !== "string") return false; + if (!record || record.pid !== pid || record.port !== port) return "refuted"; + if (typeof secret !== "string" || secret.length === 0) return "indeterminate"; const challenge = (io.createChallengeFn ?? createLocalAttestationChallenge)(); // One proof failure is definitive and never retried; a transport failure only means // the listener did not answer yet, so it gets the same bounded retry the identity @@ -282,7 +291,7 @@ async function attestFencedIdentity( : Math.max(1, Math.min(requestedAttempts, 5)); for (let attempt = 1; attempt <= attempts; attempt++) { const remainingMs = io.deadlineAt === undefined ? timeoutMs : Math.min(timeoutMs, io.deadlineAt - nowFn()); - if (remainingMs <= 0) return false; + if (remainingMs <= 0) return "indeterminate"; try { const res = await fetchFn(url, { headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, @@ -290,15 +299,17 @@ async function attestFencedIdentity( }); const body = (await res.json().catch(() => null)) as HealthzIdentity | null; // The second answer must still be the same fenced (or by now healthy) process. - if (!isOpencodexHealthz(body) && !isPackageTreeFencedHealthz(body)) return false; - if (body?.pid !== pid) return false; - return verifyLocalAttestationProof(secret, challenge, pid, port, res.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)); + if (!isOpencodexHealthz(body) && !isPackageTreeFencedHealthz(body)) return "refuted"; + if (body?.pid !== pid) return "refuted"; + return verifyLocalAttestationProof(secret, challenge, pid, port, res.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)) + ? "proven" + : "refuted"; } catch { - if (attempt >= attempts) return false; + if (attempt >= attempts) return "indeterminate"; await sleepFn(100); } } - return false; + return "indeterminate"; } /** A bounded version string safe to carry beyond the untrusted health response. */ @@ -333,7 +344,7 @@ export function isConnectionRefused(error: unknown): boolean { return visit(error, 0); } -async function classifyHealthz( +export async function classifyHealthz( url: string, fetchFn: LivenessFetch, timeoutMs: number, @@ -407,7 +418,7 @@ export async function proxyIdentityAt( if (opts.expectedPid !== undefined && fencedPid !== opts.expectedPid) return null; const attestMs = io.deadlineAt === undefined ? baseTimeoutMs : Math.min(baseTimeoutMs, io.deadlineAt - nowFn()); if (attestMs <= 0) return null; - if (!(await attestFencedIdentity(url, port, fencedPid, io, fetchFn, attestMs))) return null; + if ((await attestFencedIdentity(url, port, fencedPid, io, fetchFn, attestMs)) !== "proven") return null; const fencedVersion = isHealthzVersion(fenced?.version) ? fenced!.version as string : undefined; return { pid: fencedPid, diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index d038b85ee71..2b89ca1c828 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -601,7 +601,7 @@ describe("a sibling start leaves shared client routing to the live owner", () => const stop = slice("async function handleStopUnlocked(", "async function handleUninstall("); const findAt = stop.indexOf("const live = await findLiveProxy({ acceptPackageTreeFenced: true });"); const askAt = stop.indexOf("if (siblingStopFoundOwner(siblingOfPort, live)) {"); - const attestAt = stop.indexOf("} else if (live?.pid && !(await proveLiveProxyOwnedByHome(live))) {"); + const attestAt = stop.indexOf('} else if (live?.pid && (await proveLiveProxyOwnedByHome(live)) !== "proven") {'); expect(findAt).toBeGreaterThan(-1); expect(askAt).toBeGreaterThan(findAt); expect(askAt).toBeLessThan(attestAt); diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index e989f759e2a..0f15b2941ba 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -2,7 +2,10 @@ import { afterEach, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { findCrossHomeOwner, markLiveHomeSibling } from "../../src/cli/cross-home-owner"; +import { findCrossHomeOwner, findCrossHomeOwnerDetailed, markCrossHomeSibling, markLiveHomeSibling } from "../../src/cli/cross-home-owner"; +import { readOwnerRegistryHomes, registerOwnerRegistryHome } from "../../src/config/owner-registry"; +import { writeRuntimePort } from "../../src/config/process-state"; +import { directLocalHttpFetch } from "../../src/server/direct-local-http"; import { resetSiblingStartForTests, siblingOfLivePort } from "../../src/codex/sibling-start"; import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; import { @@ -411,3 +414,106 @@ test("a lone custom-home start still syncs Grok and prunes its own Claude roster expect(await ensure.exited).toBe(0); expect(existsSync(claudePath)).toBe(false); }, 30_000); + +test("a registered custom-home owner is proven through its own runtime record", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port, attestationSecret: TEST_ATTESTATION_SECRET, + })); + writeFileSync(join(fx.grok, "config.toml"), grokFence(port)); + + // Before registration no record names the listener: unverifiable is not absent. + const verdict = await findCrossHomeOwnerDetailed({ homeDir: fx.home }); + expect(verdict.kind).toBe("indeterminate"); + expect(verdict.kind === "indeterminate" ? verdict.port : null).toBe(port); + expect(await markCrossHomeSibling()).toBe(true); + resetSiblingStartForTests(); + + registerOwnerRegistryHome(homeA); + expect(readOwnerRegistryHomes()).toContain(homeA); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); +}); + +test("a live legacy record without an attestation secret fails closed", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port, + })); + writeFileSync(join(fx.grok, "config.toml"), grokFence(port)); + const verdict = await findCrossHomeOwnerDetailed({ homeDir: fx.home }); + expect(verdict.kind).toBe("indeterminate"); + expect(await markCrossHomeSibling()).toBe(true); + expect(siblingOfLivePort()).toBe(port); +}); + +test("a dropped attestation probe retries inside the shared deadline", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + defaultRuntime(fx, ownerPid, port); + writeFileSync(join(fx.grok, "config.toml"), grokFence(port)); + let calls = 0; + const flakyFetch = (async (input: string | URL | Request, init?: RequestInit) => { + calls += 1; + // The identity probe answers; the first attestation is lost, the retry wins. + if (calls === 2) throw new TypeError("fetch failed"); + return directLocalHttpFetch(input, init); + }) as typeof fetch; + const verdict = await findCrossHomeOwnerDetailed({ + homeDir: fx.home, + io: { fetchFn: flakyFetch, sleepFn: () => Promise.resolve() }, + }); + expect(verdict).toEqual({ kind: "owner", port }); + expect(calls).toBe(3); +}); + +test("one shared deadline bounds every candidate probe", async () => { + const fx = fixture(); + const port = healthServer(process.pid + 1); + defaultRuntime(fx, process.pid + 1, port); + writeFileSync(join(fx.grok, "config.toml"), grokFence(port)); + let calls = 0; + const countingFetch = (async (input: string | URL | Request, init?: RequestInit) => { + calls += 1; + return directLocalHttpFetch(input, init); + }) as typeof fetch; + const verdict = await findCrossHomeOwnerDetailed({ + homeDir: fx.home, + io: { fetchFn: countingFetch, deadlineAt: 0, nowFn: () => 0 }, + }); + expect(verdict.kind).toBe("indeterminate"); + expect(calls).toBe(0); +}); + +test("an attested sibling record defers to the owner port it names", async () => { + const fx = fixture(); + const siblingPid = process.pid + 1; + const ownerPid = process.pid + 2; + const siblingPort = healthServer(siblingPid); + const ownerPort = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: siblingPid, port: siblingPort, siblingOfPort: ownerPort, + })); + const homeB = join(fx.root, "homeB", ".opencodex"); + mkdirSync(homeB, { recursive: true }); + writeFileSync(join(homeB, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port: ownerPort, attestationSecret: TEST_ATTESTATION_SECRET, + })); + registerOwnerRegistryHome(homeA); + registerOwnerRegistryHome(homeB); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(ownerPort); +}); + +test("publishing a runtime record registers the home for cross-home discovery", () => { + const fx = fixture(); + writeRuntimePort({ pid: process.pid, port: 0 }); + expect(readOwnerRegistryHomes()).toContain(fx.ocx); +}); diff --git a/tests/server/proxy-liveness-package-tree-fence.test.ts b/tests/server/proxy-liveness-package-tree-fence.test.ts index 49b13de4962..14b8a083dff 100644 --- a/tests/server/proxy-liveness-package-tree-fence.test.ts +++ b/tests/server/proxy-liveness-package-tree-fence.test.ts @@ -73,9 +73,9 @@ describe("package-tree fenced liveness (#5496)", () => { const body = { service: "opencodex", status: "ok", version: "2.59.0", uptime: 12, pid: PID, port: PORT }; const listener = fencedListener(secret, body, 200); const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "config" as const }; - expect(await proveLiveProxyOwnedByHome(live, ownedIo(secret, listener.fetchFn))).toBe(true); - expect(await proveLiveProxyOwnedByHome(live, ownedIo(createLocalAttestationSecret(), listener.fetchFn))).toBe(false); - expect(await proveLiveProxyOwnedByHome(live, ownedIo(secret, listener.fetchFn, { readRuntimeFn: () => null }))).toBe(false); + expect(await proveLiveProxyOwnedByHome(live, ownedIo(secret, listener.fetchFn))).toBe("proven"); + expect(await proveLiveProxyOwnedByHome(live, ownedIo(createLocalAttestationSecret(), listener.fetchFn))).toBe("refuted"); + expect(await proveLiveProxyOwnedByHome(live, ownedIo(secret, listener.fetchFn, { readRuntimeFn: () => null }))).toBe("refuted"); expect(listener.seen.challenged).toBe(2); }); @@ -169,25 +169,25 @@ describe("fenced-identity transport retries (#6198)", () => { }) as typeof fetch; const io = ownedIo(secret, flakyFetch, { attempts: 3, sleepFn: () => Promise.resolve() }); const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; - expect(await proveLiveProxyOwnedByHome(live, io)).toBe(true); + expect(await proveLiveProxyOwnedByHome(live, io)).toBe("proven"); expect(calls).toBe(3); }); - test("the owner stays absent only after the bounded attempts run out", async () => { + test("a transport that never answers stays indeterminate after the bounded attempts run out", async () => { const secret = createLocalAttestationSecret(); const deadFetch = (async () => { throw new TypeError("fetch failed"); }) as typeof fetch; const io = ownedIo(secret, deadFetch, { attempts: 3, sleepFn: () => Promise.resolve() }); const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; - expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + expect(await proveLiveProxyOwnedByHome(live, io)).toBe("indeterminate"); }); test("a definitive proof failure is not retried", async () => { const listener = fencedListener(createLocalAttestationSecret(), fencedBody(), 200); const io = ownedIo(createLocalAttestationSecret(), listener.fetchFn, { attempts: 5, sleepFn: () => Promise.resolve() }); const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; - expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + expect(await proveLiveProxyOwnedByHome(live, io)).toBe("refuted"); expect(listener.seen.challenged).toBe(1); }); @@ -200,7 +200,7 @@ describe("fenced-identity transport retries (#6198)", () => { }) as typeof fetch; const io = ownedIo(secret, deadFetch, { attempts: 42, sleepFn: () => Promise.resolve() }); const live = { pid: PID, port: PORT, hostname: "127.0.0.1", source: "runtime" as const }; - expect(await proveLiveProxyOwnedByHome(live, io)).toBe(false); + expect(await proveLiveProxyOwnedByHome(live, io)).toBe("indeterminate"); expect(calls).toBe(5); }); }); From b3cb717314773bef239c03aefe9b8bd8f0936f15 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:21:18 +0900 Subject: [PATCH 05/10] test(codex): allow the shared owner-registry pointer in foreign-home manifests (#6198) --- .../codex-composed-acceptance.test.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index b383c7a35c4..7fe749de16c 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -142,10 +142,15 @@ function manifest(root: string): Record { return entries; } -/** The catalog/cache artifacts an explicit side-profile sync may legitimately write while OFF. */ -function manifestWithoutCatalogArtifacts(entries: Record): Record { +/** Artifacts a runtime may legitimately leave in a foreign or OFF Codex home: the + * catalog/cache files an explicit side-profile sync can write, and the shared + * ocx-homes/ owner-registry pointer every serving runtime publishes so sibling + * homes can locate its protected runtime record. Neither names managed state. */ +function manifestWithoutLifecycleArtifacts(entries: Record): Record { return Object.fromEntries( - Object.entries(entries).filter(([key]) => !key.includes("opencodex-catalog") && key !== "models_cache.json"), + Object.entries(entries).filter(([key]) => + !key.includes("opencodex-catalog") && key !== "models_cache.json" + && !key.startsWith("ocx-homes/") && !key.startsWith("ocx-homes\\")), ); } @@ -545,15 +550,15 @@ describe("WP13 composed toggle acceptance", () => { // SID and LocalAppData PowerShell children with 30 s budgets each; run 35093667426 // exceeded healthy controls by 33.8 s before the runtime-port watchdog fired at 45 s. expect(existsSync(fx.catalogLockPath)).toBe(false); - expect(manifest(fx.codex)).toEqual(before); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); for (const argv of [["ensure"], ["restore"]]) { const result = await fx.runCli(argv); expect(result.exitCode).toBe(0); - expect(manifest(fx.codex)).toEqual(before); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); } const synced = await fx.runCli(["sync"]); expect(synced.exitCode).toBe(0); - expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); const unchangedCache = await fx.runCli(["sync-cache", "--json"]); expect(unchangedCache.exitCode).toBe(0); // An OFF sync may or may not leave a catalog behind; either way the explicit cache @@ -568,7 +573,7 @@ describe("WP13 composed toggle acceptance", () => { ? "Codex model cache is already current; nothing to sync." : "No Codex catalog to derive a cache from; nothing to sync."); expect(unchangedHuman.stdout).not.toContain("Codex integration is OFF"); - expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(200); expect(sync.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); @@ -579,7 +584,7 @@ describe("WP13 composed toggle acceptance", () => { expect([200, 404]).toContain(toggle.status); expect(toggle.body).toHaveProperty("desiredEnabled", false); } - expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); // P08 is intentionally the ON control: it must reach the same running // server through the real CLI without passing a port flag. const enabled = await fx.request(server.runtime, "/api/native-integrations/codex", { @@ -722,7 +727,7 @@ describe("WP13 composed toggle acceptance", () => { expect(String(sync.body.message ?? sync.body.error)).toMatch(/Refusing|service|ownership/i); const restore = await fx.runCli(["restore"]); expect(restore.exitCode).toBe(1); - expect(manifest(fx.codex)).toEqual(before); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); expect(fx.lockAllowlist.some(existsSync)).toBe(false); } finally { await fx.stop(server); @@ -767,7 +772,7 @@ describe("WP13 composed toggle acceptance", () => { const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(409); expect(String(sync.body.message ?? sync.body.error)).toMatch(/ownership|proven|read|malformed/i); - expect(manifest(fx.codex)).toEqual(before); + expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); expect(fx.lockAllowlist.some(existsSync)).toBe(false); } finally { await fx.stop(server); From f3454e72082e70e89d48953b80886aa23c1659b9 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:55:18 +0900 Subject: [PATCH 06/10] Revert "test(codex): allow the shared owner-registry pointer in foreign-home manifests (#6198)" This reverts commit b3cb717314773bef239c03aefe9b8bd8f0936f15. --- .../codex-composed-acceptance.test.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index 7fe749de16c..b383c7a35c4 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -142,15 +142,10 @@ function manifest(root: string): Record { return entries; } -/** Artifacts a runtime may legitimately leave in a foreign or OFF Codex home: the - * catalog/cache files an explicit side-profile sync can write, and the shared - * ocx-homes/ owner-registry pointer every serving runtime publishes so sibling - * homes can locate its protected runtime record. Neither names managed state. */ -function manifestWithoutLifecycleArtifacts(entries: Record): Record { +/** The catalog/cache artifacts an explicit side-profile sync may legitimately write while OFF. */ +function manifestWithoutCatalogArtifacts(entries: Record): Record { return Object.fromEntries( - Object.entries(entries).filter(([key]) => - !key.includes("opencodex-catalog") && key !== "models_cache.json" - && !key.startsWith("ocx-homes/") && !key.startsWith("ocx-homes\\")), + Object.entries(entries).filter(([key]) => !key.includes("opencodex-catalog") && key !== "models_cache.json"), ); } @@ -550,15 +545,15 @@ describe("WP13 composed toggle acceptance", () => { // SID and LocalAppData PowerShell children with 30 s budgets each; run 35093667426 // exceeded healthy controls by 33.8 s before the runtime-port watchdog fired at 45 s. expect(existsSync(fx.catalogLockPath)).toBe(false); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifest(fx.codex)).toEqual(before); for (const argv of [["ensure"], ["restore"]]) { const result = await fx.runCli(argv); expect(result.exitCode).toBe(0); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifest(fx.codex)).toEqual(before); } const synced = await fx.runCli(["sync"]); expect(synced.exitCode).toBe(0); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); const unchangedCache = await fx.runCli(["sync-cache", "--json"]); expect(unchangedCache.exitCode).toBe(0); // An OFF sync may or may not leave a catalog behind; either way the explicit cache @@ -573,7 +568,7 @@ describe("WP13 composed toggle acceptance", () => { ? "Codex model cache is already current; nothing to sync." : "No Codex catalog to derive a cache from; nothing to sync."); expect(unchangedHuman.stdout).not.toContain("Codex integration is OFF"); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(200); expect(sync.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); @@ -584,7 +579,7 @@ describe("WP13 composed toggle acceptance", () => { expect([200, 404]).toContain(toggle.status); expect(toggle.body).toHaveProperty("desiredEnabled", false); } - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); // P08 is intentionally the ON control: it must reach the same running // server through the real CLI without passing a port flag. const enabled = await fx.request(server.runtime, "/api/native-integrations/codex", { @@ -727,7 +722,7 @@ describe("WP13 composed toggle acceptance", () => { expect(String(sync.body.message ?? sync.body.error)).toMatch(/Refusing|service|ownership/i); const restore = await fx.runCli(["restore"]); expect(restore.exitCode).toBe(1); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifest(fx.codex)).toEqual(before); expect(fx.lockAllowlist.some(existsSync)).toBe(false); } finally { await fx.stop(server); @@ -772,7 +767,7 @@ describe("WP13 composed toggle acceptance", () => { const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(409); expect(String(sync.body.message ?? sync.body.error)).toMatch(/ownership|proven|read|malformed/i); - expect(manifestWithoutLifecycleArtifacts(manifest(fx.codex))).toEqual(manifestWithoutLifecycleArtifacts(before)); + expect(manifest(fx.codex)).toEqual(before); expect(fx.lockAllowlist.some(existsSync)).toBe(false); } finally { await fx.stop(server); From 3cff6018d047c323e60058e8e02b8c7604f53390 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:45:29 +0900 Subject: [PATCH 07/10] fix(cli): anchor the owner registry to the default home and fail closed (#6198) --- src/cli/cross-home-owner.ts | 3 +- src/config/owner-registry.ts | 30 +++++---- structure/codex-home.md | 2 +- tests/cli/sibling-home-client-sync.test.ts | 74 ++++++++++++++++++++++ 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 3afcd995214..6ffc6144a4b 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -198,7 +198,8 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io const queue = [...candidates]; const probed = new Set(); for (const port of queue) { - if (!probed.add(port)) continue; + if (probed.has(port)) continue; + probed.add(port); const portRecords = records.get(port) ?? []; // The recorded hostnames are tried first; every remaining loopback family is a // candidate too, because IPv4 and IPv6 listeners on one port are independent. diff --git a/src/config/owner-registry.ts b/src/config/owner-registry.ts index 49efb81e66a..e84995dd7d5 100644 --- a/src/config/owner-registry.ts +++ b/src/config/owner-registry.ts @@ -14,10 +14,13 @@ * can never grant ownership; it can only send the reader to a record that still has * to prove itself. * - * The anchor is CODEX_HOME because that is the shared state this protects: the - * managed Codex client config lives there, and a sibling exists to leave that write - * alone. Entries are written atomically and never read back as truth - they only - * nominate a home for the caller's own record + liveness verification. + * The anchor is the default OpenCodex home (~/.opencodex), an OpenCodex-owned + * namespace every runtime for this user can reach regardless of which + * OPENCODEX_HOME or CODEX_HOME it serves. Codex, Grok and Claude homes stay + * untouched: discovery metadata must not write into the client state it exists + * to protect, so OFF or foreign-owned client homes never see it. Entries are + * written atomically and never read back as truth - they only nominate a home + * for the caller's own record + liveness verification. */ import { createHash } from "node:crypto"; @@ -26,19 +29,23 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { atomicWriteFile } from "./atomic-write"; import { getConfigDir } from "./paths"; -import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; const REGISTRY_DIR_NAME = "ocx-homes"; const REGISTRY_ENTRY_SUFFIX = ".json"; const MAX_REGISTRY_ENTRIES = 64; +// Directory listing itself is bounded so a cluttered folder cannot stall startup; +// the 64-entry result cap applies AFTER validation so dead names cannot crowd out +// live owners. +const MAX_REGISTRY_LISTING = 4096; const MAX_ENTRY_BYTES = 4096; const HOME_KEY_LENGTH = 24; function registryBaseDir(): string { - const raw = process.env.CODEX_HOME?.trim(); - // Without an env override the Codex client home defaults beside the user profile, - // the same location the managed config writers use. - return raw ? resolve(raw) : join(homedir(), ".codex"); + // Always the default home, never the caller's OPENCODEX_HOME: the pointer must + // sit where every sibling runtime can find it no matter which custom home is + // serving. + return join(homedir(), ".opencodex"); } /** The shared directory the registry lives under. */ @@ -57,7 +64,7 @@ function registryEntryPath(dir: string, home: string): string { */ export function registerOwnerRegistryHome(home: string): void { try { - assertNotRealCodexHomeUnderTest(registryBaseDir()); + assertNotRealHomeUnderTest(registryBaseDir()); const dir = ownerRegistryDir(); mkdirSync(dir, { recursive: true }); atomicWriteFile( @@ -79,12 +86,13 @@ export function readOwnerRegistryHomes(): string[] { try { names = readdirSync(dir) .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX)) - .slice(0, MAX_REGISTRY_ENTRIES); + .slice(0, MAX_REGISTRY_LISTING); } catch { return []; } const homes: string[] = []; for (const name of names) { + if (homes.length >= MAX_REGISTRY_ENTRIES) break; const path = join(dir, name); try { const stat = statSync(path); diff --git a/structure/codex-home.md b/structure/codex-home.md index 98aa67b3642..3ca1799eef8 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -155,7 +155,7 @@ A sibling instance is `ocx start --port ` while a live proxy serves the c lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and the launchd domain with the live owner. `handleStart` marks the process through `src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's -lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record only for a custom home, plus managed Grok and Codex loopback URLs as location hints. It accepts a different process only when the listener's PID matches that record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/.json, home path only, written beside runtime-port.json publication) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason `sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index 0f15b2941ba..dfe945993a8 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -517,3 +517,77 @@ test("publishing a runtime record registers the home for cross-home discovery", writeRuntimePort({ pid: process.pid, port: 0 }); expect(readOwnerRegistryHomes()).toContain(fx.ocx); }); + +/** + * Shared start-to-shutdown acceptance for the ownership topologies that must veto + * shared-client writes: the managed Grok/Codex routing and the Claude roster keep + * their exact bytes across the secondary's whole lifecycle. + */ +async function secondaryStartPreservesBytes( + fx: ReturnType, + ownerPort: number, + expectedSiblingPort: number, +): Promise { + const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); + const secondaryPort = reservation.port; + reservation.stop(true); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + const claudePath = join(fx.claude, "agents", "ocx-existing.md"); + writeFileSync(grokPath, grokFence(ownerPort)); + writeFileSync(codexPath, codexRouting(ownerPort)); + writeFileSync(claudePath, "owned roster bytes\n"); + const before = [grokPath, codexPath, claudePath].map(path => readFileSync(path)); + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify({ + port: secondaryPort, hostname: "127.0.0.1", codexAutoStart: false, syncResumeHistory: false, + checkForUpdates: false, clientIntegrations: { codex: true, grok: true, "claude-desktop": false }, + claudeCode: { injectAgents: false, systemEnv: false }, providers: {}, defaultProvider: "openai", + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(secondaryPort)], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(child); + const runtime = await waitForRuntime(join(fx.ocx, "runtime-port.json"), child); + expect(runtime.siblingOfPort).toBe(expectedSiblingPort); + await waitForClientStartup(child); + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); + child.kill("SIGTERM"); + await child.exited; + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); +} + +test("a custom-home owner discovered through the registry vetoes shared writes end to end", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + const ownerPort = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port: ownerPort, attestationSecret: TEST_ATTESTATION_SECRET, + })); + registerOwnerRegistryHome(homeA); + await secondaryStartPreservesBytes(fx, ownerPort, ownerPort); +}, 30_000); + +test("an unreadable listener on a managed port vetoes shared writes end to end", async () => { + const fx = fixture(); + // HTTP 500 is neither a connection refusal nor an opencodex identity: the + // classification is unknown, so ownership is indeterminate and must fail closed. + const managed = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unreadable", { status: 500 }) }); + servers.push(managed); + const ownerPort = managed.port; + await secondaryStartPreservesBytes(fx, ownerPort, ownerPort); +}, 30_000); + +test("a live legacy record without an attestation secret vetoes shared writes end to end", async () => { + const fx = fixture(); + const ownerPid = process.pid + 1; + const ownerPort = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port: ownerPort, + })); + registerOwnerRegistryHome(homeA); + await secondaryStartPreservesBytes(fx, ownerPort, ownerPort); +}, 30_000); From d0ec2e9edbe627c3e88488aa50de73571005d11e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:31:57 +0900 Subject: [PATCH 08/10] test(cli): anchor the owner registry behind a test-only env seam (lidge-jun#6198) os.homedir() resolves the passwd database on POSIX, so the fixture's HOME rewrite could not move the ~/.opencodex registry anchor: Linux and macOS CI wrote into the protected real home, the armed test-home guard refused, and the silent best-effort catch left every registry read empty. OCX_OWNER_REGISTRY_DIR gives suites an explicit anchor while production keeps the default home, and the write guard now walks every ancestor of the resolved directory so an override that still lands under ~/.opencodex cannot slip past it. --- src/config/owner-registry.ts | 21 +++++++++++++++++++-- structure/codex-home.md | 2 +- tests/cli/sibling-home-client-sync.test.ts | 4 ++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/config/owner-registry.ts b/src/config/owner-registry.ts index e84995dd7d5..9709936f721 100644 --- a/src/config/owner-registry.ts +++ b/src/config/owner-registry.ts @@ -26,12 +26,20 @@ import { createHash } from "node:crypto"; import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { atomicWriteFile } from "./atomic-write"; import { getConfigDir } from "./paths"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; const REGISTRY_DIR_NAME = "ocx-homes"; +/** + * Test seam, not a user knob: os.homedir() resolves the passwd database on POSIX, + * so rewriting HOME inside a test process cannot move the anchor and the armed + * test-home guard would rightly refuse the write. Suites point this at their own + * fixture home; production always leaves it unset so the default home stays the + * one locator every sibling can find. + */ +const REGISTRY_DIR_ENV = "OCX_OWNER_REGISTRY_DIR"; const REGISTRY_ENTRY_SUFFIX = ".json"; const MAX_REGISTRY_ENTRIES = 64; // Directory listing itself is bounded so a cluttered folder cannot stall startup; @@ -50,6 +58,8 @@ function registryBaseDir(): string { /** The shared directory the registry lives under. */ export function ownerRegistryDir(): string { + const override = process.env[REGISTRY_DIR_ENV]?.trim(); + if (override) return resolve(override); return join(registryBaseDir(), REGISTRY_DIR_NAME); } @@ -64,8 +74,15 @@ function registryEntryPath(dir: string, home: string): string { */ export function registerOwnerRegistryHome(home: string): void { try { - assertNotRealHomeUnderTest(registryBaseDir()); const dir = ownerRegistryDir(); + // Guard every ancestor of the write target: an override that still resolves + // under the protected ~/.opencodex must not slip past the test home guard. + for (let ancestor = dir; ;) { + assertNotRealHomeUnderTest(ancestor); + const parent = dirname(ancestor); + if (parent === ancestor) break; + ancestor = parent; + } mkdirSync(dir, { recursive: true }); atomicWriteFile( registryEntryPath(dir, home), diff --git a/structure/codex-home.md b/structure/codex-home.md index 3ca1799eef8..c6b22979151 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -155,7 +155,7 @@ A sibling instance is `ocx start --port ` while a live proxy serves the c lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and the launchd domain with the live owner. `handleStart` marks the process through `src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's -lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/.json, home path only, written beside runtime-port.json publication) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/.json, home path only, written beside runtime-port.json publication; OCX_OWNER_REGISTRY_DIR overrides the anchor for tests only) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason `sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index dfe945993a8..1a21528c4fa 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -37,6 +37,10 @@ function fixture() { Object.assign(process.env, { HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocx, CODEX_HOME: codex, GROK_HOME: grok, CLAUDE_CONFIG_DIR: claude, + // os.homedir() reads the passwd database, not $HOME, so the owner registry's + // default-home anchor cannot be moved by the HOME rewrite above; point its + // documented seam at this fixture's stand-in for the default ~/.opencodex. + OCX_OWNER_REGISTRY_DIR: join(home, ".opencodex", "ocx-homes"), }); return { root, home, ocx, codex, grok, claude }; } From 18546fdfa4268da8537a59c65392c14ed8f71ef3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:59:29 +0900 Subject: [PATCH 09/10] fix(cli): prune dead registry pointers before the cap and fail closed on truncation (lidge-jun#6198) The 64-entry cap counted dead pointers the same as live ones, so a pile of stale homes could crowd out a registered live owner and let a second instance start as owner over shared clients. readOwnerRegistry now skips pointers whose home no longer publishes a runtime record before the cap applies, removeRuntimePort retires its own pointer, and a listing truncated by either bound returns truncated=true, which cross-home discovery surfaces as indeterminate so shared writes fail closed. Adds a registry-only start-to-shutdown preservation e2e so the registry path is proven without Codex/Grok URL hints, plus regression coverage for pointer retirement, saturation pruning, and truncation. --- src/cli/cross-home-owner.ts | 12 ++- src/config/owner-registry.ts | 46 +++++++--- src/config/process-state.ts | 5 +- structure/codex-home.md | 2 +- tests/cli/sibling-home-client-sync.test.ts | 102 ++++++++++++++++++++- 5 files changed, 147 insertions(+), 20 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 6ffc6144a4b..299cfd2a07a 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -14,7 +14,7 @@ import { readClientConnectionState } from "../client/state"; import { findManagedRegion, resolveGrokHome } from "../grok/inject"; import { providerTableString } from "../codex/injected-marker"; import { isLocalAttestationSecret } from "../lib/local-management-attestation"; -import { readOwnerRegistryHomes } from "../config/owner-registry"; +import { readOwnerRegistry } from "../config/owner-registry"; import { classifyHealthz, loopbackProbeHosts, @@ -163,7 +163,9 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io recordHome(defaultHome); // The shared registry is what lets one custom home find another: every runtime // that published a runtime record registered its home beside the shared clients. - for (const home of readOwnerRegistryHomes()) recordHome(home); + const registry = readOwnerRegistry(); + for (const home of registry.homes) recordHome(home); + const registryTruncated = registry.truncated; // Grok's writer reads its config in full; cap discovery separately so startup stays bounded. const grok = readBoundedRegularFile(join(resolveGrokHome(), "config.toml"), MAX_GROK_CONFIG_BYTES); @@ -195,6 +197,12 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io } }; + if (registryTruncated) { + // A truncated registry can hide the live owner's home entirely; "no owner + // found" is then indistinguishable from "owner never read". Fail closed. + noteIndeterminate(null, "the owner registry listing was truncated before every pointer could be checked"); + } + const queue = [...candidates]; const probed = new Set(); for (const port of queue) { diff --git a/src/config/owner-registry.ts b/src/config/owner-registry.ts index 9709936f721..f2838a49ed6 100644 --- a/src/config/owner-registry.ts +++ b/src/config/owner-registry.ts @@ -24,7 +24,7 @@ */ import { createHash } from "node:crypto"; -import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { atomicWriteFile } from "./atomic-write"; @@ -91,25 +91,37 @@ export function registerOwnerRegistryHome(home: string): void { } catch { /* discovery aid only */ } } +export interface OwnerRegistryRead { + homes: string[]; + /** + * The listing or the validated result hit a bound before every pointer could be + * checked. Callers must treat truncation as "discovery may have missed a live + * owner" and fail closed rather than concluding no owner exists. + */ + truncated: boolean; +} + /** * Every registered home path, including this process's own (the caller filters it * out). Entries are pointers, not facts: malformed, oversized, or unreadable entries - * are skipped rather than trusted. Bounded so a cluttered directory cannot stall - * startup discovery. + * are skipped rather than trusted, and a pointer whose home no longer publishes a + * runtime record nominates nothing - it is pruned before the entry cap so a pile + * of dead homes cannot crowd out a live owner. Bounded so a cluttered directory + * cannot stall startup discovery; when a bound actually cuts off unchecked + * pointers, {@link OwnerRegistryRead.truncated} says the answer is incomplete. */ -export function readOwnerRegistryHomes(): string[] { +export function readOwnerRegistry(): OwnerRegistryRead { const dir = ownerRegistryDir(); let names: string[]; try { names = readdirSync(dir) - .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX)) - .slice(0, MAX_REGISTRY_LISTING); + .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX)); } catch { - return []; + return { homes: [], truncated: false }; } + let truncated = names.length > MAX_REGISTRY_LISTING; const homes: string[] = []; - for (const name of names) { - if (homes.length >= MAX_REGISTRY_ENTRIES) break; + for (const name of names.slice(0, MAX_REGISTRY_LISTING)) { const path = join(dir, name); try { const stat = statSync(path); @@ -118,10 +130,22 @@ export function readOwnerRegistryHomes(): string[] { const home = parsed && typeof parsed === "object" ? (parsed as Record).home : undefined; - if (typeof home === "string" && home.length > 0) homes.push(home); + if (typeof home !== "string" || home.length === 0) continue; + // The record is written before the pointer, so a registered home without + // runtime-port.json is a dead entry: skip it before it can spend the cap. + if (!existsSync(join(home, "runtime-port.json"))) continue; + if (homes.length >= MAX_REGISTRY_ENTRIES) { truncated = true; break; } + homes.push(home); } catch { /* a bad entry names nothing */ } } - return homes; + return { homes, truncated }; +} + +/** Retire 'home' from the shared registry. Best-effort like registration. */ +export function unregisterOwnerRegistryHome(home: string): void { + try { + unlinkSync(registryEntryPath(ownerRegistryDir(), home)); + } catch { /* a missing pointer needs no removal */ } } /** Register this process's own home after its runtime record is published. */ diff --git a/src/config/process-state.ts b/src/config/process-state.ts index 2134e64c072..496741a474e 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -9,7 +9,7 @@ import { } from "../lib/windows-elevation"; import { atomicWriteFile } from "./atomic-write"; import { getConfigDir, hardenConfigDir } from "./paths"; -import { registerOwnHome } from "./owner-registry"; +import { registerOwnHome, unregisterOwnerRegistryHome } from "./owner-registry"; export function getPidPath(): string { return join(getConfigDir(), "ocx.pid"); @@ -105,6 +105,9 @@ export function removePid(expectedPid?: number): void { export function removeRuntimePort(expectedPid?: number): void { if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return; try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ } + // The record is gone, so the registry pointer names a dead home. Retire it + // beside the record or stale pointers accumulate toward the reader's cap. + unregisterOwnerRegistryHome(getConfigDir()); } /** diff --git a/structure/codex-home.md b/structure/codex-home.md index c6b22979151..90ff9cf3912 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -155,7 +155,7 @@ A sibling instance is `ocx start --port ` while a live proxy serves the c lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and the launchd domain with the live owner. `handleStart` marks the process through `src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's -lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/.json, home path only, written beside runtime-port.json publication; OCX_OWNER_REGISTRY_DIR overrides the anchor for tests only) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/.json, home path only, written beside runtime-port.json publication; OCX_OWNER_REGISTRY_DIR overrides the anchor for tests only) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home, and pointers whose home no longer publishes a record are pruned before the reader's entry cap while a truncated listing fails closed as indeterminate. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason `sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index 1a21528c4fa..f0f43259e26 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -3,8 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { findCrossHomeOwner, findCrossHomeOwnerDetailed, markCrossHomeSibling, markLiveHomeSibling } from "../../src/cli/cross-home-owner"; -import { readOwnerRegistryHomes, registerOwnerRegistryHome } from "../../src/config/owner-registry"; -import { writeRuntimePort } from "../../src/config/process-state"; +import { ownerRegistryDir, readOwnerRegistry, registerOwnerRegistryHome } from "../../src/config/owner-registry"; +import { removeRuntimePort, writeRuntimePort } from "../../src/config/process-state"; import { directLocalHttpFetch } from "../../src/server/direct-local-http"; import { resetSiblingStartForTests, siblingOfLivePort } from "../../src/codex/sibling-start"; import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; @@ -122,7 +122,7 @@ afterEach(async () => { } for (const server of servers.splice(0)) server.stop(true); for (const root of roots.splice(0)) removeTreeWithRetry(root); - for (const key of ["HOME", "USERPROFILE", "OPENCODEX_HOME", "CODEX_HOME", "GROK_HOME", "CLAUDE_CONFIG_DIR"]) { + for (const key of ["HOME", "USERPROFILE", "OPENCODEX_HOME", "CODEX_HOME", "GROK_HOME", "CLAUDE_CONFIG_DIR", "OCX_OWNER_REGISTRY_DIR"]) { if (originalEnv[key] === undefined) delete process.env[key]; else process.env[key] = originalEnv[key]; } @@ -438,7 +438,7 @@ test("a registered custom-home owner is proven through its own runtime record", resetSiblingStartForTests(); registerOwnerRegistryHome(homeA); - expect(readOwnerRegistryHomes()).toContain(homeA); + expect(readOwnerRegistry().homes).toContain(homeA); expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); }); @@ -519,7 +519,58 @@ test("an attested sibling record defers to the owner port it names", async () => test("publishing a runtime record registers the home for cross-home discovery", () => { const fx = fixture(); writeRuntimePort({ pid: process.pid, port: 0 }); - expect(readOwnerRegistryHomes()).toContain(fx.ocx); + expect(readOwnerRegistry().homes).toContain(fx.ocx); +}); + +test("removing a runtime record retires its registry pointer", () => { + const fx = fixture(); + writeRuntimePort({ pid: process.pid, port: 42101 }); + expect(readOwnerRegistry().homes).toContain(fx.ocx); + removeRuntimePort(process.pid); + expect(readOwnerRegistry().homes).not.toContain(fx.ocx); +}); + +test("stale registry pointers are pruned before they can crowd out a live owner", async () => { + const fx = fixture(); + // More dead pointers than the entry cap, each naming a home that no longer + // publishes a record - the pile must not hide the registered live owner. + // Pointer files are written directly: the production register call pays an + // atomic fsync per entry, which alone would blow the test's own budget here. + mkdirSync(ownerRegistryDir(), { recursive: true }); + for (let i = 0; i < 70; i++) { + writeFileSync(join(ownerRegistryDir(), "dead-" + i + ".json"), JSON.stringify({ home: join(fx.root, "dead" + i, ".opencodex") })); + } + const ownerPid = process.pid + 1; + const port = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port, attestationSecret: TEST_ATTESTATION_SECRET, + })); + registerOwnerRegistryHome(homeA); + const registry = readOwnerRegistry(); + expect(registry.homes).toContain(homeA); + expect(registry.truncated).toBe(false); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); +}); + +test("a registry listing too deep to scan fully reports truncation instead of a false none", async () => { + const fx = fixture(); + // Entries whose homes still publish records pass the existence prune, so the + // result cap is the bound that actually cuts off the owner - truncation, not + // a confident none, is the only honest answer left. + mkdirSync(ownerRegistryDir(), { recursive: true }); + for (let i = 0; i < 70; i++) { + const deadHome = join(fx.root, "full" + i, ".opencodex"); + mkdirSync(deadHome, { recursive: true }); + writeFileSync(join(deadHome, "runtime-port.json"), JSON.stringify({ pid: process.pid + 1000 + i, port: 1 })); + writeFileSync(join(ownerRegistryDir(), "full-" + i + ".json"), JSON.stringify({ home: deadHome })); + } + const registry = readOwnerRegistry(); + expect(registry.truncated).toBe(true); + expect(registry.homes.length).toBe(64); + const verdict = await findCrossHomeOwnerDetailed({ homeDir: fx.home }); + expect(verdict.kind).toBe("indeterminate"); }); /** @@ -595,3 +646,44 @@ test("a live legacy record without an attestation secret vetoes shared writes en registerOwnerRegistryHome(homeA); await secondaryStartPreservesBytes(fx, ownerPort, ownerPort); }, 30_000); + +test("a registry-only discovered owner vetoes shared writes end to end", async () => { + const fx = fixture(); + // No managed URL in any shared client: the registry pointer is the only way a + // secondary can find this owner, so the e2e proves registry discovery rather + // than the URL-hint path the other end-to-end cases already cover. + const ownerPid = process.pid + 1; + const ownerPort = healthServer(ownerPid); + const homeA = join(fx.root, "homeA", ".opencodex"); + mkdirSync(homeA, { recursive: true }); + writeFileSync(join(homeA, "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port: ownerPort, attestationSecret: TEST_ATTESTATION_SECRET, + })); + registerOwnerRegistryHome(homeA); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + const claudePath = join(fx.claude, "agents", "ocx-existing.md"); + writeFileSync(grokPath, "# user content\n"); + writeFileSync(codexPath, "model = \"gpt-6\"\n"); + writeFileSync(claudePath, "owned roster bytes\n"); + const before = [grokPath, codexPath, claudePath].map(path => readFileSync(path)); + const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); + const secondaryPort = reservation.port; + reservation.stop(true); + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify({ + port: secondaryPort, hostname: "127.0.0.1", codexAutoStart: false, syncResumeHistory: false, + checkForUpdates: false, clientIntegrations: { codex: true, grok: true, "claude-desktop": false }, + claudeCode: { injectAgents: false, systemEnv: false }, providers: {}, defaultProvider: "openai", + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(secondaryPort)], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(child); + const runtime = await waitForRuntime(join(fx.ocx, "runtime-port.json"), child); + expect(runtime.siblingOfPort).toBe(ownerPort); + await waitForClientStartup(child); + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); + child.kill("SIGTERM"); + await child.exited; + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); +}, 30_000); From 8dbb264300688e2813688eee7091c60150053273 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 29 Sep 2026 01:42:46 +0000 Subject: [PATCH 10/10] fix(cli): refuse unlocated cross-home ownership before shared writes An indeterminate discovery with no port cannot establish the process-local sibling marker. Refuse startup and ensure instead of returning true while leaving shared-client writer gates open. Keep known-port and local handoff behaviour unchanged, and add a registry-truncation regression. Validated original/patched marking function bodies with a deterministic Node source-excerpt harness. Repository Bun integration tests remain for exact-head CI; no running proxy or user client configuration was touched. --- src/cli/cross-home-owner.ts | 5 +++-- tests/cli/sibling-home-client-sync.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 299cfd2a07a..ceafe48dde9 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -294,6 +294,7 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io export async function markCrossHomeSibling(): Promise { const verdict = await findCrossHomeOwnerDetailed(); if (verdict.kind === "none") return false; + if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write."); if (verdict.kind === "indeterminate") { // Fail closed: an owner the reader could not verify still owns the shared // writes. Marking the best port hint keeps every sibling gate engaged even @@ -304,7 +305,6 @@ export async function markCrossHomeSibling(): Promise { ); } const port = verdict.port; - if (port === null) return true; markSiblingStart(port); return true; } @@ -319,7 +319,8 @@ export async function markLiveHomeSibling(live: { pid: number | null; port: numb } const verdict = await findCrossHomeOwnerDetailed(); if (verdict.kind === "none" || verdict.port === live.port) return false; - if (verdict.port !== null) markSiblingStart(verdict.port); + if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write."); + markSiblingStart(verdict.port); return true; } diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts index f0f43259e26..0a2d1bc0407 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -573,6 +573,25 @@ test("a registry listing too deep to scan fully reports truncation instead of a expect(verdict.kind).toBe("indeterminate"); }); +test("an unlocated owner refuses start and ensure rather than claiming an unmarked sibling", async () => { + const fx = fixture(); + mkdirSync(ownerRegistryDir(), { recursive: true }); + // Existing but malformed records pass the registry's existence check without + // supplying a port to probe. Its result cap leaves ownership indeterminate. + for (let i = 0; i < 65; i++) { + const home = join(fx.root, "unlocated-" + i); + mkdirSync(home); + writeFileSync(join(home, "runtime-port.json"), "{}\n"); + writeFileSync(join(ownerRegistryDir(), "unlocated-" + i + ".json"), JSON.stringify({ home })); + } + expect(readOwnerRegistry().truncated).toBe(true); + expect(await findCrossHomeOwnerDetailed({ homeDir: fx.home })).toMatchObject({ kind: "indeterminate", port: null }); + await expect(markCrossHomeSibling()).rejects.toThrow("refusing startup"); + expect(siblingOfLivePort()).toBeNull(); + await expect(markLiveHomeSibling({ pid: process.pid, port: 42101 })).rejects.toThrow("refusing startup"); + expect(siblingOfLivePort()).toBeNull(); +}); + /** * Shared start-to-shutdown acceptance for the ownership topologies that must veto * shared-client writes: the managed Grok/Codex routing and the Claude roster keep