diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 78555e3af2..ceafe48dde 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -13,12 +13,80 @@ 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 { readOwnerRegistry } 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; @@ -60,20 +128,44 @@ 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(); + 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" && validPort((record as { port?: unknown }).port)) { - candidates.add((record as { port: number }).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. + 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); @@ -96,17 +188,123 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr } } catch { /* an absent or invalid client home is not owner evidence */ } - for (const port of candidates) { - const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS); - if (owner && Number.isSafeInteger(owner.pid) && owner.pid! > 0 && owner.pid !== process.pid) 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; + } + }; + + 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) { + 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. + 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. + } + } } - return null; + + if (indeterminateReason !== null) { + return { kind: "indeterminate", port: indeterminatePort, reason: indeterminateReason }; + } + 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.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 + // 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; markSiblingStart(port); return true; } @@ -119,9 +317,10 @@ 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) 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/src/cli/index.ts b/src/cli/index.ts index 6610cb3d2e..3d2ee50b83 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 0000000000..f2838a49ed --- /dev/null +++ b/src/config/owner-registry.ts @@ -0,0 +1,154 @@ +/** + * 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 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"; +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"; +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; +// 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 { + // 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. */ +export function ownerRegistryDir(): string { + const override = process.env[REGISTRY_DIR_ENV]?.trim(); + if (override) return resolve(override); + 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 { + 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), + JSON.stringify({ home: resolve(home), v: 1 }) + "\n", + ); + } 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, 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 readOwnerRegistry(): OwnerRegistryRead { + const dir = ownerRegistryDir(); + let names: string[]; + try { + names = readdirSync(dir) + .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX)); + } catch { + return { homes: [], truncated: false }; + } + let truncated = names.length > MAX_REGISTRY_LISTING; + const homes: string[] = []; + for (const name of names.slice(0, MAX_REGISTRY_LISTING)) { + 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) 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, 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. */ +export function registerOwnHome(): void { + registerOwnerRegistryHome(getConfigDir()); +} diff --git a/src/config/process-state.ts b/src/config/process-state.ts index cb98672acd..496741a474 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, unregisterOwnerRegistryHome } 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 { @@ -100,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/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index a7b027b736..ea0079c0ab 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,31 +266,50 @@ 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)(); - 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 "indeterminate"; + 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 "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 "indeterminate"; + await sleepFn(100); + } } + return "indeterminate"; } /** A bounded version string safe to carry beyond the untrusted health response. */ @@ -317,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, @@ -391,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/structure/codex-home.md b/structure/codex-home.md index e396045da4..90ff9cf391 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 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/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index d038b85ee7..2b89ca1c82 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 163861d20f..0a2d1bc040 100644 --- a/tests/cli/sibling-home-client-sync.test.ts +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -2,9 +2,17 @@ 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 { 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"; +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 +21,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-")); @@ -28,19 +37,39 @@ 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 }; } -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, - fetch: () => Response.json({ service, status: "ok", version: "0.0.0", uptime: 1, pid }), + 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); + 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`; } @@ -93,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]; } @@ -116,19 +145,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 +168,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 +198,50 @@ 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, attestationSecret: TEST_ATTESTATION_SECRET })); + 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); - 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 }); test.skipIf(process.platform === "win32")("a FIFO in place of a hint file cannot stall discovery", async () => { @@ -180,9 +254,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 +267,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 +294,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 +312,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 +341,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); @@ -330,3 +418,291 @@ 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(readOwnerRegistry().homes).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(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"); +}); + +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 + * 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); + +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); diff --git a/tests/server/proxy-liveness-package-tree-fence.test.ts b/tests/server/proxy-liveness-package-tree-fence.test.ts index db35375acf..14b8a083df 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); }); @@ -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("proven"); + expect(calls).toBe(3); + }); + + 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("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("refuted"); + 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("indeterminate"); + expect(calls).toBe(5); + }); +});