Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions src/update/proxy-liveness-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ 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. 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.
*/
export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) {
// An unusable port is not an ambiguous probe: there is nothing to ask.
Expand All @@ -38,6 +47,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');",
Expand All @@ -56,15 +67,34 @@ 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;",
// 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'));",
" server.listen({ host, port: Number(port), exclusive: true }, () => server.close(() => settle('DEAD')));",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"};",
"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(
Expand Down
11 changes: 11 additions & 0 deletions structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,17 @@ 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, 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`.

`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 `<mise>/installs/node/<version>/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 <verified-alias>` 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
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

Expand Down
Loading
Loading