From c55b149888b1642731c68f75be895e35ad9d16ae Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:35:44 +0900 Subject: [PATCH 1/7] fix(cli): preserve default bind identity for guarded stops Publish the bound hostname and preserve legacy missing-hostname records while keeping stop and update-restart identity checks consistent across replacement. Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- .../content/docs/reference/cli/lifecycle.md | 6 ++ src/cli/index.ts | 2 +- src/cli/stop-approval.ts | 4 +- src/cli/system-restart-client.ts | 5 ++ src/cli/update-restart-child.ts | 16 ++++- src/cli/update-restart-home.ts | 9 ++- src/cli/update-restart.ts | 44 +++++++------ src/config/tracked-runtime-inspection.ts | 6 +- structure/config.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/runtime.md | 2 +- tests/cli/cli-restart-health.test.ts | 4 +- tests/cli/cli-stop-json.test.ts | 39 +++++++++++ .../cli/cli-update-restart-admission.test.ts | 3 +- tests/cli/cli-update-restart-child.test.ts | 11 +++- tests/cli/cli-update-restart-home.test.ts | 30 ++++++++- tests/cli/cli-update-restart.test.ts | 64 ++++++++++++++++++- tests/cli/system-restart-client.test.ts | 19 +++++- tests/service/process-state.test.ts | 7 ++ tests/service/shutdown-launcher.test.ts | 3 + 20 files changed, 235 insertions(+), 43 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 841ac656659..127407b5283 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -152,6 +152,12 @@ unreadable command line in the approved proxy's supervision chain (or surviving stopped manager) leaves wrapper ownership unknown, so the guarded stop cannot report success. Missing or unreadable evidence blocks the guarded stop. +New runtime records include the bound hostname. For older records that omit it, a guarded +stop accepts the discovered default loopback address only when the remaining approval and +ownership checks agree. An omitted hostname cannot independently prove the listener's bind +scope. When the record contains a hostname, it must match the approved value exactly; +wildcard and loopback binds remain distinct. + ### `ocx restart` When this CLI is newer than an attested standalone POSIX proxy, restart can stop the old diff --git a/src/cli/index.ts b/src/cli/index.ts index b95c6fdbd39..f42e69e942c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -606,7 +606,7 @@ async function handleStart(options: { block?: boolean } = {}) { writeRuntime: bound => { updateRestartChild?.check(bound.port, bound.config.hostname ?? ""); writeRuntimePort({ pid: process.pid, port: bound.port, - hostname: bound.config.hostname, + hostname: bound.server.hostname, attestationSecret: bound.localAttestationSecret, command: currentServingCommand(), ...siblingRuntimeField(), diff --git a/src/cli/stop-approval.ts b/src/cli/stop-approval.ts index 5a68879e8d5..0e45cca200f 100644 --- a/src/cli/stop-approval.ts +++ b/src/cli/stop-approval.ts @@ -27,7 +27,7 @@ export interface GuardedStopSnapshot { * inspection probe -- it carries no path, command line, stderr, or token. */ export type TrackedRuntimeCheck = - | { pid: number; port: number; hostname: string } + | { pid: number; port: number; hostname: string | undefined } | { untracked: string } | null; @@ -145,7 +145,7 @@ export async function runApprovedStop( return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`); } if (target.pid !== expected.pid || target.port !== expected.port - || target.hostname !== expected.hostname) { + || (target.hostname === undefined ? expected.hostname !== "127.0.0.1" : target.hostname !== expected.hostname)) { return approvalChanged("the tracked runtime record changed"); } if (manager.kind === "unknown") { diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index be8241d5e35..4d5b5b53502 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -164,6 +164,11 @@ export async function requestBoundSystemRestart( if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed"); if (skew.relation === "cli-newer") { if (!updateHome) return rejected("update_restart_home_unverified"); + try { + if (JSON.stringify((deps.readUpdateHome ?? readUpdateRestartHome)()) !== JSON.stringify(updateHome)) { + return rejected("update_restart_home_changed"); + } + } catch { return rejected("update_restart_home_changed"); } const current = readRuntime(target.pid); if (!current || current.pid !== runtime.pid || current.port !== runtime.port || current.hostname !== runtime.hostname || current.attestationSecret !== runtime.attestationSecret) { diff --git a/src/cli/update-restart-child.ts b/src/cli/update-restart-child.ts index e2fcc37bc76..8fea36461fd 100644 --- a/src/cli/update-restart-child.ts +++ b/src/cli/update-restart-child.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { isIP } from "node:net"; import { getRuntimePortPath, readRuntimePort } from "../config/process-state"; import { packageVersion } from "../lib/package-version"; import { parseStrictSemver } from "../lib/strict-semver"; @@ -10,12 +11,21 @@ export const UPDATE_RESTART_CHILD_ENV = "OCX_UPDATE_RESTART_CHILD"; export interface UpdateRestartChildMarker { home: UpdateRestartHome; version: string; port: number; hostname: string; deadlineAt: number } export interface UpdateRestartChildGuard { check(port?: number, hostname?: string): void; complete(): void } -function parseMarker(raw: string): UpdateRestartChildMarker { +function safeBindHost(host: string): boolean { + if (!host || host.length > 255 || host !== host.trim()) return false; + if (host.startsWith("[") || host.endsWith("]")) { + return host.startsWith("[") && host.endsWith("]") && isIP(host.slice(1, -1)) === 6; + } + return isIP(host) !== 0 || /^(?:\*|0|(0+\.){3}0+)\.?$/.test(host); +} + +export function parseUpdateRestartMarker(raw: string): UpdateRestartChildMarker { if (raw.length > 8192) throw new Error("update_restart_child_marker_invalid"); const value = JSON.parse(raw) as UpdateRestartChildMarker; if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port) - || typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string" + || typeof value.hostname !== "string" || !safeBindHost(value.hostname) || value.port < 1 || value.port > 65535 || typeof value.version !== "string" || value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home + || !(value.home.diagnosedHostname === null || (typeof value.home.diagnosedHostname === "string" && safeBindHost(value.home.diagnosedHostname))) || !Number.isSafeInteger(value.home.revision) || value.home.serviceRecord?.schema !== 1 || typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid"); for (const directory of [value.home.config, value.home.codex]) { @@ -49,7 +59,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo if (raw === undefined) return undefined; delete env[UPDATE_RESTART_CHILD_ENV]; delete env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; - const marker = parseMarker(raw); + const marker = parseUpdateRestartMarker(raw); if (argv.length !== 3 || argv[0] !== "start" || argv[1] !== "--port" || argv[2] !== String(marker.port)) { throw new Error("update_restart_child_command_changed"); } diff --git a/src/cli/update-restart-home.ts b/src/cli/update-restart-home.ts index 842f2cbcb26..eb10668e156 100644 --- a/src/cli/update-restart-home.ts +++ b/src/cli/update-restart-home.ts @@ -18,6 +18,7 @@ export interface UpdateRestartHome { codex: UpdateRestartDirectory; revision: number; serviceRecord: UpdateRestartServiceRecord; + diagnosedHostname: string | null; } export type UpdateRestartDirectoryStat = Pick; @@ -44,7 +45,9 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR throw new Error("update_restart_owner_unverified"); } const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true })); - return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord }; + const diagnostics = readConfigDiagnostics({ passive: true }); + if (diagnostics.error) throw new Error("update_restart_configuration_unverified"); + return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord, diagnosedHostname: diagnostics.config.hostname ?? null }; } export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void { @@ -62,7 +65,7 @@ export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt /** Read-only eligibility shared by parent pre-stop and child admission. */ export function assertUpdateRestartConfiguration(hostname: string): void { - const diagnostics = readConfigDiagnostics(); + const diagnostics = readConfigDiagnostics({ passive: true }); if (readClientConnectionState().kind !== "disconnected" || diagnostics.error - || (diagnostics.config.hostname ?? "") !== hostname) throw new Error("update_restart_configuration_changed"); + || (diagnostics.config.hostname === undefined ? hostname !== "127.0.0.1" : diagnostics.config.hostname !== hostname)) throw new Error("update_restart_configuration_changed"); } diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 98bedf7c85c..59fc1e34e9f 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -12,7 +12,8 @@ import { acquireOwnershipMutationLease, unprivilegedOwnershipMutationEnvironment import { serviceStatePaths } from "../service/state"; import { findLiveProxy, probeEndpointLiveness, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { waitForPortAvailable } from "../server/ports"; -import { UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildMarker } from "./update-restart-child"; +import { normalizeBindHostname } from "../server/bind-host"; +import { UPDATE_RESTART_CHILD_ENV, parseUpdateRestartMarker, type UpdateRestartChildMarker } from "./update-restart-child"; import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome, type UpdateRestartHomeDeps } from "./update-restart-home"; import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "./update-restart-transport"; import type { UpdateRestartCandidate } from "./update-restart-candidate"; @@ -74,6 +75,9 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli lease = io.acquire(); const home = candidate.home; if (JSON.stringify(io.home()) !== JSON.stringify(home)) throw new Error("home_changed"); + const marker = parseUpdateRestartMarker(JSON.stringify({ home, version: candidate.cliVersion, port: candidate.target.port, + hostname: normalizeBindHostname(candidate.runtime.hostname), deadlineAt })); + if (normalizeBindHostname(candidate.target.hostname) !== marker.hostname) throw new Error("target"); const revalidate = () => { withinDeadline(); io.checkHome(home); @@ -122,7 +126,7 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli withinDeadline(); io.checkHome(home); phase = "start"; - const child = io.start({ home, version: candidate.cliVersion, port: candidate.target.port, hostname: candidate.runtime.hostname ?? "", deadlineAt }); + const child = io.start(marker); // The child must acquire its own lease and validate this frozen handoff before preflight. lease.release(); lease = undefined; @@ -136,7 +140,7 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli if (live) { io.checkHome(home); if (live.source !== "runtime" || live.pid !== child.pid || live.port !== candidate.target.port - || (live.hostname ?? "") !== (candidate.target.hostname ?? "") || live.role === "client" + || !live.hostname || normalizeBindHostname(live.hostname) !== marker.hostname || live.role === "client" || live.packageTreeFenced || live.version !== candidate.cliVersion) throw new Error("replacement_mismatch"); return { ok: true, live }; } @@ -253,6 +257,24 @@ export function standalone(target: UpdateRestartCandidate["target"], deadlineAt: } /** Production composition keeps stop/start side effects out of cli/index.ts. */ +/** Verify the new runtime record, its own proof, and an exact reread after attestation. */ +export async function observeUpdateRestartReplacement(candidate: UpdateRestartCandidate, deadline: number, childPid: number, + deps: { findLive: typeof findLiveProxy; readRuntime: typeof readRuntimePort; attest: typeof observeAttestedUpdateReplacement } = + { findLive: findLiveProxy, readRuntime: readRuntimePort, attest: observeAttestedUpdateReplacement }): Promise { + const live = await deps.findLive({ deadlineAt: deadline, attempts: 1 }); + if (!live) return null; + if (live.pid !== childPid) throw new Error("update_restart_replacement_changed"); + const runtime = deps.readRuntime(childPid); + if (!runtime?.attestationSecret || runtime.pid !== childPid || runtime.port !== candidate.target.port + || !runtime.hostname || normalizeBindHostname(runtime.hostname) !== normalizeBindHostname(candidate.runtime.hostname) || runtime.siblingOfPort !== undefined) { + throw new Error("update_restart_replacement_unattested"); + } + const attested = await deps.attest({ target: { ...live, pid: childPid }, + secret: runtime.attestationSecret, version: candidate.cliVersion, deadlineAt: deadline }); + if (JSON.stringify(deps.readRuntime(childPid)) !== JSON.stringify(runtime)) throw new Error("update_restart_replacement_changed"); + return attested; +} + export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate, deadlineAt: number, environment: NodeJS.ProcessEnv): Promise { const argv = startArgv(candidate.target.port); const executable = process.execPath; @@ -286,21 +308,7 @@ export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate child.unref(); return child; }, - observe: async (deadline, childPid) => { - const live = await findLiveProxy({ deadlineAt: deadline, attempts: 1 }); - if (!live) return null; - if (live.pid !== childPid) throw new Error("update_restart_replacement_changed"); - const runtime = readRuntimePort(childPid); - if (!runtime?.attestationSecret || runtime.port !== candidate.target.port - || runtime.hostname !== candidate.runtime.hostname || runtime.siblingOfPort !== undefined) { - throw new Error("update_restart_replacement_unattested"); - } - const attested = await observeAttestedUpdateReplacement({ target: { ...live, pid: childPid }, - secret: runtime.attestationSecret, version: candidate.cliVersion, deadlineAt: deadline }); - const current = readRuntimePort(childPid); - if (JSON.stringify(current) !== JSON.stringify(runtime)) throw new Error("update_restart_replacement_changed"); - return attested; - }, + observe: (deadline, childPid) => observeUpdateRestartReplacement(candidate, deadline, childPid), wait: ms => Bun.sleep(ms), }); } diff --git a/src/config/tracked-runtime-inspection.ts b/src/config/tracked-runtime-inspection.ts index 6cc241e5f19..f3f2f7431e6 100644 --- a/src/config/tracked-runtime-inspection.ts +++ b/src/config/tracked-runtime-inspection.ts @@ -59,7 +59,7 @@ export type TrackedRecordRefusal = | "unverified"; export type TrackedRecordInspection = - | { status: "verified"; pid: number; port: number; hostname: string } + | { status: "verified"; pid: number; port: number; hostname: string | undefined } | { status: "refused"; reason: TrackedRecordRefusal }; /** @@ -147,7 +147,7 @@ export function inspectTrackedRuntimeRecord( if (parsed.pid !== pid) { return { status: "refused", reason: "runtime-record-pid-mismatch" }; } - return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname ?? "" }; + return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname }; } catch { return { status: "refused", reason: "unverified" }; } @@ -180,7 +180,7 @@ const TRACKED_REFUSAL_CAUSES: Record = { */ export function describeTrackedRuntimeTarget( deps: TrackedRecordProbeDeps = {}, -): { pid: number; port: number; hostname: string } | { untracked: string } { +): { pid: number; port: number; hostname: string | undefined } | { untracked: string } { const inspection = inspectTrackedRuntimeRecord(deps); return inspection.status === "verified" ? { pid: inspection.pid, port: inspection.port, hostname: inspection.hostname } diff --git a/structure/config.md b/structure/config.md index a0b53b198d4..93bd6bb8962 100644 --- a/structure/config.md +++ b/structure/config.md @@ -64,7 +64,7 @@ silently move the active installation. Config reads in this leaf and `src/config `withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. Timer-driven caches under the config root admit their write the same way and build the snapshot inside the callback: the provider account quota cache (`src/providers/account-quota-disk.ts`), the Codex account quota cache (`src/codex/quota.ts`) and the reasoning support cache, while a models.dev refresh admits its publication after the network read (`src/providers/reasoning-metadata.ts`). A timed-out admission skips a best-effort cache write and fails that refresh unpublished, a joined refresh reports the same failure, and a cancel, clear or reset invalidates a write still waiting for admission (#7815; `tests/providers/provider-account-quota-persistence.test.ts`, `tests/codex-integration/reasoning-metadata.test.ts`). -`src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. Ordinary and connected-client runtimes publish their absolute relaunch command in the runtime record; older records can omit it. +`src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. Ordinary and connected-client runtimes publish their absolute relaunch command in the runtime record; older records can omit it. Tracked runtime inspection retains an omitted hostname as `undefined` for guarded stop's legacy-record check. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional, owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 9920d530564..948c6662135 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -55,7 +55,7 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity and physical homes. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from `src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit diff --git a/structure/runtime.md b/structure/runtime.md index 940ed94c02d..f3c2b17aacf 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -117,7 +117,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock and a read-only authority reader for admitted writers that starts no optional ACL flight. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. No-follow exclusive publication reuses its private descriptor writer for raw recovery bytes, checks bigint identity and publishes by a no-replace hard link; cleanup never scrubs an already published inode. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. The existing-secret compliance probe in `src/lib/windows-secret-acl.ts` uses the same SID-based private-entry policy as recovery-copy reuse when the opt-in single-owner ACE fast check cannot establish compliance; recovery-copy reuse always calls the SID policy directly. Retained SYSTEM and Administrators ACEs do not trigger mutation or duplicate recovery publication. The asynchronous probe in `src/lib/windows-owner-acl.ts` yields during the bounded descriptor subprocess and shares the synchronous parser, without admitting a hardening success memo. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. The optional `clientStopping: true` field fences Link sync through signal restoration and the final diagnostic flush; `src/client/sync-runtime.ts` compares the runtime generation before each sync commit. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | +| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. New records publish the bound hostname. Guarded stop compares a present hostname exactly; an omission is accepted only for an approved default loopback and cannot itself prove bind scope. The optional `clientStopping: true` field fences Link sync through signal restoration and the final diagnostic flush; `src/client/sync-runtime.ts` compares the runtime generation before each sync commit. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | diff --git a/tests/cli/cli-restart-health.test.ts b/tests/cli/cli-restart-health.test.ts index 017c087129c..e51405ca4ae 100644 --- a/tests/cli/cli-restart-health.test.ts +++ b/tests/cli/cli-restart-health.test.ts @@ -401,8 +401,8 @@ const NON_DESKTOP: SupervisionEvidence[] = [{ kind: "none" }, { kind: "unknown", reason: "probe-timeout", desktopSeen: false }, { kind: "unsupported" }]; const RESTART_TARGET: ProxyRestartLive = { pid: 4242, port: 19999, source: "runtime" }; const UPDATE_CANDIDATE: UpdateRestartCandidate = { - home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }, - target: { ...RESTART_TARGET, pid: 4242, version: "2.76.0" }, + home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1, digest: "a".repeat(64) }, diagnosedHostname: null }, + target: { ...RESTART_TARGET, pid: 4242, hostname: "127.0.0.1", version: "2.76.0" }, runtime: { pid: 4242, port: 19999, attestationSecret: "a".repeat(43) }, cliVersion: "2.77.0", }; function desktopRestartFixture() { diff --git a/tests/cli/cli-stop-json.test.ts b/tests/cli/cli-stop-json.test.ts index 5394f8256ac..0e55a5212f8 100644 --- a/tests/cli/cli-stop-json.test.ts +++ b/tests/cli/cli-stop-json.test.ts @@ -21,6 +21,7 @@ import { type StopApproval, } from "../../src/cli/stop-approval"; import type { ResolveJson } from "../../src/cli/resolve"; +import { describeTrackedRuntimeTarget } from "../../src/config/tracked-runtime-inspection"; /** * A test batch shares ONE Bun process, and Bun does not clear process.exitCode on a @@ -413,6 +414,44 @@ const BOUND_STOP_MANAGER = () => ({ kind: "bound" as const, pid: 42, managerPid: 7, backend: "launchd" as const, childNeedsSeparateStop: false as const }); describe("approval-bound stop", () => { + test("the tracked-record inspection and stop guard accept a verified legacy default", async () => { + const approval = { ...STOP_APPROVAL, hostname: "127.0.0.1" }; + const current = { ...STOP_RESOLVE, liveness: { ...STOP_RESOLVE.liveness, hostname: "127.0.0.1" } } as ResolveJson; + let stops = 0; + const target = () => describeTrackedRuntimeTarget({ + readFile: path => path.endsWith("ocx.pid") ? "42" : JSON.stringify({ pid: 42, port: 10100 }), + signal: () => {}, identity: () => "match", + }); + const result = await runApprovedStop(approval, async () => current, target, BOUND_STOP_MANAGER, + async () => { stops++; return { ok: true, summary: summarize(record(), { exitCode: 0 }) }; }); + expect(result.summary.outcome).toBe("stopped"); + expect(stops).toBe(1); + }); + test("a missing legacy hostname permits an otherwise verified default-loopback stop", async () => { + const approval = { ...STOP_APPROVAL, hostname: "127.0.0.1" }; + const current = { ...STOP_RESOLVE, liveness: { ...STOP_RESOLVE.liveness, hostname: "127.0.0.1" } } as ResolveJson; + let stops = 0; + const result = await runApprovedStop(approval, async () => current, + () => ({ pid: 42, port: 10100, hostname: undefined }), BOUND_STOP_MANAGER, + async () => { stops++; return { ok: true, summary: summarize(record(), { exitCode: 0 }) }; }); + expect(result.summary.outcome).toBe("stopped"); + expect(stops).toBe(1); + }); + + test.each([ + ["127.0.0.1", "0.0.0.0"], ["0.0.0.0", "127.0.0.1"], + ["0.0.0.0", undefined], ["127.0.0.1", ""], + ] as const)("a tracked hostname change from %s to %s refuses without stopping", async (approvedHost, trackedHost) => { + const approval = { ...STOP_APPROVAL, hostname: approvedHost }; + const current = { ...STOP_RESOLVE, liveness: { ...STOP_RESOLVE.liveness, hostname: approvedHost } } as ResolveJson; + let stops = 0; + const result = await runApprovedStop(approval, async () => current, + () => ({ pid: 42, port: 10100, hostname: trackedHost }), BOUND_STOP_MANAGER, + async () => { stops++; return { ok: true, summary: summarize(record(), { exitCode: 0 }) }; }); + expect(result.summary.outcome).toBe("approval-changed"); + expect(stops).toBe(0); + }); + test("mismatched evidence refuses before manager or proxy stop", async () => { expect(parseStopApproval(["--json", "--expect-pid", "42"]).ok).toBe(false); let managerStops = 0; diff --git a/tests/cli/cli-update-restart-admission.test.ts b/tests/cli/cli-update-restart-admission.test.ts index 96271728a24..a8f16686c39 100644 --- a/tests/cli/cli-update-restart-admission.test.ts +++ b/tests/cli/cli-update-restart-admission.test.ts @@ -120,12 +120,13 @@ test("child requires a schema-1 lowercase digest before acquiring or checking st } }); test("child validates fingerprint and supervision before/under lease and every publication guard", () => { - for (const drift of ["record", "supervision", "replacement"] as const) for (let checkpoint = 1; checkpoint <= 7; checkpoint++) { + for (const drift of ["record", "supervision", "replacement", "configuration"] as const) for (let checkpoint = 1; checkpoint <= 7; checkpoint++) { const s = fixture(); let checks = 0; let acquires = 0; let releases = 0; const mutate = () => { if (drift === "record") s.drift(); if (drift === "supervision") s.flip(); if (drift === "replacement") { renameSync(s.record, s.record + ".old"); writeFileSync(s.record, JSON.stringify(s.state)); } + if (drift === "configuration") writeFileSync(join(s.home.config.path, "config.json"), JSON.stringify({ hostname: "::1" })); }; const marker = { home: s.home, version: "2.77.0", port: 23456, hostname: "127.0.0.1", deadlineAt: 5000 }; const invoke = () => { diff --git a/tests/cli/cli-update-restart-child.test.ts b/tests/cli/cli-update-restart-child.test.ts index 101fa61b28e..731a937b3ff 100644 --- a/tests/cli/cli-update-restart-child.test.ts +++ b/tests/cli/cli-update-restart-child.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { admitUpdateRestartChild, UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildIo } from "../../src/cli/update-restart-child"; +import { admitUpdateRestartChild, parseUpdateRestartMarker, UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildIo } from "../../src/cli/update-restart-child"; import { OWNERSHIP_MUTATION_LEASE_TOKEN_ENV } from "../../src/service/ownership-mutation-lease.mjs"; function setup() { const calls: string[] = []; let now = 1000; let exit: (() => void) | undefined; - const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }; + const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }; const env = { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker), [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "parent-token" }; const io: UpdateRestartChildIo = { checkState: () => {}, @@ -18,6 +18,13 @@ function setup() { } const argv = ["start", "--port", "10100"]; describe("update restart child admission", () => { + test("marker accepts supported IP and wildcard spellings without changing their scope", () => { + const { marker } = setup(); + for (const hostname of ["127.0.0.1", "0.0.0.0", "::1", "::", "[::]", "*", "0", "00.0.0.000"]) { + const value = { ...marker, hostname, home: { ...marker.home, diagnosedHostname: hostname } }; + expect(parseUpdateRestartMarker(JSON.stringify(value)).hostname).toBe(hostname); + } + }); test("normal invocations do not acquire a lease", () => { expect(admitUpdateRestartChild(argv, { env: {}, acquire: () => { throw new Error("unexpected"); } })).toBeUndefined(); }); diff --git a/tests/cli/cli-update-restart-home.test.ts b/tests/cli/cli-update-restart-home.test.ts index d3485d522c4..a8fb18645f8 100644 --- a/tests/cli/cli-update-restart-home.test.ts +++ b/tests/cli/cli-update-restart-home.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync, readFileSync, statSync, chmodSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -99,6 +99,34 @@ test("production parent guard rejects client and hostname drift before stop", () writeFileSync(config, JSON.stringify({ hostname: "127.0.0.1", runtimeRole: "client" })); expect(() => assertUpdateRestartConfiguration("127.0.0.1")).toThrow(); }); +test("a default bind is compatible with a diagnosed omitted hostname", () => { + const h = home(); + writeFileSync(join(h.config, "config.json"), "{}\n"); + expect(() => assertUpdateRestartConfiguration("127.0.0.1")).not.toThrow(); +}); +test("the initial home snapshot detects same-bind diagnosed hostname changes", () => { + const h = home(); + const config = join(h.config, "config.json"); + writeFileSync(config, "{}\n"); + const captured = readUpdateRestartHome(); + checkHome(captured); + writeFileSync(config, JSON.stringify({ hostname: "127.0.0.1" })); + expect(() => checkHome(captured)).toThrow("update_restart_home_changed"); +}); +test("capturing restart eligibility leaves retired config untouched and emits no warning", () => { + const h = home(); + const config = join(h.config, "config.json"); + const bytes = JSON.stringify({ providers: { "opencode-free": { adapter: "openai-chat", apiKey: "synthetic-key" } } }); + writeFileSync(config, bytes); + const before = readdirSync(h.config); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + readUpdateRestartHome(); + expect(readFileSync(config, "utf8")).toBe(bytes); + expect(readdirSync(h.config)).toEqual(before); + expect(warn).not.toHaveBeenCalled(); + } finally { warn.mockRestore(); } +}); test("production child checker refuses client and competing runtime before acquisition", () => { for (const kind of ["client", "runtime", "malformed-runtime"] as const) { const h = home(); diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index eee339f1288..a91a2cdab91 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -1,20 +1,46 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { describeUpdateRestartFailure, runUpdateRestart, UpdateRestartEligibilityError, type UpdateRestartIo } from "../../src/cli/update-restart"; +import { describeUpdateRestartFailure, observeUpdateRestartReplacement, runUpdateRestart, UpdateRestartEligibilityError, type UpdateRestartIo } from "../../src/cli/update-restart"; import type { UpdateRestartCandidate } from "../../src/cli/update-restart-candidate"; import { reportRestartFailure } from "../../src/cli/restart-failure"; import { updateRestartVeto } from "../../src/update/restart-ownership"; import type { LiveProxy } from "../../src/server/proxy-liveness"; const candidate: UpdateRestartCandidate = { - home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, + home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }, target: { pid: 123, port: 10100, hostname: "127.0.0.1", source: "runtime", version: "2.76.0" }, runtime: { pid: 123, port: 10100, hostname: "127.0.0.1", attestationSecret: "a".repeat(43) }, cliVersion: "2.77.0", }; +test("production replacement verification binds the new record and rejects drift", async () => { + const legacy = { ...candidate, runtime: { ...candidate.runtime, hostname: undefined } }; + const live = { ...candidate.target, pid: 456 }; + const fresh = { pid: 456, port: 10100, hostname: "127.0.0.1", attestationSecret: "b".repeat(43) }; + for (const mode of ["valid", "missing", "wildcard", "ipv6", "pid", "port", "sibling", "drift"] as const) { + let reads = 0, proofs = 0; + const first = mode === "missing" ? { ...fresh, hostname: undefined } + : mode === "wildcard" ? { ...fresh, hostname: "0.0.0.0" } + : mode === "ipv6" ? { ...fresh, hostname: "::1" } + : mode === "pid" ? { ...fresh, pid: 999 } + : mode === "port" ? { ...fresh, port: 9999 } + : mode === "sibling" ? { ...fresh, siblingOfPort: 10101 } : fresh; + const io = { + findLive: async () => live, + readRuntime: () => { reads++; return mode === "drift" && reads > 1 ? { ...first, attestationSecret: "c".repeat(43) } : first; }, + attest: async (options: { secret: string }) => { proofs++; expect(options.secret).toBe(fresh.attestationSecret); return { ...live, version: "2.77.0" }; }, + }; + if (mode === "valid") { + expect(await observeUpdateRestartReplacement(legacy, 5000, 456, io)).toMatchObject({ pid: 456, version: "2.77.0" }); + expect(reads).toBe(2); expect(proofs).toBe(1); + } else { + await expect(observeUpdateRestartReplacement(legacy, 5000, 456, io)).rejects.toThrow(); + expect(proofs).toBe(mode === "drift" ? 1 : 0); + } + } +}); function setup() { const calls: string[] = []; let now = 1000; - const home = { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }; + const home = { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }; const live: LiveProxy = { ...candidate.target, pid: 456, version: "2.77.0" }; const io: UpdateRestartIo = { now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; }, @@ -29,6 +55,38 @@ function setup() { return { calls, io, live, expire: () => { now = 5000; } }; } describe("CLI update restart transaction", () => { + test("an oversized child handoff refuses before stopping", async () => { + const s = setup(); + const bad = { ...candidate, home: { ...candidate.home, config: { ...candidate.home.config, path: "x".repeat(8192) } } }; + s.io.home = () => bad.home; + expect((await runUpdateRestart(bad, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("stop"); + }); + test("a malformed or unsupported bind handoff refuses before stopping", async () => { + for (const hostname of ["localhost", "invalid host", "a".repeat(256)]) { + const s = setup(); + const bad = { ...candidate, home: { ...candidate.home, diagnosedHostname: hostname } }; + s.io.home = () => bad.home; + expect((await runUpdateRestart(bad, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("stop"); + } + }); + test("malformed IP bind spellings never reach stop", async () => { + for (const hostname of ["[::1", "::1]", ":::", "[:::]"]) { + const s = setup(); + const bad = { ...candidate, target: { ...candidate.target, hostname }, runtime: { ...candidate.runtime, hostname } }; + s.io.runtime = () => bad.runtime; + expect((await runUpdateRestart(bad, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("stop"); + } + }); + test("a legacy absent hostname hands the verified default bind to the replacement", async () => { + const s = setup(); + const legacy = { ...candidate, runtime: { ...candidate.runtime, hostname: undefined } }; + s.io.runtime = () => legacy.runtime; + expect(await runUpdateRestart(legacy, 5000, s.io)).toEqual({ ok: true, live: s.live }); + expect(s.calls.filter(call => call === "stop")).toHaveLength(1); + }); test("stops only the captured target, settles, launches once and verifies expected child/version", async () => { const s = setup(); expect(await runUpdateRestart(candidate, 5000, s.io)).toEqual({ ok: true, live: s.live }); diff --git a/tests/cli/system-restart-client.test.ts b/tests/cli/system-restart-client.test.ts index 1116d65467a..b26510d98bb 100644 --- a/tests/cli/system-restart-client.test.ts +++ b/tests/cli/system-restart-client.test.ts @@ -55,12 +55,29 @@ function successfulDeps() { // Matches the /healthz fixture version below so the skew guard stays out of the way; // the dedicated skew tests override it explicitly. cliVersion: "test", - readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }), + readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }), }, }; } describe("bound system restart client", () => { + test("rejects a changed diagnosed hostname after asynchronous discovery", async () => { + const setup = successfulDeps(); + const home = setup.deps.readUpdateHome(); + let changed = false; + setup.deps.readUpdateHome = () => ({ ...home, diagnosedHostname: changed ? "::1" : "127.0.0.1" }); + setup.deps.findLive = async () => { changed = true; return target; }; + setup.deps.cliVersion = "2.77.0"; + setup.deps.fetchImpl = (async () => { + const response = successfulDepsResponse(setup.secret, setup.challenge); + const body = await response.json() as Record; + body.version = "2.76.0"; + return new Response(JSON.stringify(body), { status: 200, headers: response.headers }); + }) as typeof fetch; + const result = await requestBoundSystemRestart(target, 10_000, setup.deps); + expect(result.accepted).toBe(false); + if (!result.accepted) expect((result.error as Error).message).toBe("update_restart_home_changed"); + }); test("rejects an unattested discovery result without network access", async () => { let fetches = 0; const outcome = await requestBoundSystemRestart( diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index 73cfd51657a..df30a535c76 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -324,6 +324,13 @@ describe("tracked runtime record inspection", () => { Object.assign(new Error("fake " + code), { code }); const liveOcx: TrackedRecordProbeDeps = { signal: () => {}, identity: () => "match" }; const trackedPid = () => writeFileSync(getPidPath(), "4242", "utf-8"); + test("an omitted tracked hostname remains distinct from an explicitly empty hostname", () => { + trackedPid(); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195 }), "utf-8"); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual({ pid: 4242, port: 58195, hostname: undefined }); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195, hostname: "" }), "utf-8"); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual({ pid: 4242, port: 58195, hostname: "" }); + }); const trustedProbePaths = () => { const trustedSystem32 = join(testDir, "trusted", "System32"); const trustedPowerShell = join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"); diff --git a/tests/service/shutdown-launcher.test.ts b/tests/service/shutdown-launcher.test.ts index 31bb4dda714..17c7876b5f6 100644 --- a/tests/service/shutdown-launcher.test.ts +++ b/tests/service/shutdown-launcher.test.ts @@ -166,6 +166,9 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { ); } expect(existsSync(join(home, "ocx.pid"))).toBe(true); + // An omitted configured hostname binds and publishes the default loopback address. + const published = JSON.parse(readFileSync(join(home, "runtime-port.json"), "utf8")); + expect(published.hostname).toBe("127.0.0.1"); const injected = readFileSync(codexConfig, "utf8"); if (approved) { expect(injected).toContain(`base_url = "http://127.0.0.1:${port}/v1"`); From 0b7bef7f062e5277b00062acd578f21bdb0e4c56 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:41:33 +0900 Subject: [PATCH 2/7] fix: refuse blank update restart runtime hostnames Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- src/cli/update-restart.ts | 1 + structure/ops/service-and-sidecars.md | 2 +- tests/cli/cli-update-restart.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 59fc1e34e9f..aff20049b40 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -75,6 +75,7 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli lease = io.acquire(); const home = candidate.home; if (JSON.stringify(io.home()) !== JSON.stringify(home)) throw new Error("home_changed"); + if (candidate.runtime.hostname !== undefined && !candidate.runtime.hostname.trim()) throw new Error("target"); const marker = parseUpdateRestartMarker(JSON.stringify({ home, version: candidate.cliVersion, port: candidate.target.port, hostname: normalizeBindHostname(candidate.runtime.hostname), deadlineAt })); if (normalizeBindHostname(candidate.target.hostname) !== marker.hostname) throw new Error("target"); diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 948c6662135..5f235ae9ed6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -55,7 +55,7 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from `src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index a91a2cdab91..cd3f507a805 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -87,6 +87,33 @@ describe("CLI update restart transaction", () => { expect(await runUpdateRestart(legacy, 5000, s.io)).toEqual({ ok: true, live: s.live }); expect(s.calls.filter(call => call === "stop")).toHaveLength(1); }); + test.each(["", " \t "])("a present blank runtime hostname %j refuses before stop", async hostname => { + const s = setup(); + const bad = { ...candidate, runtime: { ...candidate.runtime, hostname } }; + s.io.runtime = () => bad.runtime; + const result = await runUpdateRestart(bad, 5000, s.io); + expect(s.calls).toEqual(["acquire", "release"]); + expect(result).toEqual({ ok: false, code: "update_restart_eligibility_failed" }); + }); + test.each([["127.0.0.1", "127.0.0.1"], ["localhost", "127.0.0.1"], ["::1", "::1"], ["0.0.0.0", "0.0.0.0"], ["::", "::"], ["[::]", "[::]"], ["*", "*"]])("preserves bind %s in the transaction with injected eligibility", async (hostname, expectedHost) => { + const s = setup(); + const bound = { ...candidate, target: { ...candidate.target, hostname }, runtime: { ...candidate.runtime, hostname } }; + const live = { ...s.live, hostname }; + s.io.runtime = () => bound.runtime; + s.io.start = marker => { expect(marker.hostname).toBe(expectedHost); s.calls.push("start"); return { pid: 456, exitCode: null, signalCode: null }; }; + s.io.observe = async () => live; + expect(await runUpdateRestart(bound, 5000, s.io)).toEqual({ ok: true, live }); + expect(s.calls.filter(call => call === "stop")).toHaveLength(1); + }); + test.each(["0.0.0.0", "::", "*"])("refuses a wildcard target %s with a default runtime bind", async hostname => { + for (const runtimeHost of [undefined, "127.0.0.1"]) { + const s = setup(); + const bad = { ...candidate, target: { ...candidate.target, hostname }, runtime: { ...candidate.runtime, hostname: runtimeHost } }; + s.io.runtime = () => bad.runtime; + expect(await runUpdateRestart(bad, 5000, s.io)).toEqual({ ok: false, code: "update_restart_eligibility_failed" }); + expect(s.calls).toEqual(["acquire", "release"]); + } + }); test("stops only the captured target, settles, launches once and verifies expected child/version", async () => { const s = setup(); expect(await runUpdateRestart(candidate, 5000, s.io)).toEqual({ ok: true, live: s.live }); From fb1106e34acc77e6539ba0d44883ec81efd342a6 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:03:49 +0900 Subject: [PATCH 3/7] fix: explain blank update restart hostname Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- .../content/docs/reference/cli/lifecycle.md | 3 +++ src/cli/index.ts | 4 ++-- src/cli/update-restart.ts | 12 ++++++++++-- structure/ops/service-and-sidecars.md | 2 +- tests/cli/cli-update-restart.test.ts | 18 +++++++++++++++--- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 127407b5283..0cdf076ef19 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -324,6 +324,9 @@ restart the proxy using the intended current installation; for a background serv or resolve `PATH` to the intended installation. These diagnostics do not repair the service or change whether requests are allowed. +If an update restart reports a blank hostname in the runtime record, it leaves the proxy running. +Restart through the owning service, desktop app, or terminal to regenerate that record, then retry. + Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does an absent proxy version. Doctor does not report placeholders as a confirmed match. Different strings still produce a neutral warning when they cannot be strictly parsed as SemVer or differ diff --git a/src/cli/index.ts b/src/cli/index.ts index f42e69e942c..f5247b73c42 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,7 +13,7 @@ import { getCodexHome } from "../codex/paths"; import { admitUpdateRestartChild } from "./update-restart-child"; import { HEALTH_SENTINEL_STANDALONE_ARG, startServiceHealthSentinel } from "../service/health-sentinel-launch"; import { UpdateRestartRequired } from "./update-restart-candidate"; -import { describeUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart"; +import { reportUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart"; import { findGuiDist } from "../server/gui-static"; import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; // Best-effort recovery for runtime execution and spawned children if launched @@ -984,7 +984,7 @@ async function handleProxyRestart( console.log(`🔄 Running proxy ${candidate.target.version} is older than this CLI (${candidate.cliVersion}); restarting it from the current installation...`); const update = await restartFromCurrentInstallation(candidate, deadlineAt, detachedStartEnvironment()); if (update.ok) console.log(`✅ Proxy updated to ${update.live.version} (PID ${update.live.pid}).`); - else console.error(`❌ ${describeUpdateRestartFailure(update.code, update.reason)} (${update.code})`); + else reportUpdateRestartFailure(update); process.exitCode = update.ok ? 0 : 1; return update.ok; } diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index aff20049b40..7569b8b7b95 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -40,7 +40,7 @@ export interface UpdateRestartIo { wait(ms: number): Promise; } const ELIGIBILITY_REASONS = ["windows", "unsupported_platform", "foreground", "service", "shared_or_client", - "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed", "desktop"] as const; + "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed", "desktop", "blank_runtime_hostname"] as const; export type UpdateRestartEligibilityReason = (typeof ELIGIBILITY_REASONS)[number]; /** Carries only a closed reason code across the eligibility/transport boundary. */ @@ -75,7 +75,10 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli lease = io.acquire(); const home = candidate.home; if (JSON.stringify(io.home()) !== JSON.stringify(home)) throw new Error("home_changed"); - if (candidate.runtime.hostname !== undefined && !candidate.runtime.hostname.trim()) throw new Error("target"); + if (candidate.runtime.hostname !== undefined && !candidate.runtime.hostname.trim()) { + eligibilityReason = "blank_runtime_hostname"; + throw new Error("target"); + } const marker = parseUpdateRestartMarker(JSON.stringify({ home, version: candidate.cliVersion, port: candidate.target.port, hostname: normalizeBindHostname(candidate.runtime.hostname), deadlineAt })); if (normalizeBindHostname(candidate.target.hostname) !== marker.hostname) throw new Error("target"); @@ -170,6 +173,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar case "unverifiable_ancestry": return "The proxy's process ancestry or manager could not be verified. Run `ocx status` to identify its owner before restarting."; case "configuration_changed": return "The selected configuration or connection changed. Run `ocx status` to inspect the intended proxy before restarting."; case "target_changed": return "The captured proxy identity changed. Run `ocx status` to inspect the intended proxy before restarting."; + case "blank_runtime_hostname": return "The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Restart the proxy through its owning service, desktop app, or terminal to regenerate the runtime record, then retry."; } })(); return `${action} Nothing was stopped by this restart attempt.`; @@ -196,6 +200,10 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar } } +export function reportUpdateRestartFailure(result: Extract): void { + console.error(`❌ ${describeUpdateRestartFailure(result.code, result.reason)} (${result.code})`); +} + export interface UpdateRestartStandaloneDeps { platform?: NodeJS.Platform; expectedHome?: UpdateRestartHome; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 5f235ae9ed6..b20a003b736 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -55,7 +55,7 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop with a fixed, sanitized reason directing restart through the owner to regenerate the record. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from `src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index cd3f507a805..22dfeb8c254 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -1,5 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { describeUpdateRestartFailure, observeUpdateRestartReplacement, runUpdateRestart, UpdateRestartEligibilityError, type UpdateRestartIo } from "../../src/cli/update-restart"; +import { readFileSync } from "node:fs"; +import { describeUpdateRestartFailure, observeUpdateRestartReplacement, reportUpdateRestartFailure, runUpdateRestart, UpdateRestartEligibilityError, type UpdateRestartIo } from "../../src/cli/update-restart"; +import { repoPath } from "../helpers/repo-root"; import type { UpdateRestartCandidate } from "../../src/cli/update-restart-candidate"; import { reportRestartFailure } from "../../src/cli/restart-failure"; import { updateRestartVeto } from "../../src/update/restart-ownership"; @@ -43,6 +45,7 @@ function setup() { const home = { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }; const live: LiveProxy = { ...candidate.target, pid: 456, version: "2.77.0" }; const io: UpdateRestartIo = { + inspectSupervision: () => ({ kind: "none" }), now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; }, home: () => home, checkHome: () => {}, runtime: () => candidate.runtime, standalone: () => true, runtimeReady: () => true, @@ -89,11 +92,20 @@ describe("CLI update restart transaction", () => { }); test.each(["", " \t "])("a present blank runtime hostname %j refuses before stop", async hostname => { const s = setup(); - const bad = { ...candidate, runtime: { ...candidate.runtime, hostname } }; + const bad = { ...candidate, runtime: { ...candidate.runtime, hostname, attestationSecret: "PRIVATE_CANARY\u001b" } }; s.io.runtime = () => bad.runtime; const result = await runUpdateRestart(bad, 5000, s.io); expect(s.calls).toEqual(["acquire", "release"]); - expect(result).toEqual({ ok: false, code: "update_restart_eligibility_failed" }); + expect(result).toEqual({ ok: false, code: "update_restart_eligibility_failed", reason: "blank_runtime_hostname" }); + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + if (!result.ok) reportUpdateRestartFailure(result); + expect(errors.mock.calls).toEqual([["❌ The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Restart the proxy through its owning service, desktop app, or terminal to regenerate the runtime record, then retry. Nothing was stopped by this restart attempt. (update_restart_eligibility_failed)"]]); + } finally { errors.mockRestore(); } + }); + test("the CLI sends update restart failures to the reporting function", () => { + const source = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); + expect(source).toContain("else reportUpdateRestartFailure(update);"); }); test.each([["127.0.0.1", "127.0.0.1"], ["localhost", "127.0.0.1"], ["::1", "::1"], ["0.0.0.0", "0.0.0.0"], ["::", "::"], ["[::]", "[::]"], ["*", "*"]])("preserves bind %s in the transaction with injected eligibility", async (hostname, expectedHost) => { const s = setup(); From ac88b261848f34e0d77e7e6bf910eb9eea1d98cc Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:26:33 +0900 Subject: [PATCH 4/7] fix: clarify detached proxy recovery Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- .../content/docs/reference/cli/lifecycle.md | 8 +- src/cli/update-restart.ts | 2 +- structure/ops/service-and-sidecars.md | 2 +- tests/cli/cli-update-restart.test.ts | 74 ++++++++++++++++++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 0cdf076ef19..b20074eab1c 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -325,7 +325,13 @@ or resolve `PATH` to the intended installation. These diagnostics do not repair change whether requests are allowed. If an update restart reports a blank hostname in the runtime record, it leaves the proxy running. -Restart through the owning service, desktop app, or terminal to regenerate that record, then retry. +Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning +installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its +terminal. A standalone proxy started by `ocx ensure` may be detached and have no terminal: ask its owner or administrator +to verify the process, home and supervisor and arrange a controlled shutdown. `ocx stop` may refuse +when ownership or safe shutdown cannot be verified; do not bypass the refusal. Confirm the proxy is +stopped before starting it from the owning installation. Do not edit the runtime record or force a +stop. A successful new start regenerates the record; then retry the update restart if needed. Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does an absent proxy version. Doctor does not report placeholders as a confirmed match. Different diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 7569b8b7b95..88e02ea25de 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -173,7 +173,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar case "unverifiable_ancestry": return "The proxy's process ancestry or manager could not be verified. Run `ocx status` to identify its owner before restarting."; case "configuration_changed": return "The selected configuration or connection changed. Run `ocx status` to inspect the intended proxy before restarting."; case "target_changed": return "The captured proxy identity changed. Run `ocx status` to inspect the intended proxy before restarting."; - case "blank_runtime_hostname": return "The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Restart the proxy through its owning service, desktop app, or terminal to regenerate the runtime record, then retry."; + case "blank_runtime_hostname": return "The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached proxy, ask its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown; `ocx stop` may refuse. Confirm it is stopped before starting it from the owning installation. Do not edit the runtime record or force a stop."; } })(); return `${action} Nothing was stopped by this restart attempt.`; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index b20a003b736..48095bbd907 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -55,7 +55,7 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop with a fixed, sanitized reason directing restart through the owner to regenerate the record. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop with fixed, sanitized owner-specific recovery guidance. A detached standalone proxy may have no terminal; the guidance asks its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown, and does not promise that `ocx stop` succeeds. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from `src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index 22dfeb8c254..81eef53f72b 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -6,6 +6,14 @@ import type { UpdateRestartCandidate } from "../../src/cli/update-restart-candid import { reportRestartFailure } from "../../src/cli/restart-failure"; import { updateRestartVeto } from "../../src/update/restart-ownership"; import type { LiveProxy } from "../../src/server/proxy-liveness"; +import { findLiveProxy } from "../../src/server/proxy-liveness"; +import { isValidRuntimePortState } from "../../src/config/process-state"; +import { runDesktopAwareProxyRestart } from "../../src/cli/desktop-runtime-guidance"; +import { UpdateRestartRequired } from "../../src/cli/update-restart-candidate"; +import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; +import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, createLocalAttestationProof } from "../../src/lib/local-management-attestation"; +import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../src/lib/system-restart-contract"; +import { discoverStableProxyForRestart } from "../../src/cli/tray-proxy"; const candidate: UpdateRestartCandidate = { home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) }, diagnosedHostname: "127.0.0.1" }, @@ -57,6 +65,67 @@ function setup() { }; return { calls, io, live, expire: () => { now = 5000; } }; } + +test.each(["", " \t "])("composed restart reports blank record %j without a stop or start", async hostname => { + const runtime = { ...candidate.runtime, hostname, attestationSecret: Buffer.alloc(32, 7).toString("base64url") }; + expect(isValidRuntimePortState(runtime)).toBe(true); + const calls: string[] = []; + const challenge = Buffer.alloc(32, 8).toString("base64url"); + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`); + expect(new URL(String(input)).pathname).toBe("/healthz"); + const nonce = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + const proof = nonce ? createLocalAttestationProof(runtime.attestationSecret, nonce, runtime.pid, runtime.port) : null; + return Response.json({ service: "opencodex", pid: runtime.pid, version: "2.76.0", restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION }, + { headers: proof ? { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } : {} }); + }) as typeof fetch; + const discover = () => findLiveProxy({ readPidFn: () => runtime.pid, readRuntimeFn: () => runtime, + verifyPidFn: pid => pid, configFn: () => ({ port: runtime.port }), fetchFn: fetchImpl, nowFn: () => 1000 }); + const s = setup(); + const result = await runDesktopAwareProxyRestart({ + findLive: () => discoverStableProxyForRestart({ findLive: discover, waitBetweenChecks: async () => {} }), + requestInPlaceRestart: previous => requestBoundSystemRestart(previous, 5000, { + fetchImpl, readRuntime: () => runtime, findLive: discover, createChallenge: () => challenge, + now: () => 1000, cliVersion: candidate.cliVersion, readUpdateHome: () => candidate.home, + }), + startWhenStopped: () => { calls.push("fallback start"); return { status: "started" }; }, + waitForReplacement: async () => { calls.push("replacement wait"); return null; }, + }, () => ({ kind: "none" })); + expect(result.ok).toBe(false); + if (result.ok || result.phase !== "request" || !(result.error instanceof UpdateRestartRequired)) throw new Error("candidate not produced"); + s.io.runtime = () => runtime; + const update = await runUpdateRestart(result.error.candidate(), 5000, s.io); + expect(update).toEqual({ ok: false, code: "update_restart_eligibility_failed", reason: "blank_runtime_hostname" }); + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + if (!update.ok) reportUpdateRestartFailure(update); + expect(errors.mock.calls).toEqual([["❌ The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached proxy, ask its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown; `ocx stop` may refuse. Confirm it is stopped before starting it from the owning installation. Do not edit the runtime record or force a stop. Nothing was stopped by this restart attempt. (update_restart_eligibility_failed)"]]); + } finally { errors.mockRestore(); } + expect(calls).toEqual(["GET /healthz", "GET /healthz", "GET /healthz"]); + expect(s.calls).toEqual(["acquire", "release"]); + + // A record changed after proof cannot become an update candidate. + let reads = 0; + const drift = await requestBoundSystemRestart(candidate.target, 5000, { + fetchImpl, readRuntime: () => ++reads === 1 ? runtime : { ...runtime, hostname: "127.0.0.1" }, + findLive: discover, createChallenge: () => challenge, now: () => 1000, + cliVersion: candidate.cliVersion, readUpdateHome: () => candidate.home, + }); + expect(drift.accepted).toBe(false); + if (drift.accepted) throw new Error("drift accepted"); + expect(drift.uncertain).toBe(false); + expect((drift.error as Error).message).toBe("restart_target_changed"); + const badProof = await requestBoundSystemRestart(candidate.target, 5000, { + fetchImpl: (async () => Response.json({ service: "opencodex", pid: runtime.pid }, + { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: "A".repeat(43) } })) as typeof fetch, + readRuntime: () => runtime, findLive: discover, createChallenge: () => challenge, + now: () => 1000, cliVersion: candidate.cliVersion, readUpdateHome: () => candidate.home, + }); + expect(badProof.accepted).toBe(false); + if (badProof.accepted) throw new Error("bad proof accepted"); + expect((badProof.error as Error).message).toBe("restart_attestation_failed"); + expect(s.calls).toEqual(["acquire", "release"]); +}); describe("CLI update restart transaction", () => { test("an oversized child handoff refuses before stopping", async () => { const s = setup(); @@ -97,10 +166,11 @@ describe("CLI update restart transaction", () => { const result = await runUpdateRestart(bad, 5000, s.io); expect(s.calls).toEqual(["acquire", "release"]); expect(result).toEqual({ ok: false, code: "update_restart_eligibility_failed", reason: "blank_runtime_hostname" }); + if (result.ok) throw new Error("blank hostname accepted"); const errors = spyOn(console, "error").mockImplementation(() => {}); try { - if (!result.ok) reportUpdateRestartFailure(result); - expect(errors.mock.calls).toEqual([["❌ The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Restart the proxy through its owning service, desktop app, or terminal to regenerate the runtime record, then retry. Nothing was stopped by this restart attempt. (update_restart_eligibility_failed)"]]); + reportUpdateRestartFailure(result); + expect(errors.mock.calls).toEqual([[`❌ ${describeUpdateRestartFailure(result.code, result.reason)} (update_restart_eligibility_failed)`]]); } finally { errors.mockRestore(); } }); test("the CLI sends update restart failures to the reporting function", () => { From c26d3d74950200c6a363b93d22b1d701083716a8 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:37:59 +0900 Subject: [PATCH 5/7] fix: clarify safe detached proxy recovery Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- .../content/docs/reference/cli/lifecycle.md | 32 +++++++++++++++---- src/cli/update-restart.ts | 2 +- structure/ops/service-and-sidecars.md | 2 +- tests/cli/cli-update-restart.test.ts | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index b20074eab1c..d473d9a0878 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -325,13 +325,31 @@ or resolve `PATH` to the intended installation. These diagnostics do not repair change whether requests are allowed. If an update restart reports a blank hostname in the runtime record, it leaves the proxy running. -Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning -installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its -terminal. A standalone proxy started by `ocx ensure` may be detached and have no terminal: ask its owner or administrator -to verify the process, home and supervisor and arrange a controlled shutdown. `ocx stop` may refuse -when ownership or safe shutdown cannot be verified; do not bypass the refusal. Confirm the proxy is -stopped before starting it from the owning installation. Do not edit the runtime record or force a -stop. A successful new start regenerates the record; then retry the update restart if needed. +Use `ocx status` and `ocx service status` as clues, not proof of ownership or the actual bind. +Once ownership is verified, for a service use the owning installation's `ocx service restart`; +for Desktop, use the app; for a foreground proxy, use its terminal. A standalone proxy started by `ocx ensure` may be +detached and have no terminal: + +1. Independently verify the process identity, installation, home, supervisor and actual listening + address and port. Use OS process and listener inspection with sufficient permissions; check + IPv4, IPv6 and wildcard listeners as applicable. A PID alone can be reused, and missing or + incomplete inspection results do not prove ownership or absence. For example, on macOS or + Linux where `lsof` is available, `ps -p -o pid=,ppid=,args=` and + `lsof -nP -iTCP: -sTCP:LISTEN` provide process and listener clues. They do not by + themselves establish the home, supervisor, or complete visibility; use the appropriate OS + tools and permissions to verify those separately. +2. Only if those facts are verified and forced termination is acceptable, the owner can run + `ocx stop` from the owning installation and home. It tries a graceful stop first, but may + fall back to forced termination if that fails; it can also refuse, including when the blank + hostname prevents guarded ownership verification. Do not bypass a refusal. +3. Independently confirm the identified process has exited, no supervisor will restart it, + and the intended address and port are free. A successful stop or a status result alone is + not proof of all three. Once confirmed, run `ocx start` from the intended installation and + home with the intended bind and port settings; a successful start regenerates the record. + +If any check is incomplete, the stop fails or is refused, or forced termination is unacceptable, +stop and ask the owner or administrator to investigate. Do not edit the runtime record or kill +unrelated processes. Retry the update restart if needed after the new proxy is running. Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does an absent proxy version. Doctor does not report placeholders as a confirmed match. Different diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 88e02ea25de..60b1fc519ff 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -173,7 +173,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar case "unverifiable_ancestry": return "The proxy's process ancestry or manager could not be verified. Run `ocx status` to identify its owner before restarting."; case "configuration_changed": return "The selected configuration or connection changed. Run `ocx status` to inspect the intended proxy before restarting."; case "target_changed": return "The captured proxy identity changed. Run `ocx status` to inspect the intended proxy before restarting."; - case "blank_runtime_hostname": return "The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached proxy, ask its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown; `ocx stop` may refuse. Confirm it is stopped before starting it from the owning installation. Do not edit the runtime record or force a stop."; + case "blank_runtime_hostname": return "The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Use `ocx status` and `ocx service status` as clues, not proof of ownership or bind address. Once ownership is verified, for a service use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached standalone proxy, independently verify its process, home, supervisor and actual bind. If verified and forced termination is acceptable, the owner can run `ocx stop` from that installation and home; it may refuse or force termination if graceful shutdown fails. Independently confirm the process has exited, its supervisor will not restart it, and the intended address and port are free before running `ocx start` with the intended settings. If any step is uncertain or fails, stop and ask the owner or administrator; do not edit the runtime record or kill unrelated processes."; } })(); return `${action} Nothing was stopped by this restart attempt.`; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 48095bbd907..4ff70acf17b 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -55,7 +55,7 @@ without running handlers is not covered by this forwarding mechanism. ## Guarded CLI update restart -`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop with fixed, sanitized owner-specific recovery guidance. A detached standalone proxy may have no terminal; the guidance asks its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown, and does not promise that `ocx stop` succeeds. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from +`src/cli/system-restart-client.ts` freezes the newer-CLI candidate's runtime identity, physical homes and diagnosed hostname, which later reads must match exactly. An omitted hostname corresponds to the default loopback bind; a present empty or whitespace-only runtime hostname refuses before stop with fixed, sanitized owner-specific recovery guidance. For a detached standalone proxy, the guidance conditions `ocx stop` on independent ownership and bind verification and acceptance of possible forced termination, requires independent exit, supervision and port checks before `ocx start`, and directs uncertain cases to the owner or administrator. The serialized child handoff is validated before stop; replacement checks compare bind scope while retaining exact record rereads and attestation. `src/cli/update-restart-home.ts` captures the service-record digest from `src/cli/update-restart-service-record.ts`: all ordered `serviceStatePaths()` candidates, including confirmed absences and the authoritative position, plus the launchd plist or systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime, mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index 81eef53f72b..8215f70cabf 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -99,7 +99,7 @@ test.each(["", " \t "])("composed restart reports blank record %j without a stop const errors = spyOn(console, "error").mockImplementation(() => {}); try { if (!update.ok) reportUpdateRestartFailure(update); - expect(errors.mock.calls).toEqual([["❌ The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Run `ocx status` and `ocx service status` to identify its owner. For a service, use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached proxy, ask its owner or administrator to verify the process, home and supervisor and arrange a controlled shutdown; `ocx stop` may refuse. Confirm it is stopped before starting it from the owning installation. Do not edit the runtime record or force a stop. Nothing was stopped by this restart attempt. (update_restart_eligibility_failed)"]]); + expect(errors.mock.calls).toEqual([["❌ The running proxy's runtime record has a blank hostname, so its bind address cannot be verified. Use `ocx status` and `ocx service status` as clues, not proof of ownership or bind address. Once ownership is verified, for a service use the owning installation's `ocx service restart`; for Desktop, use the app; for a foreground proxy, use its terminal. For a detached standalone proxy, independently verify its process, home, supervisor and actual bind. If verified and forced termination is acceptable, the owner can run `ocx stop` from that installation and home; it may refuse or force termination if graceful shutdown fails. Independently confirm the process has exited, its supervisor will not restart it, and the intended address and port are free before running `ocx start` with the intended settings. If any step is uncertain or fails, stop and ask the owner or administrator; do not edit the runtime record or kill unrelated processes. Nothing was stopped by this restart attempt. (update_restart_eligibility_failed)"]]); } finally { errors.mockRestore(); } expect(calls).toEqual(["GET /healthz", "GET /healthz", "GET /healthz"]); expect(s.calls).toEqual(["acquire", "release"]); From abe66279fb0a76e51df4b294fa1c150323fd1445 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:24:45 +0900 Subject: [PATCH 6/7] docs: correct Traditional Chinese restart guidance Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index c47b087fe6b..f6539c3b0a0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -38,7 +38,9 @@ ocx start --socks5-off ### `ocx restart` -執行 `stop` 後接 `ensure`:停止代理/服務、還原原生 Codex、在背景啟動代理,並將即時連接埠同步回 Codex。 +代理正在執行時,指令會向身分已驗證的特定 PID 與連接埠請求原地重新啟動,等待正常排空,並確認同一連接埠上出現不同的執行階段 PID。受管路由與服務監督會保留;若請求結果不確定,不會將其重試為獨立的停止與啟動。只有代理未執行時,才會回退至一般的 `ensure` 啟動。 + +如果更新後的重新啟動回報執行階段紀錄中的主機名稱為空白,這次嘗試不會停止代理。請參閱[狀態與復原指引](/reference/cli/lifecycle/#status-and-health)(英文),先確認擁有權與實際綁定位址;若確認不完整,請洽擁有者或管理員。 ### `ocx ensure` From af85e24f5bcb3553c654f4c26459e14e212bd027 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:54:52 +0900 Subject: [PATCH 7/7] docs: distinguish guarded update restart in zh-TW Co-authored-by: kerthans <112744990+kerthans@users.noreply.github.com> --- docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index f6539c3b0a0..9ce705f5d54 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -38,7 +38,9 @@ ocx start --socks5-off ### `ocx restart` -代理正在執行時,指令會向身分已驗證的特定 PID 與連接埠請求原地重新啟動,等待正常排空,並確認同一連接埠上出現不同的執行階段 PID。受管路由與服務監督會保留;若請求結果不確定,不會將其重試為獨立的停止與啟動。只有代理未執行時,才會回退至一般的 `ensure` 啟動。 +一般情況下,代理正在執行時,指令會向身分已驗證的特定 PID 與連接埠請求原地重新啟動,等待正常排空,並確認同一連接埠上出現不同的執行階段 PID。此路徑會保留受管路由與服務監督;若請求結果不確定,不會將其重試為獨立的停止與啟動。只有代理未執行時,才會回退至一般的 `ensure` 啟動。 + +若目前 CLI 比已驗證的獨立 POSIX 代理新,且符合嚴格的擁有權與監督條件,受保護的更新路徑會停止舊代理,確認其已停止後才從目前的安裝啟動新代理;此路徑不同於上述原地重新啟動。若資格或停止狀態無法確認,指令會回報失敗。條件與其他情況請參閱[重新啟動參考資料](/reference/cli/lifecycle/#ocx-restart)(英文)。 如果更新後的重新啟動回報執行階段紀錄中的主機名稱為空白,這次嘗試不會停止代理。請參閱[狀態與復原指引](/reference/cli/lifecycle/#status-and-health)(英文),先確認擁有權與實際綁定位址;若確認不完整,請洽擁有者或管理員。