From b58ddd9490b18c24ef7a652d38bd1e54590536b4 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 2 Oct 2026 21:32:20 -0400 Subject: [PATCH 1/5] fix(update): treat a bindable port as stopped when the liveness dial is dropped --- src/update/proxy-liveness-probe.mjs | 30 ++++++++++++++----- .../update/update-stop-classification.test.ts | 10 +++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs index 6179156a32f..6023afe5a6d 100644 --- a/src/update/proxy-liveness-probe.mjs +++ b/src/update/proxy-liveness-probe.mjs @@ -38,6 +38,8 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150 const script = [ "const http = require('node:http');", "const [host, port, timeout] = process.argv.slice(1);", + "let settled = false;", + "const settle = word => { if (!settled) { settled = true; process.stdout.write(word); } };", "const req = http.get({ host, port: Number(port), path: '/healthz', timeout: Number(timeout) }, res => {", " let body = '';", " res.setEncoding('utf8');", @@ -56,15 +58,29 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150 " && typeof parsed.uptime === 'number'));", " // Only a clean 200 decides anything. Any other status means the endpoint is", " // answering but not telling us what it is, which is not evidence of absence.", - " if (res.statusCode !== 200) process.stdout.write('UNKNOWN');", - " else process.stdout.write(isOpencodex ? 'LIVE' : 'DEAD');", - " } catch { process.stdout.write('UNKNOWN'); }", + " if (res.statusCode !== 200) settle('UNKNOWN');", + " else settle(isOpencodex ? 'LIVE' : 'DEAD');", + " } catch { settle('UNKNOWN'); }", " });", "});", - "req.on('timeout', () => { process.stdout.write('UNKNOWN'); req.destroy(); });", - "// ECONNREFUSED is the one error that proves nothing is listening. Everything else -", - "// reset, unreachable host, TLS confusion - leaves the question open.", - "req.on('error', err => process.stdout.write(err && err.code === 'ECONNREFUSED' ? 'DEAD' : 'UNKNOWN'));", + // A refused connection is the clean proof that nothing is listening. Everything else - + // reset, unreachable host, a silent drop - leaves the question open, with one more way + // to close it: a port nobody listens on can be bound, a held one cannot (EADDRINUSE). + // A proxy bound to a Tailscale address is the case that needs this: the stack drops + // the SYN instead of refusing it, so the dial only ever times out. + "let bindStarted = false;", + "const bindCheck = () => {", + " if (bindStarted || settled) return;", + " bindStarted = true;", + " const server = require('node:net').createServer();", + " server.once('error', () => settle('UNKNOWN'));", + " server.listen({ host, port: Number(port), exclusive: true }, () => server.close(() => settle('DEAD')));", + "};", + "req.on('timeout', () => { req.destroy(); bindCheck(); });", + "req.on('error', err => {", + " if (err && err.code === 'ECONNREFUSED') settle('DEAD');", + " else bindCheck();", + "});", ].join("\n"); try { const probe = spawnSync( diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index 8db433fd45d..b66648b3315 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -209,6 +209,16 @@ describe("stop failure classification (#3008)", () => { } }); + test("a dial that is dropped, not refused, falls through to the bind check", () => { + // A proxy bound to a Tailscale address is never refused when it is gone: the SYN is + // dropped and the dial only times out, so the probe asks whether the port can be bound. + // 192.0.2.1 (TEST-NET-1) is not a local address, so the bind fails as well and the + // answer must stay unknown rather than being read as a dead proxy. The positive + // "bindable means dead" branch needs a local address that drops SYNs, which a test + // cannot create portably; it was checked by hand against the real Tailscale address. + expect(probeProxyLiveness(10100, "192.0.2.1", 400)).toBe("unknown"); + }); + test("the shared decision covers the whole post-stop matrix", () => { // This is THE predicate both updaters call, not a copy of it: src/update/index.ts and // bin/ocx.mjs each import decidePostStopUpdate. Testing a local reimplementation would From b823eaf01180f0d6cad62c1b04cb671676fbd25d Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 2 Oct 2026 22:00:23 -0400 Subject: [PATCH 2/5] fix(update): destroy bind-probe sockets and cover the silent-dial path with a local fixture --- src/update/proxy-liveness-probe.mjs | 1 + .../update/update-stop-classification.test.ts | 33 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs index 6023afe5a6d..36bb78b1ce3 100644 --- a/src/update/proxy-liveness-probe.mjs +++ b/src/update/proxy-liveness-probe.mjs @@ -73,6 +73,7 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150 " if (bindStarted || settled) return;", " bindStarted = true;", " const server = require('node:net').createServer();", + " server.on('connection', socket => socket.destroy());", " server.once('error', () => settle('UNKNOWN'));", " server.listen({ host, port: Number(port), exclusive: true }, () => server.close(() => settle('DEAD')));", "};", diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index b66648b3315..60c350069d9 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -209,13 +209,32 @@ describe("stop failure classification (#3008)", () => { } }); - test("a dial that is dropped, not refused, falls through to the bind check", () => { - // A proxy bound to a Tailscale address is never refused when it is gone: the SYN is - // dropped and the dial only times out, so the probe asks whether the port can be bound. - // 192.0.2.1 (TEST-NET-1) is not a local address, so the bind fails as well and the - // answer must stay unknown rather than being read as a dead proxy. The positive - // "bindable means dead" branch needs a local address that drops SYNs, which a test - // cannot create portably; it was checked by hand against the real Tailscale address. + test("a silent dial to a port nobody holds any more is dead, not unknown", async () => { + // The Tailscale case in miniature: the dial is neither answered nor refused, so it can + // only time out, and the port is free by the time the probe asks to bind it. The + // fixture accepts the probe's connection, closes its listener, and keeps that one + // socket open and silent. + const listener = spawn(process.execPath, ["-e", [ + "const net = require('node:net');", + "const server = net.createServer(socket => { server.close(); socket.on('error', () => {}); });", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + try { + expect(probeProxyLiveness(port, "127.0.0.1", 400)).toBe("dead"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + }); + + test("a dial that cannot be bound either stays unknown", () => { + // 192.0.2.1 (TEST-NET-1) is not a local address: the dial fails or times out and the + // bind fails too, so nothing proves the proxy is gone. expect(probeProxyLiveness(10100, "192.0.2.1", 400)).toBe("unknown"); }); From ed45962db00b50dec21183e97f1718546e4207dd Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 2 Oct 2026 23:44:17 -0400 Subject: [PATCH 3/5] docs(update): document the bind fallback in the liveness probe contracts --- src/update/proxy-liveness-probe.mjs | 9 ++++++++- structure/ops/service-and-sidecars.md | 10 ++++++++++ structure/runtime.md | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs index 36bb78b1ce3..947b49a13f0 100644 --- a/src/update/proxy-liveness-probe.mjs +++ b/src/update/proxy-liveness-probe.mjs @@ -16,7 +16,14 @@ import { spawnSync } from "node:child_process"; * stop. Fail-open was wrong here: a listener that accepts connections but withholds * `/healthz`, or a probe that times out, is exactly the state where replacing package * files is most dangerous, and "we could not tell" is not evidence the proxy is gone. - * Only a refused connection or a definitive non-OpenCodex answer earns `"dead"`. + * A refused connection or a definitive non-OpenCodex answer earns `"dead"`. So does one + * more observation when the dial is neither answered nor refused (a timeout, or a dropped + * SYN as with a listener bound to a tailnet address): the child tries one transient + * exclusive bind of the same host and port, and a successful bind means nothing holds the + * port at that instant. A failed bind (`EADDRINUSE`, `EADDRNOTAVAIL`, any error) stays + * `"unknown"`, a held-but-silent port included. The whole probe is bounded by the child's + * own `timeoutMs` plus a 1500 ms spawn ceiling, after which it is `"unknown"`. A successful + * bind is a statement about that moment, not a promise that the endpoint cannot restart. */ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) { // An unusable port is not an ambiguous probe: there is nothing to ask. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e075bac8b00..feef8fd91ab 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -413,6 +413,16 @@ so an override can never shorten the budgets that prevent a duplicate proxy, and `src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the constants in child processes. +The npm and Bun updaters confirm the stop with the plain-ESM tri-state probe +`src/update/proxy-liveness-probe.mjs` (shared with `ocx resolve`), decided by +`src/update/stop-decision.mjs`. A refused dial is `dead`. A dial that is only dropped or times +out, which is what a listener bound to a tailnet address produces once it is gone, falls back to +one transient exclusive bind of the same host and port: success is `dead`, any failed bind +(`EADDRINUSE`, `EADDRNOTAVAIL`) is `unknown` and still aborts the update. The probe's ceiling is +its dial timeout plus a 1500 ms child-spawn limit, after which the answer is `unknown`. A +successful bind records that nothing held the port at that instant; it does not claim the +endpoint can never restart. Focused coverage is `tests/update/update-stop-classification.test.ts`. + `src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages, and the managed Linux service additionally follows its mise package launcher onto an upgraded version ([package-tree integrity fence](docs-and-release.md#package-tree-integrity-fence)). ## Restart handoff diff --git a/structure/runtime.md b/structure/runtime.md index f988c76a0f6..da3e337f022 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -57,7 +57,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI resolve and stop contracts for embedding shells -`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. +`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. That probe answers "dead" on a refused dial or, when the dial is only dropped or times out (a listener bound to a tailnet address), on one transient exclusive bind of the same host and port that succeeds; a failed bind stays unknown. The bind is an observation at that instant and briefly occupies the port, so a proxy that starts in the same moment can lose that bind. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. `ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. From 2be1fe11caf4dbfde77741c7e875ec40a4d5da4d Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 2 Oct 2026 23:57:47 -0400 Subject: [PATCH 4/5] fix(update): restrict the bind fallback to literal IP addresses --- src/update/proxy-liveness-probe.mjs | 8 ++++++- structure/ops/service-and-sidecars.md | 5 +++-- structure/runtime.md | 2 +- .../update/update-stop-classification.test.ts | 22 +++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs index 947b49a13f0..8316cbc222d 100644 --- a/src/update/proxy-liveness-probe.mjs +++ b/src/update/proxy-liveness-probe.mjs @@ -20,7 +20,9 @@ import { spawnSync } from "node:child_process"; * more observation when the dial is neither answered nor refused (a timeout, or a dropped * SYN as with a listener bound to a tailnet address): the child tries one transient * exclusive bind of the same host and port, and a successful bind means nothing holds the - * port at that instant. A failed bind (`EADDRINUSE`, `EADDRNOTAVAIL`, any error) stays + * port at that instant. The fallback runs only for a literal IP address, because a name can + * resolve to different addresses for the dial and the bind. A failed bind (`EADDRINUSE`, + * `EADDRNOTAVAIL`, any error) stays * `"unknown"`, a held-but-silent port included. The whole probe is bounded by the child's * own `timeoutMs` plus a 1500 ms spawn ceiling, after which it is `"unknown"`. A successful * bind is a statement about that moment, not a promise that the endpoint cannot restart. @@ -79,6 +81,10 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150 "const bindCheck = () => {", " if (bindStarted || settled) return;", " bindStarted = true;", + // A name can resolve to several addresses, and the dial and the bind resolve it on + // their own: a live proxy on one address would not stop a bind on another. Only a + // literal address names the same endpoint on both sides. + " if (require('node:net').isIP(host) === 0) { settle('UNKNOWN'); return; }", " const server = require('node:net').createServer();", " server.on('connection', socket => socket.destroy());", " server.once('error', () => settle('UNKNOWN'));", diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index feef8fd91ab..732a51ad182 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -417,8 +417,9 @@ The npm and Bun updaters confirm the stop with the plain-ESM tri-state probe `src/update/proxy-liveness-probe.mjs` (shared with `ocx resolve`), decided by `src/update/stop-decision.mjs`. A refused dial is `dead`. A dial that is only dropped or times out, which is what a listener bound to a tailnet address produces once it is gone, falls back to -one transient exclusive bind of the same host and port: success is `dead`, any failed bind -(`EADDRINUSE`, `EADDRNOTAVAIL`) is `unknown` and still aborts the update. The probe's ceiling is +one transient exclusive bind of the same host and port, only when the host is a literal IP address +(a name can resolve differently for the dial and the bind, so it stays `unknown`): success is `dead`, +any failed bind (`EADDRINUSE`, `EADDRNOTAVAIL`) is `unknown` and still aborts the update. The probe's ceiling is its dial timeout plus a 1500 ms child-spawn limit, after which the answer is `unknown`. A successful bind records that nothing held the port at that instant; it does not claim the endpoint can never restart. Focused coverage is `tests/update/update-stop-classification.test.ts`. diff --git a/structure/runtime.md b/structure/runtime.md index da3e337f022..11681fd7710 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -57,7 +57,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI resolve and stop contracts for embedding shells -`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. That probe answers "dead" on a refused dial or, when the dial is only dropped or times out (a listener bound to a tailnet address), on one transient exclusive bind of the same host and port that succeeds; a failed bind stays unknown. The bind is an observation at that instant and briefly occupies the port, so a proxy that starts in the same moment can lose that bind. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. +`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. That probe answers "dead" on a refused dial or, when the dial is only dropped or times out (a listener bound to a tailnet address), on one transient exclusive bind of the same literal IP address and port that succeeds (a hostname never gets the bind, since a name can resolve differently for the dial and the bind); a failed bind stays unknown. The bind is an observation at that instant and briefly occupies the port, so a proxy that starts in the same moment can lose that bind. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. `ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index 60c350069d9..e9464df0ac9 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -232,6 +232,28 @@ describe("stop failure classification (#3008)", () => { } }); + test("a hostname never gets the bind fallback, because the dial and the bind may resolve differently", async () => { + // Same fixture as the silent-dial case, but addressed by name: the dial times out and + // the port is bindable, yet a name can resolve to other addresses for the bind, so a + // free port proves nothing about the endpoint the dial reached. + const listener = spawn(process.execPath, ["-e", [ + "const net = require('node:net');", + "const server = net.createServer(socket => { server.close(); socket.on('error', () => {}); });", + "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000); + listener.stdout.once("data", chunk => { clearTimeout(timer); resolve(Number(String(chunk))); }); + listener.once("error", error => { clearTimeout(timer); reject(error); }); + }); + try { + expect(probeProxyLiveness(port, "localhost", 400)).toBe("unknown"); + } finally { + listener.kill(); + await new Promise(resolve => listener.once("exit", () => resolve())); + } + }); + test("a dial that cannot be bound either stays unknown", () => { // 192.0.2.1 (TEST-NET-1) is not a local address: the dial fails or times out and the // bind fails too, so nothing proves the proxy is gone. From 8a9d95a76cf4e8fff2ab623cb1cde051601dcdb3 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sat, 3 Oct 2026 00:11:33 -0400 Subject: [PATCH 5/5] test(update): make the hostname case independent of localhost address order --- tests/update/update-stop-classification.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index e9464df0ac9..dceeb232785 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -235,11 +235,16 @@ describe("stop failure classification (#3008)", () => { test("a hostname never gets the bind fallback, because the dial and the bind may resolve differently", async () => { // Same fixture as the silent-dial case, but addressed by name: the dial times out and // the port is bindable, yet a name can resolve to other addresses for the bind, so a - // free port proves nothing about the endpoint the dial reached. + // free port proves nothing about the endpoint the dial reached. The fixture listens + // dual-stack (`::`, falling back to `0.0.0.0` where IPv6 is unavailable) so the dial + // is silent whichever address `localhost` resolves to first, instead of being refused + // on `::1` and returning `dead` before the hostname rule is reached. const listener = spawn(process.execPath, ["-e", [ "const net = require('node:net');", "const server = net.createServer(socket => { server.close(); socket.on('error', () => {}); });", - "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port)));", + "const report = () => process.stdout.write(String(server.address().port));", + "server.once('error', () => { server.removeAllListeners('error'); server.listen(0, '0.0.0.0', report); });", + "server.listen({ port: 0, host: '::', ipv6Only: false }, report);", ].join("\n")], { stdio: ["ignore", "pipe", "ignore"] }); const port = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("listener did not report a port")), 10_000);