Repository navigation
fix(start): recover a same-host record left by an earlier lifetime of this PID #8562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f2b3d30
46207d0
64e23ec
eccd2cc
9731049
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import { existsSync, mkdirSync, readFileSync, readlinkSync, unlinkSync } from "node:fs"; | ||
| import { hostname } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { isLocalAttestationSecret } from "../lib/local-management-attestation"; | ||
| import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; | ||
|
|
@@ -27,8 +29,122 @@ function ensureProcessStateDir(): void { | |
| else hardenConfigDir(); | ||
| } | ||
|
|
||
| /** | ||
| * Whether this process has written `ocx.pid` or `runtime-port.json` in its own lifetime (#8552). | ||
| * | ||
| * Set before the write, deliberately even when the write then throws, and never cleared: a record | ||
| * this process wrote (or may have written) and later removed still means its PID once named a live | ||
| * owner of this home, so it can never be told apart from an earlier lifetime of the same PID number. | ||
| * Moving the assignment after the write would let a failed write fail open. | ||
| */ | ||
| let publishedOwnershipRecords = false; | ||
|
|
||
| export function hasPublishedOwnershipRecords(): boolean { | ||
| return publishedOwnershipRecords; | ||
| } | ||
|
|
||
| /** | ||
| * Where the owner of a runtime record ran, as digests that store none of their inputs (#8552). | ||
| * | ||
| * Every container runs the proxy as PID 1, so a PID alone cannot say whether a record came from | ||
| * this container's previous run or from another container serving the same state volume. The | ||
| * `host` name must match, and on Linux at least one of two stronger facts as well | ||
| * ({@link sameRuntimeOwnerScope}): | ||
| * | ||
| * - `network` with `boot`, the network namespace within one boot: a container restarted inside | ||
| * its Kubernetes pod keeps it, because the pod sandbox owns it. Namespace inode numbers repeat | ||
| * across machines and boots, so the boot id has to match too; | ||
| * - `container`, the writable layer of the overlay mounted at `/` (its `upperdir`), counted only | ||
| * when its path carries a random 64-hex layer id (Docker's `overlay2` driver, containers/storage | ||
| * for Podman and CRI-O): a container started again keeps it, across a host reboot as well, and | ||
| * no other container ever mounts it, not even one sharing its network (`--network container:`) | ||
| * or its pod. A layer named by a sequence number (containerd's overlayfs snapshotter, which | ||
| * nodes built alike hand out identically) or a host whose own root is an overlay proves nothing | ||
| * beyond one machine, so it is not used; nor is a container id, which a container joined to | ||
| * another's network sees in mountinfo. | ||
| * | ||
| * A replacement pod, a second container with a fixed `hostname`, or another node matches neither. | ||
| * Processes that share one network namespace (host networking, pod sidecars, WSL distros, which | ||
| * share one VM) can match on `network`; for them the loopback probe still finds a listening owner. | ||
| * Elsewhere the PID space is the whole machine and the host name suffices. Linux evidence that | ||
| * cannot be read yields a per-process value that matches no other lifetime, so the self-record | ||
| * proof fails closed. | ||
| */ | ||
| export interface RuntimeOwnerScope { | ||
| host: string; | ||
| boot?: string; | ||
| network?: string; | ||
| container?: string; | ||
| } | ||
|
|
||
| let cachedRuntimeOwnerScope: RuntimeOwnerScope | undefined; | ||
|
|
||
| export function runtimeOwnerScope(): RuntimeOwnerScope { | ||
| cachedRuntimeOwnerScope ??= computeRuntimeOwnerScope(); | ||
| return cachedRuntimeOwnerScope; | ||
| } | ||
|
|
||
| function scopeDigest(...parts: string[]): string { | ||
| return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 32); | ||
| } | ||
|
|
||
| /** | ||
| * The `upperdir` of the overlay mounted at `/` when it names a randomly identified writable layer | ||
| * (a `/<64 hex>/` path segment): a container's own layer, unique across machines. The last root | ||
| * mount wins, as it is the one visible. Any other root filesystem (bare host, btrfs/zfs/vfs drivers, | ||
| * fuse-overlayfs, an overlay host root, containerd's sequence-numbered snapshots) yields nothing. | ||
| */ | ||
| export function rootOverlayUpperdirFromMountinfo(mountinfo: string): string | undefined { | ||
| let upperdir: string | undefined; | ||
| for (const line of mountinfo.split("\n")) { | ||
| const separator = line.indexOf(" - "); | ||
| if (separator < 0) continue; | ||
| const fields = line.slice(0, separator).split(" "); | ||
| if (fields[4] !== "/") continue; | ||
| const [fstype, , superOptions = ""] = line.slice(separator + 3).split(" "); | ||
| const match = fstype === "overlay" ? /(?:^|,)upperdir=([^,]+)/.exec(superOptions) : null; | ||
| upperdir = match && /\/[0-9a-f]{64}\//.test(match[1]) ? match[1] : undefined; | ||
| } | ||
| return upperdir; | ||
| } | ||
|
|
||
| function readOptional(read: () => string): string | undefined { | ||
| try { return read(); } catch { return undefined; } | ||
| } | ||
|
|
||
| function computeRuntimeOwnerScope(): RuntimeOwnerScope { | ||
| if (process.platform !== "linux") return { host: scopeDigest(process.platform, hostname()) }; | ||
| const boot = readOptional(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()); | ||
| const network = readOptional(() => readlinkSync("/proc/self/ns/net")); | ||
| const container = readOptional(() => rootOverlayUpperdirFromMountinfo(readFileSync("/proc/self/mountinfo", "utf8")) ?? ""); | ||
| const scope: RuntimeOwnerScope = { host: scopeDigest("linux", hostname()) }; | ||
| if (boot && /^[0-9a-f-]{36}$/.test(boot) && network && /^net:\[\d+\]$/.test(network)) { | ||
| scope.boot = scopeDigest("boot", boot); | ||
| scope.network = scopeDigest("net", network); | ||
| } | ||
| if (container) scope.container = scopeDigest("container", container); | ||
| if (!scope.network && !scope.container) return { host: scopeDigest("unverifiable", randomUUID()) }; | ||
| return scope; | ||
| } | ||
|
|
||
| /** | ||
| * Whether a recorded owner scope proves the record was written where this process runs. Only an | ||
| * exact, well-formed match counts; anything else (absent, malformed, partial) is "no". | ||
| */ | ||
| export function sameRuntimeOwnerScope(recorded: unknown, own: RuntimeOwnerScope): boolean { | ||
| if (!recorded || typeof recorded !== "object" || Array.isArray(recorded)) return false; | ||
| const record = recorded as Record<string, unknown>; | ||
| if (typeof record.host !== "string" || record.host !== own.host) return false; | ||
| if (own.network === undefined && own.container === undefined) { | ||
| return record.boot === undefined && record.network === undefined && record.container === undefined; | ||
| } | ||
| return (own.network !== undefined && record.network === own.network && own.boot !== undefined && record.boot === own.boot) | ||
| || (own.container !== undefined && record.container === own.container); | ||
|
Comment on lines
+137
to
+138
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject malformed optional fields before accepting an owner scope. If a record contains a matching
|
||
| } | ||
|
|
||
| export function writePid(pid: number): void { | ||
| ensureProcessStateDir(); | ||
| publishedOwnershipRecords = true; | ||
| atomicWriteFile(getPidPath(), String(pid)); | ||
| } | ||
|
|
||
|
|
@@ -45,9 +161,16 @@ export type RuntimePortState = { | |
| /** | ||
| * The live proxy's port when this runtime is a sibling instance started beside it | ||
| * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. | ||
| * Absent for every other runtime, so those records keep their bytes. | ||
| * Absent for every other runtime. | ||
| */ | ||
| siblingOfPort?: number; | ||
| /** | ||
| * {@link runtimeOwnerScope} of the owner, stamped by {@link writeRuntimePort} when this process | ||
| * writes its own record. Not validated here: a malformed value must not hide a live owner from | ||
| * status, stop or liveness, and the one reader that uses it (`observeProxyLiveness`) accepts | ||
| * only an exact match ({@link sameRuntimeOwnerScope}). | ||
| */ | ||
| ownerScope?: RuntimeOwnerScope; | ||
| }; | ||
|
|
||
| export function isValidRuntimePortState(value: unknown): value is RuntimePortState { | ||
|
|
@@ -70,7 +193,12 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta | |
|
|
||
| export function writeRuntimePort(state: RuntimePortState): void { | ||
| ensureProcessStateDir(); | ||
| atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n"); | ||
| publishedOwnershipRecords = true; | ||
| // Only the owner can say where it runs; a record written on another process's behalf keeps | ||
| // whatever identity it already carried. | ||
| const ownerScope = state.pid === process.pid ? runtimeOwnerScope() : state.ownerScope; | ||
| const record = { ...state, ...(ownerScope === undefined ? {} : { ownerScope }) }; | ||
| atomicWriteFile(getRuntimePortPath(), JSON.stringify(record, 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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Link the new recovery procedure from localized deployment guides.
The Japanese, Korean, Russian, and Simplified Chinese remote-hub guides describe the container deployment, but omit this new refusal and one-time recovery procedure. A reader using those guides cannot find the steps for an unclean restart that does not recover automatically. Add a link to this canonical English section from each affected deployment guide. As per coding guidelines, “Update all directly affected pages when a user workflow changes” and “Do not add duplicated policy text when a stable canonical document can be linked instead.” (raw.githubusercontent.com)
Source: Coding guidelines