Signal fencing uses the configuration mutation lock (C), yielding 50 ms between at most 20 attempts only when ConfigMutationLockError wraps SQLITE_BUSY or SQLITE_LOCKED; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. tests/clients/client-link-stop.test.ts covers a real cross-process C transaction, retry exhaustion and non-contention errors.
src/client/sync-runtime.ts fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as clientStopping under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation: it reapplies routing even though no Child listener is serving it. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. A bound restart marks the record the same way before it drains the listener, and abandons the restart with the runtime still serving when the mark cannot be written; and every publication of the runtime record carries a fresh servingGeneration, so a sync admitted before a restart whose replacement failed is refused by the record the rollback publishes, while syncs admitted afterwards proceed (tests/clients/client-restart-sync-fence.test.ts). The separate-process catalog/settings-response holds in tests/clients/client-link-stop.test.ts cover late commits, fresh sync during shutdown and the completed-sync control.
Orderly signals in an unsupervised Link Child call src/client/stop-routing.ts before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. src/client/restore-ownership.ts optionally binds each write under C to the immutable served connection, including selection and rotation state. Only catalogFingerprint and catalogSyncedAt, updated by normal catalog sync, are excluded from this restore-specific comparison; priorCatalog remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with 409 runtime_stopping; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: tests/clients/client-link-stop.test.ts, tests/clients/client-runtime-restart.test.ts.
src/link/ssh-argv.ts and src/link/remote-windows.ts build every gated Child command through remoteGatedOcxArgv and windowsGatedOcxArgv: the script selects one executable, reads its version inside the remote script, refuses below the command's minimum with a named ocx-fanout: marker and exit 121, and only then runs the arguments. Every gated script exports OCX_NO_DESKTOP_HANDOFF=1 before the version probe and the run, so a package launcher answers for itself and a Desktop handoff cannot pass the gate on behalf of an older launcher. The pinned sync is the 2.93.0 instance and executes sync --expect-link; the Home adds --restart-app-server-only or --restart-codex only for a restarting sync (the Child operation runners below and ocx sync --restart-children), never on Windows (the builder refuses it, unsupported:windows-restart) or for a link recorded as ocx-child, and withholds it for a link whose relayed model traffic is in flight (src/cli/sync-fanout.ts, src/link/sync-then-restart.ts). A link whose childKind is not recorded can receive the flag on that second sync, which is how the selected-kind marker reports ocx-child; ocx-child ignores it. The update builders in src/link/child-gated-ocx.ts require 2.97.0 inside the script, so the Home never sends update to an ocx that reports an older version; they refuse a selected ocx-child by file name (ocx-fanout:child-ocx-ephemeral) and on POSIX report ocx-fanout:entry:pair|binary (Windows reads no census, so its script prints no entry marker and the Home infers binary); the plan probe also reports the Child's clock, and the shipped builders refuse Windows apply. A Windows command that would exceed the encoded command-line budget throws windows-command-too-long; the plan probe fits a Home version of at least 40 characters. src/link/child-update-contract.ts is the Home's own strict copy of the Child's update.orchestrated/1 plan and result documents and maps every apply exit to a closed verdict. src/link/child-observations.ts keeps a tolerant per-link cache in <configDir>/link/child-observations.json beside the strict links.json: it drops unknown keys, prunes removed links on write and reads damage as empty. The per-Child sync classifier lives in src/link/sync-fanout-classify.ts. The plan probe is wired to the observe operation (src/link/child-observe.ts, below), which always passes the resolved platform to classifyUpdatePlanProbe; the apply builder and the sync restart flag are called from the Child operation runners (below) and ocx sync --restart-children; nothing runs on a timer except the Child-ops scheduler (below), which submits no-restart sync jobs and restarts app-servers only for idle Children under childCodexRestart: "app-server-when-idle". The relay, not onAuthenticatedCatalog (which fires on admission and keeps driving the supervisor and the enrollment wait), records each Child's catalog pulls. Coverage: tests/clients/link-gated-ocx-argv.test.ts, tests/clients/link-child-update-contract.test.ts, tests/clients/link-child-observations.test.ts. Fan-out platform discovery in src/link/remote-platform.ts accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's src/cli/sync-command.ts fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. With --overwrite-conflict the pinned sync gate requires 2.100.0 and refuses ephemeral Children before executing; explicit client selection is forwarded. Known ephemeral links receive plain catalog sync. Coverage: tests/clients/link-pinned-sync-overwrite.test.ts, tests/cli/cli-sync-fanout-ephemeral.test.ts.
src/link/ owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import.
src/link/ssh-argv.ts builds every OpenSSH argument vector. All commands run with BatchMode and trust only the link known_hosts file, keyed by HostKeyAlias=<alias>: the global file is disabled with GlobalKnownHostsFile=none, and KnownHostsCommand=none, VerifyHostKeyDNS=no and CheckHostIP=no shut out every other source of host-key trust. Command-line options take precedence over ~/.ssh/config, so a user config cannot re-enable them. HashKnownHosts=no preserves alias-prefixed records in these private files without changing the user's normal known_hosts file. Tunnel and exec commands use StrictHostKeyChecking=yes; only the probe uses accept-new, against an empty temporary file, so an offered key can be shown before it is trusted. The known_hosts path must be absolute and free of ssh expansion syntax. Forwards bind 127.0.0.1 on both ends, and aliases that could be parsed as options are refused.
Every remote ocx call goes through remoteOcxArgv with the link's platform. On POSIX it is a quoted sh -c script. After an ephemeral ocx-child (below), it first tries the install that last started serving this home (#8359): every ocx start that binds records its relaunch argv at the front of $OPENCODEX_HOME/serving-runtimes.json (src/config/serving-runtimes.ts), [ocx] for a compiled binary or [bun, cli/index.ts] for a package install, so a desktop takeover or a service restart moves its own install to the front, and a start that never binds records nothing. The served install wins whatever its version, because it is what the Child runs. The script reads only the first entry's command, one or two absolute paths without quotes or backslashes in a file of at most 64 KiB; it runs a pair as bun <cli> ... only while the entry file exists, and any other shape, a first record without a command, a file cut off before its closing brace, a missing or unreadable file or a failed --version falls through. The reader accepts only the complete document in the recorder's own two-space layout. The pinned sync script shares this block, so the served install's own version gates sync --expect-link. Runtime inspection (POSIX_INSPECT_SCRIPT in src/link/remote-plant.ts) reads the same census through REMOTE_SERVED_RUNTIME, so ocx link probe reports the served install's version and kind. Windows does not read the census yet: the npm layout's [bun.exe, index.ts] pair needs an argument-taking probe in the compiled helper. Then it tries executable Desktop and system candidates before generic PATH shims: $HOME/.opencodex-desktop/bin/ocx, /Applications/OpenCodex.app/Contents/MacOS/ocx, /usr/bin/ocx, then inherited PATH plus Bun/local/Homebrew directories. Each candidate must successfully report --version; an unrunnable stale shim is skipped. Arguments remain quoted once. The script still exits 125 on a Windows/MSYS/Cygwin uname, a guard against POSIX dispatch to a Windows host; exhaustion exits 127 (remote_ocx_missing). On Windows, src/link/remote-windows.ts builds one powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand command whose arguments travel as base64 JSON data, never as shell text, and quoteRemote admits only that exact shape. The script tries the Desktop ocx.exe, the GOAT-managed .local/share/goat/opencodex/current/ocx.exe junction under the user profile, .local/bin/ocx.exe, the per-user and Program Files OpenCodex directories, then PATH. Each candidate must answer --version within ten seconds, and only the selected one runs the real command with CRT-quoted arguments and inherited standard handles, so the link key still arrives on stdin and the exit status passes through. The whole script travels deflated so the command fits, and the Child inflates it before anything in it runs. A helper compiled with Add-Type (OCX_JOB_SOURCE, a quoted literal inside that script, never evaluated as PowerShell) creates that process with PROC_THREAD_ATTRIBUTE_JOB_LIST, so it is a member of a Win32 Job with kill-on-close and silent breakaway from the moment it exists: terminating the PowerShell host, which is what the SSH server does with a dropped channel, kills that ocx.exe, while a runtime it starts on its own breaks away and survives. One finally block releases the attribute list and the process, thread and Job handles on every path and terminates the child through its own handle if anything fails after creation. If the inflation or Add-Type fails (Constrained Language Mode blocks both the stream types and Add-Type, for example), the command exits 126 before any ocx.exe is probed or started. A failure while the helper creates, waits on or reads the selected process also exits 126, after the finally block above has terminated that process. There is no uncontained fallback. The --version probes that choose a candidate run outside the Job, bounded to ten seconds each; only the selected command runs inside it. The inflation guard covers the inflation alone, so the exit status and errors of the inflated script itself pass through unchanged. The encoded command stays under 8,000 characters for cmd.exe's 8,191-character command line. A dropped real OpenSSH channel is a required native release gate: the opt-in case in tests/clients/link-ssh-argv-discovery.test.ts runs against a disposable Windows OpenSSH host named by OCX_WINDOWS_OPENSSH_FIXTURE. Exhaustion exits 127. Coverage: tests/clients/link-ssh-argv-discovery.test.ts.
src/link/remote-platform.ts discovers the platform through the pinned host key: requireRemotePlatform runs the fixed, locale-independent cmd /c echo %OS% (WINDOWS_OS_PROBE), treats Windows_NT as Windows, and otherwise classifies uname -s. An ssh transport failure or an unclassifiable answer throws, so enrollment answers probe_failed before it trusts the host or issues a key. The first-contact probe runs the cross-shell echo ocx-link-probe and requires that exact output. The probe stores the platform on the pending host, confirmation and apply reuse it, and the link record keeps it as the optional remotePlatform in src/link/store.ts; a record written before the field existed is discovered again under its pinned key. The optional childKind ("ephemeral" | "full") joined the same strict RECORD_KEYS set with the ocx-child plant flow, with the same trade-off as remotePlatform: a Home downgraded to a release without the field rejects a links.json that contains it, so downgrading needs those links disconnected (or the field removed) first. On Windows, enrollment skips the curl preflight and reads the Child's loopback /healthz and /v1/catalog with a bounded .NET request that ignores proxies, never follows a redirect and caps the body at 4 MiB (windowsChildHttpArgv). Ordinary revoke, the Child-initiated issue and revoke in src/client/link-join.ts and src/client/link-teardown.ts, and the read-only ocx link cleanup in src/cli/link-cleanup.ts use the same adapter. Coverage: tests/server/link-enrollment.test.ts, tests/server/link-management-routes.test.ts, tests/cli/cli-link-cleanup.test.ts, tests/clients/link-store.test.ts.
src/link/ssh-config.ts lists host candidates from ~/.ssh/config. Arguments are split with the rules of OpenSSH's argv_split. Pattern hosts, Match blocks and aliases that fail the alias check produce no candidates, and only top-level Include directives are followed, because an include inside a Host or Match block is conditional. Include discovery shields literal parentheses and braces from Bun's extended-glob and brace expansion, preserving existing escapes and character classes; wildcard matching, traversal options and lexical ordering remain with Bun.Glob. Coverage: tests/clients/link-ssh-config.test.ts. A candidate is an offer, not trust.
src/link/ssh-runner.ts drains tunnel stdout and stderr with a 64 KiB cap per stream. After the SSH process exits, either reader that has not reached EOF gets 250 ms to consume remaining bytes, then is cancelled and its lock released without waiting for the underlying cancellation promise. Already consumed diagnostics are retained for host-key, auth and network classification, so an inherited pipe cannot indefinitely hold the supervisor's exit classification or pause retries and the outage clock. This grace applies to spawned tunnels; bounded one-shot commands retain their command timeout.
src/link/ssh-capture.ts is opt-in SSH log capture for the tunnels, off by default (#7297). Its only authority is <configDir>/link/ssh-capture/control.json, an unexpired window that ocx link capture start (src/cli/link-capture.ts) writes on this machine: 15 minutes by default, 60 at most, and a window further out than that maximum, a foreign key, a symlink, a non-regular or multiply linked file, or (on POSIX) a file or directory that is not owner-only or not owned by this user reads as off. It is not a config key, so Home sync, management routes and remote input cannot open it, and the command needs no running proxy, so it works on a Child where management is refused. At each spawn the Home supervisor and spawnClientLinkTunnel in src/client/link-tunnel.ts call the per-directory opener; without the capture directory it costs one lstat and the spawn is exactly what it was (buildTunnelArgv keeps LogLevel=ERROR, spawnTunnel gets no options). With an open window buildTunnelArgv writes LogLevel=DEBUG1 in place of LogLevel=ERROR (the same argv goes to the pidfile) and spawnTunnel gets a stderrLine tap. The runner then hands each stderr line to the tap and returns, and caps at 64 KiB, only the lines the tap keeps, so debug output cannot end a healthy tunnel. src/link/ssh-capture-redact.ts decides both what is stored and what the classifier reads: a line is stored only as a fixed rendering of a known OpenSSH shape, with hosts, addresses, users, ports, paths, keys, fingerprints, commands, environment values and server messages replaced by placeholders and only digits and closed-set algorithm, method and error names carried over, or as the category unrecognized with a count; debugN: lines and the informational shapes ssh prints only above ERROR (including Connection closed by, Connection reset by, Timeout, server … not responding, Disconnected from, ssh_dispatch_run_fatal and Connection to … timed out, which ssh logs at INFO before exiting, and the kex_exchange_identification reasons kex.c logs with verbose_f) are kept out of classifySshStderr, so they leave the exit cause, retries and admission as they are at ERROR. An unrecognized unprefixed line still counts, as it would at ERROR; a server banner, which ssh prints only above ERROR, is such a line, and that is the one way a capture window can change the classified cause. Records go through an asynchronous queue of at most 512 (when it is full, an error-level line replaces the oldest queued debug or verbose record, so a flood never pushes out the line that explains a failure; every displaced or refused record is counted in queue_overflow) to one ssh-<startedAt>.jsonl per window, appended through an owner-only no-follow handle (Windows: hardenSecretDirAsync/hardenSecretPathAsync), capped at 1 MiB with one capture_truncated record, which ends the file for every later process too (the writer reads the file's tail before appending); the writer never creates the directory and stores nothing once the window expired or stop removed the control file. Retention deletes capture files 72 hours after their last write (at process start through retainSshCapture, which both supervisors call when they use the default opener, on spawn, on every capture command, and by one unref'd timer re-armed after every prune for the earliest remaining file's expiry, so a file an earlier process wrote expires on time) and start keeps at most four; stop removes only the control file. A running ssh keeps its level until it respawns. On Windows the reader trusts the private ACL the writer applied to the capture directory and does not re-check the control file's owner or ACL on each read; with OPENCODEX_HOME placed in a folder other users can write, another user could plant a control file and raise this user's ssh to DEBUG1, but nothing is stored, because every write requires the ACL hardening to succeed on a directory this user owns. That is an accepted residual; the same placement already exposes the rest of OPENCODEX_HOME. Coverage: tests/clients/link-ssh-capture.test.ts, tests/clients/link-ssh-capture-redact.test.ts, tests/clients/link-ssh-capture-tunnels.test.ts, tests/cli/cli-link-capture.test.ts.
src/link/tunnel-state.ts is the tunnel lifecycle reducer: connecting, connected, reconnecting with capped jittered backoff, and failed for auth, host-key and forward errors or after five minutes without a connection, whether or not an attempt is in flight. Without a retry policy, failed is terminal. CLIENT_TUNNEL_RETRY_POLICY gives failed a retryAt: timeout and forward failures are first retried after 60 seconds, and each retry that fails again doubles the delay up to maxRetryDelayMs (five minutes; the failed state counts those in retries). Auth failures start at that five-minute cap, so no more than 12 auth attempts reach the peer's sshd in an hour; a host-key failure is never retried. ssh's remote port forwarding failed is its own stderr class, remote_forward: it fails the link with reason forward, which the dashboard and the K16 reason already know, and keeps detail: "remote_forward_conflict" on the state. The Child's own tunnel runs with it, and src/link/supervisor.ts runs the Home's -R tunnels with HOME_TUNNEL_RETRY_POLICY, which keeps the auth and host-key rules but caps maxRetryDelayMs at 60 seconds, so a timed-out or refused-forward Child (a laptop that slept) is retried every minute instead of backing off to five minutes (#8470); auth still starts at, and stays on, its five-minute delay. A retry runs while the state still reads failed (inFlight), a ready event promotes it to connected, and an exit returns it to failed with the original since and the next, doubled retryAt, so a long outage settles at one attempt every five minutes on the Child (every minute for the Home's timeout and forward retries) instead of restarting fast backoff. A due retry whose ssh cannot be started at all (ssh missing, an unwritable pidfile, or on the Home no listener port) goes through failedSpawnTunnel on both sides: it counts as a failed retry and keeps the reason and detail it was retrying, because it reached no peer, so the delay keeps doubling and an auth failure keeps its five-minute cadence. The Home's supervisor promotes a first attempt to connected when its link key authenticates a catalog request or the child stays alive for five seconds. It promotes a retry (reconnecting, or failed with inFlight) on the same catalog request or once the child outlives a 15-second retry grace, longer than the ten seconds ssh's ConnectTimeout allows for the TCP connect, handshake and key exchange, because an idle Child may send no catalog request at all. Liveness is a heuristic here, as it is for a first attempt: authentication and the remote forward request come after ConnectTimeout, so one that stalls past the grace reads connected until ssh exits, and the exit returns the link to the retry path. Promotion is checked before the outage limit, so a child that has outlived its grace by the time a tick runs, at the limit or late after a sleep, is promoted. At the outage limit it kills the child, except a retry still inside its grace, which carries on as the failed state's retry. A reload spawns a tunnel only for a fresh link or a due attempt, so it neither skips the backoff nor starts a second child while an exited one is still reporting. reconnect(linkId) is the deliberate exception: it queues with reload, link stop and shutdown, uses the updated record when a preceding reload finishes, stops that link's child, forgets its backoff or failed state and spawns a fresh attempt. After the child exits it rechecks the persisted record, listener port, generation and reload revision; removal or a changed tunnel cancels that restart. Automatic ticks wait until lifecycle work drains. Overlapping reconnects for one link share one restart, and every spawn bumps a per-link generation, so an exit still being classified or a stop still waiting for its child never overwrites the state of a newer child. POST /api/link/{id}/reconnect (the dashboard's Retry on a failed or reconnecting Home link, and ocx link reconnect --link-id <id>) requires the link listener to be listening (503 listener_unavailable otherwise, as for apply), then calls it after ensureStarted, answers { linkId }, and refuses a Child-initiated link with 409 link_not_home_initiated because the Child owns that tunnel. DELETE holds the link against reconnects and automatic spawns throughout remote disconnect, key revocation and store removal. Successful removal reloads the supervisor before releasing the hold; a remote disconnect failure releases it before reloading to restore the retained tunnel.
src/link/tunnel-history.ts is the Home supervisor's memory of each -R tunnel beside its state: attempt counts ssh attempts since the link was last connected, lastTransition is the last change of state kind with its time and cause, and lastFailure the last cause that took the link out of a working state. A retry of a failed link that fails again, whether its ssh ran or could not start, counts as a transition. A cause is one fixed word: the stderr class (auth, hostkey, forward, remote_forward_conflict, network, unknown), timeout for the outage limit, spawn_failed when ssh could not be started and listener_unavailable when the store has no listener port. That last case is a forward failure under the retry policy, not a terminal state; a reload that brings the port back changes the record instance and starts a fresh attempt. The supervisor routes every state change through one function so the history sees each one, forgets a link's history when its record changes or disappears, and reports it as history in status(). attachLinkTunnelDiagnostics in src/link/status-projection.ts turns it into the link row's tunnel field (attempt, nextRetryAt, lastTransition, lastFailure, ISO times), which GET /api/link/status adds only for ?diagnostics=1 because an older ocx link status validates rows key by key; the current CLI asks for it and validates it. Each recorded transition is also a tunnel_transition diagnostic (below). Nothing in the history comes from ssh output or argv.
src/link/routes.ts holds the one route and method table for linked data-plane traffic; the hub-link listener and the client relay both decide admission from it. The read-only management table beside it is described in Child management relay and Home Codex settings.
src/link/store.ts persists link records in <configDir>/link/links.json with private permissions. Records hold aliases, ports, confirmed host-key fingerprints and data-key ids, never keys. The file is a trust boundary: unknown fields, malformed values and duplicate ids are errors, and hasLinks reports false for a damaged file. A null host-key fingerprint is accepted only for a client-initiated link, because the hub never opens SSH to that client. Link create/delete, compensation, and listener-port persistence re-read and mutate the store under withConfigMutationLockSync. A bind revalidates both the stored port and the presence of a recorded link before activation; deleting the last link during bind closes that listener without publishing a port.
Link deletion snapshots the complete record before remote cleanup and compares it with the current stored record under the config mutation lock before revoking the key or deleting the record. If the record changed or disappeared during the wait, deletion returns 409 link_changed and reloads the supervisor instead of acting on the replacement.
The Desktop-purpose grant does not establish operator pairing and cannot authorize joining. It attaches only the Desktop scope to the existing session; a session that was already paired retains that independent authority.
src/server/management/link-routes.ts accepts POST /api/link/join with exactly { "alias": string }. Because joining durably redirects local client traffic, the route requires an operator-paired dashboard session; a credentiallessly bootstrapped loopback session may inspect and confirm a host but cannot join. A Tailscale identity session receives 403 tailscale_session_refused, any other caller 403 forbidden, a runtime that is not standalone 409 standalone_required, and a standalone whose live listener port (resolveListenPort in src/server/management/system-restart.ts) is not its configured port, or cannot be determined, 409 join_port_mismatch, because the client runtime it restarts into binds exactly the configured port. These gates run before link state is read and before any SSH. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns join_rollback_failed with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns 202 { "linkId": string, "alias": string, "restarting": true }.
During enrollment, src/client/link-join.ts watches the spawned SSH tunnel through its 100 ms spawn grace, readiness checks and the connection attempt. Before an unauthenticated /readyz probe and again before the keyed request, the only LISTEN owner serving 127.0.0.1:<tunnelPort> must be that tunnel PID. Both requests use redirect: "manual"; only a 401 challenge permits the keyed request. A failed, empty, foreign or ambiguous ownership recheck withholds the key and reaches the same 15-second deadline check and up-to-100 ms polling delay as any other not-ready iteration. Repeated recheck failures therefore reach rollback instead of bypassing it. The deadline is checked between operations, not an independent per-fetch cancellation timer. An observed tunnel exit winning the readiness or connection race fails the join and runs compensation.
The enrollment scanner in src/server/port-reclaim.ts retains each distinct normalized (PID, bound address) pair from Windows netstat or the POSIX lsof, ss, then netstat fallback chain. For ss, it reads PID owner fields outside the quoted, attacker-controlled process name. It filters entries for the requested loopback address before deduplicating PIDs, so another socket owned by the same process cannot overwrite the relevant listener. Duplicate rows and IPv4-mapped aliases of the same address still collapse, and the PID-only API continues to return unique PIDs. This enrollment scan does not replace the runtime supervisor's asynchronous ownership check described under Client link transport; the check-to-connect race described there remains.
src/client/link-state.ts stores <configDir>/link/client-link.json with mode 0600. The sidecar contains exactly alias, hubHostKeyFingerprint, tunnelPort, peerListenerPort and linkId; it contains no key. The client tunnel port uses MIN_LINK_PORT = 1024 through MAX_LINK_PORT = 65535 and isLinkPort; the Home listener port keeps its existing 1–65535 contract. A dashboard join picks a free port at random from JOIN_TUNNEL_PORT_MIN = 20000 through JOIN_TUNNEL_PORT_MAX = 29999 (chooseJoinTunnelPort in src/client/link-join.ts), below the macOS, Windows and Linux ephemeral ranges, so an outgoing connection rarely holds the port when the tunnel comes back after a reboot. The persisted port of an existing link is never rewritten.
src/client/link-tunnel.ts owns the client ssh -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> process. A client runtime starts that supervisor when link transport and a matching sidecar are present, and it drives the tunnel with CLIENT_TUNNEL_RETRY_POLICY, so the Child reconnects by itself after sleep, an outage or a crash. A spawned or respawned tunnel, whether connecting, reconnecting or retrying from failed, is promoted to connected only when a keyed GET http://127.0.0.1:<tunnelPort>/readyz (the link key in x-opencodex-api-key, cache: "no-store") proves the link: a 200, or a 503 whose body (read up to 4 KiB) carries service: "opencodex". The Home's link listener answers 401 before it reaches /readyz, so that 503 only means the Home's own start-up readiness is pending or failed, which relayed requests do not depend on. Until then the probe backs off from one to five seconds, or up to 30 seconds while the link reads failed; while a request is held it runs on every one-second check instead. One probe runs at a time, detached from the check, and stop() or the end of the tunnel it probes aborts it, so a slow Home never delays noticing a disconnect or stopping. While connected the same probe runs every 30 seconds and is display-only: a 401 or 403 reports probe: "unauthorized", the readiness 503 probe: "home_not_ready", and any other failure probe: "home_unreachable" in the supervisor status, which the Child's GET /api/link/status shows as the child reason; it never cuts the tunnel. The key comes from the runtime's cached key source, so no probe reads the token file. The supervisor's one-second check is an unref'd interval that stats the sidecar and config.json and parses one again only after it changed. It stops the tunnel and schedules the existing standalone recycle when the connection is no longer connected with link transport, the link id no longer matches, or the sidecar disappears; an unreadable sidecar or connection state acts only after three consecutive checks, so one read during a write never ends a healthy link. Normal shutdown, including recycle, stops the client supervisor before the client listener; it sends TERM, waits at most five seconds, then sends KILL.
The client tunnel pidfile is <configDir>/link/client-tunnel.pid with { version: 1, linkId, pid, argv, ownerPid, startTime } and private permissions. startTime is captured from Linux /proc/<pid>/stat or macOS ps when the tunnel starts; an old record without it cannot authorize a signal. reapOrphanTunnel checks argv and that start time, plus launchd parentage on macOS, before TERM and again before any KILL escalation. A gone process or a proven different identity leaves a stale pidfile and returns absent without signalling. A reused PID discovered during the wait returns unresolved without escalation. While ownerPid is alive a remaining tunnel is owned. A proven orphan is sent TERM, given at most five seconds, then sent KILL only if the same identity still answers. Anything unproven (another parent, unreadable process identity, Windows) is unresolved and never signalled. The client supervisor watches an owned or unresolved tunnel without signalling it. It sends a keyed probe only while the pidfile still proves that SSH process identity and the local LISTEN socket belongs exclusively to that PID; otherwise it waits for the process to exit before starting its own tunnel. It settles the pidfile before its first spawn; when the start-up read is unreadable or does not match, the first check that reads a matching link settles it instead, and nothing spawns before that.
ocx disconnect on the client tears down a matching sidecar link by attempting one SSH ocx link revoke --link-id <id> on Home, then disconnecting the client state and deleting the sidecar after rechecking ownership. A revoke failure still completes local disconnect and prints Home revoke failed; run ocx link revoke --link-id <linkId> on the home.; orphan cleanup runs through the same reapOrphanTunnel rule before sidecar parsing, including when the sidecar is corrupt or mismatched. After connectClient commits during a join, a restart scheduling failure leaves the connection and sidecar intact and returns join_restart_failed; the operator restarts OpenCodex to finish connecting as a Child. When the restart after that commit cannot start a replacement, the old process keeps the Codex routing on its way out instead of restoring native Codex (restart handoff).
For an empty-store Home-initiated Child, src/link/status-projection.ts derives identity and tunnel port from the client-role link configuration, preserving the K16 DTO and its legacy hub-initiated direction spelling. A caller-supplied child observation (including clientLinkTunnelStatus failures) supplies state, timestamp and reason; configuration alone reports idle because it cannot prove a live tunnel. ocx status names Remote Link Home and reads its state through the local Child listener; ordinary Hub clients retain their Hub report.
gui/src/pages/RemoteLink.tsx distinguishes loading, successful empty discovery, and failed discovery. Add Child explains Home-to-Child SSH prerequisites and the Home's ~/.ssh/config, with a Host example, setup guide, rescan and manual alias entry. Find Home names the initiating computer instead. Rescan preserves the alias but resets fingerprint review; cancelled or superseded requests cannot restore stale state. Failed discovery offers Retry without claiming the host list is empty. Action errors and retries appear only in the active sheet while it is open; the server-code translation and sanitized hint remain intact. Probe recovery includes a display-only SSH diagnostic, and connecting still requires explicit fingerprint confirmation. Retry on a failed or reconnecting Home link stays disabled through both the reconnect POST and its subsequent status refresh, including POST failures. gui/tests/remote-link.test.tsx covers these presentation states and native control focus.
Status rows request /api/link/status?children=1, so a Home with the wp2+ runtime receives a child block per Home-initiated link; against any Home that omits it, the page renders as before and calls no child endpoint. gui/src/remote-link-child-ops-api.ts parses that block defensively: a malformed child yields no badges, an unknown job state maps to running so the page keeps polling (and a whole job whose top-level state is unknown reads running while each item keeps its own token), and unknown sync or lastOperation.state values render as <code> rather than a sentence. gui/src/pages/RemoteLinkChildActions.tsx renders the badges (version and skew, install kind, catalog, Codex restart needed or not running, last and running operation) and the four row actions from child alone. The Codex badges stay dormant until the Child reports child.codex, which no released Child sends yet (a later release adds it), so the catalog badge is today's only catalog signal. Check, Sync now, Restart Codex and Update each post one {op, linkIds:[id], restart?} to /api/link/children/operations; Restart Codex chooses app-server or codex in a dialog, and Update confirms the target Home version and says a manual update ends the turn relayed for that Child while automatic updates wait for idle. Check all and Update all post the explicit link ids they counted, at most 64 per request (even groups, one job each; the cap is CHILD_OPS_MAX_LINK_IDS in src/link/child-ops-types.ts, pinned by a GUI parity test; a submit gives up after 30 s and a job poll after 15 s), so a row the confirmation dialog excluded is never touched; restart has no bulk path. A job shows per-Child progress from GET /api/link/children/jobs/<id>, then the panel leaves and status refreshes; a job the Home forgot says so. Each polled job snapshot replaces the page's copy, so a row's actions and Disconnect are disabled only while that row's own item runs and come back when its item finishes, even while the rest of a bulk job continues; a 409 link_operation_in_progress renders translated. Child-op and policy activity stay in page state separate from the sheet's busy union, so no Child operation can block the Add Child sheet.
The Child automation panel reads GET /api/link/children/policy and writes PUT /api/link/children/policy. It loads once per mount and after a save, never on each status poll, so an in-progress edit survives polling. Saving sends only changed keys, except while the damaged-file warning is shown: then it sends all four so the visible modes persist. The server re-checks policy, link state and Child activity on every job. Coverage: gui/tests/remote-link-child-ops.test.tsx.
The dashboard link routes (GET /api/link/status, GET /api/link/diagnostics, GET /api/link/candidates, POST /api/link/probe, POST /api/link/confirm-host, POST /api/link/apply, POST /api/link/{id}/reconnect, DELETE /api/link/{id}, POST /api/link/children/operations and GET /api/link/children/jobs/{jobId}) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. POST /api/link/join is stricter and requires the paired session because it durably redirects this machine's client traffic. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why it cannot authorize joining and why src/client/machine-listener.ts keeps durable machine changes on the connected listener away from that session. The remaining link routes accept this casual-path trade, the same as POST /api/github/star in src/server/management/sidebar-routes.ts; it is not a secret-backed boundary like an operator-created pairing grant or the admin token.
The admin-token link routes require trusted loopback ingress. A runtime whose public listener is not bound to loopback has no TCP listener that qualifies, because the hub management listener is relayed by Tailscale Serve and must never confer that trust (#6913). src/server/index/local-control-listener.ts therefore opens a Unix socket at <OPENCODEX_HOME>/run/control-<public port>.sock (src/lib/local-control-socket.ts) on macOS and Linux when the public bind is not loopback. Its ingress is local-control, the only non-TCP ingress, and trustedLoopbackForIngress treats it as trusted. The boundary is the filesystem: the directory is created or tightened to 0700, must be a real directory owned by this user, and the socket is 0600; a TCP relay or a forged header cannot produce the ingress, which is derived from the Bun server object. Before dispatch the listener admits only GET /api/link/status and answers 404 for anything else, and the request still needs the admin token. Two runtimes can share a home and a public port, so the public path is not the address Bun binds: it is a hard link to an instance-specific socket, and a per-port lease records the owner's host, pid and process start time from the shared owner evidence in src/codex/prompt-lock-owner.ts. Reading that record, deciding it is stale, and publishing the new one all happen inside the claim mutex in src/codex/prompt-lock-claim.ts, so a record judged dead cannot be replaced by a live one before this runtime publishes. A live owner is never displaced — a second runtime refuses the socket and leaves it serving. A record from another machine, or one that cannot be parsed, is refused rather than treated as absent. A lease whose process is provably gone is a crashed predecessor, so its dead public link and private socket are replaced; any other file at the path is left alone and no socket is served. The run directory is registered with config ownership, so uninstall removes it. A path longer than sun_path in bytes is refused before anything is created, because a length in code units admits a non-ASCII home the kernel cannot bind. On shutdown the runtime gives the record up first, before it awaits its own socket teardown and before the other optional listeners shut down, so a successor that starts while that shutdown is still running is not blocked by a departing incumbent; it removes the public link only when that link is still the inode it published, because Bun's own stop unlinks the path it was given. A release that cannot take the claim mutex is retried, and if it still cannot run it is reported and retained on the listener, so the next stop or start finishes it instead of leaving a live lease nobody serves. src/cli/link-local-control.ts sends ocx link status there when the directory is still private and the socket is this user's, falls back to TCP only on a transport failure, and leaves an explicit base URL untouched. Coverage: tests/server/local-control-socket.test.ts.
A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. GET /api/link/status tells a GUI-session caller whether it may join: the response gains joinAvailable, true only for a paired dashboard session on a standalone runtime whose live port is its configured port, and joinDenied, which is null when allowed or the first failed gate (pairing_required, standalone_required, then join_port_mismatch). On a Child whose durable connection diverges from its startup snapshot, link and machine status instead return 503 client_state_changed, as does readiness; this reports the divergence without stopping or reconstructing the relay. An admin-token caller gets the exact K16 document without either field, because ocx link status validates it key by key. The dashboard reads an absent availability field as false and an absent or unknown denial as generic unavailability; while unavailable the Child role card cannot be selected by pointer or keyboard. A pairing refusal says to pair this machine first and offers code entry only on the existing same-origin literal-loopback transport. A port refusal instead asks the operator to restart on the configured port; neither status reads nor displaying this guidance mint a grant or change pairing, keys, routing, or runtime state. Before Connect as Child the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin /healthz, sends the join, then reads /healthz once a second, skipping status polls meanwhile, and reloads only when it reports role: "client" under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading /healthz every 5 seconds until the Child answers or the page is left.
Manual Hub pairing recovery is offered only for a missing session or HTTP 401/403 from status, and moves keyboard focus into the code input. Session validation inherits the form abort signal; navigation or unmount prevents completion from updating App state.
A connected Child answers GET and HEAD /api/link/status on its own listener for a GUI session: src/client/link-status.ts projects the client sidecar and the tunnel supervisor into the K16 document with role: "child", the listener off, no links and the child row, plus joinAvailable: false. A Home-initiated Child has no sidecar and reports child: null. In link mode /api/machine/status advertises the machine origin as the shared plane, because the tunnel's hub-link ingress serves no /api/* and no session bootstrap.
confirm-host requires the remote ocx --version to print opencodex <major>.<minor>.<patch> of at least 2.66.0, the first release with ocx link. The version is parsed to a bounded semver shape: each number has at most nine digits, an optional pre-release and build of at most 64 identifier characters each follow, and the token must end there. An older version answers 409 remote_ocx_outdated, output that does not start with such a line (a usage banner, or a version with anything else attached) answers 502 remote_ocx_unrecognized, and exit 127 answers 502 remote_ocx_missing. Every refusal restores the link known_hosts file and keeps the pending probe, so a retry within the probe TTL needs no new probe. Link error bodies may carry error.hint, one line from one of three sources: the last non-empty ssh stderr line with terminal escapes removed, the ssh runner's own spawn, timeout or output-limit failure, or, for remote_ocx_outdated, opencodex <parsed version> built only from the bounded version match. Stderr bytes are capped before UTF-8 replacement decoding; structured stdout stays strict UTF-8. Every hint then has control and bidi characters removed, OpenCodex secrets and URL queries redacted, and is capped at 160 code points, cut between code points so a surrogate pair is never split. Hints never come from stdin and are never logged.
src/link/ssh-runner.ts runs every OpenSSH and ssh-keygen argv without a shell and caps captured output. On POSIX it spawns them with the inherited PATH plus /opt/homebrew/bin, /usr/local/bin, $HOME/.bun/bin and $HOME/.local/bin appended once, so a ProxyCommand helper resolves inside a desktop sidecar that inherited a minimal PATH; Windows keeps the inherited environment. src/link/supervisor.ts keeps one ssh -R child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned link: API keys at startup, and stops children before the hub-link listener on shutdown. Automatic startup begins the supervisor only after the hub-link listener owns its socket; a bind failure must never leave a reverse forward targeting the persisted port. It reaps a leftover tunnel only when Linux /proc/<pid>/cmdline matches the recorded argv exactly; on other platforms a leftover is reported, never killed. src/link/status-projection.ts builds the status document the dashboard and ocx link status read, including persisted compensation failures, and Home enrollment verifies a fresh post-restart key-authenticated catalog as described below.
Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's ocx connect --link --key-stdin over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the src/link/ state persists a compensation_failed marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. src/cli/link.ts provides ocx link probe|add|port|issue|revoke|status|reconnect|cleanup|check|update|sync|restart-codex|policy; cleanup is local and read-only (see src/cli/link-cleanup.ts above). ocx link revoke is idempotent: a 404 link_not_found answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails.
Child operations run as jobs. src/server/management/link-child-ops-routes.ts serves POST /api/link/children/operations (body exactly {op, linkIds, restart?} with op one of observe, sync, restart-codex, update; linkIds is "all" or 1 to 64 distinct link ids; restart is app-server or codex, refused on observe, defaulting to app-server for restart-codex, and any request that carries a restart must name its links, so "all" with a restart is 400 invalid_body; 202 {job}, 400 invalid_body for any other op or key including trigger, 404 link_not_found, 409 link_not_home_initiated for a single Child-initiated link outside observe, 409 link_operation_in_progress, 409 no_eligible_links only when links.json has no link, 409 runtime_role_client on a Child) and GET /api/link/children/jobs/{jobId} (ljob_ plus 16 hex; 400 invalid_job_id, 404 job_not_found). The route always submits trigger: "manual"; only the in-process scheduler can submit auto. handleLinkRoutes delegates every /api/link/children/ path before its reconnect and DELETE matchers, so DELETE /api/link/children/x answers 404 and never removes a link, and DELETE /api/link/{id} answers 409 link_operation_in_progress while a job holds that link; otherwise removal reserves the link in the engine (reserve) before its first teardown await and releases it when it ends, so a job submitted during the removal gets 409 for that link alone or a busy item, and no job reaches a Child the removal is disconnecting. Both routes take the same principals as probe and apply; the local-control socket still admits only GET /api/link/status, so on a non-loopback Home the CLI verbs exit 1 because these operations are not available there (the dashboard has no controls for them). src/link/child-ops-engine.ts keeps the ocx.link.child-job/1 jobs (types in src/link/child-ops-types.ts) in one engine per Home process (sharedChildOpsEngine; the routes cache it on LinkRouteState.childOps and status reads its activeJob): one lock per link (a held link makes a single-link request 409 and a multi-link item busy, which leaves that link's lastOperation to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of sync, restart-codex and update ended unsupported/child-initiated at admission, the record re-read before SSH (refused/link-removed when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a ChildOpsEnv (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text.
src/link/child-ops-runners.ts holds the runners. The sync-then-restart sequence both the runners and ocx sync --restart-children use is src/link/sync-then-restart.ts. observe is src/link/child-observe.ts: client-initiated and ocx-child links answer without SSH; otherwise one gated update --plan (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers --version within 10 s (ocx-fanout:launcher:available|unavailable). sync sends the pinned sync --expect-link (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as catalogSyncedEtag; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying --restart-app-server-only or --restart-codex, withheld (restart.outcome: "skipped", reason restart-skipped:link-active) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports not-run; one that may have run but failed or timed out ends uncertain/restart-result-unknown. restart-codex is that path with the restart as its purpose: a busy link sends nothing (busy), Windows is refused without SSH (windows-restart), ocx-child reports unsupported (the record's childKind or the selected-kind marker). update sends an identity pinned sync first, then a fresh observe (Windows Children too, because desktop-request is decided before the windows-apply refusal), then the gated apply update --yes --json --version <home> --expect-plan <token> --not-after <childNow + 120 s> (gate 2.97.0, bounded by min(mutationBoundMs, 900 s) + 120 s, an SSH spawn failure ending failed/ssh-spawn-failed with nothing to recover, not sent with under 15 s of the window left), through <node> <root>/bin/ocx.mjs with OCX_UPDATE_ORCHESTRATED_CHILD unset only when the plan's installer is npm or pnpm (a missing Node exits 121 child-node-unavailable, owner action launcher-node-missing), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records suppressedTarget and a manual update clears it, an uncertain apply records pendingUpdateRecovery for an hour and is never re-sent. The Windows gated builder builds only update --plan. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then lastOperation to child-observations.json. src/cli/link-child-ops.ts (runLinkCheck, runLinkChildOp, the tolerant validateChildJob) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers 400 invalid_body, 5 for a conflict. ocx link update needs --yes, as does any restart of scope codex and restart-codex itself. Coverage: tests/server/link-child-ops-routes.test.ts, tests/clients/link-child-observe.test.ts, tests/clients/link-child-ops-engine.test.ts, tests/clients/link-child-ops-runners.test.ts, tests/clients/link-child-update-launcher.test.ts, tests/cli/cli-link-check.test.ts, tests/cli/cli-link-child-ops.test.ts.
Automatic catalog fan-out (050) is the one timer in Child operations. src/link/child-ops-host.ts builds the engine env without a request and starts src/link/child-ops-scheduler.ts; src/server/index/optional-listeners.ts imports it, with the routes module that owns the shared catalog monitor (catalogRevisionForRoutes, which src/link cannot import), only dynamically and only while the link listener owns its socket, stops it when the last link closes the listener and in stop() before the supervisor teardown, and a generation counter keeps a late import from starting it (INV-LINK-CHILD-OPS-01). The scheduler registers the data-free setCatalogAutoRefreshLinkFanout slot in src/codex/catalog-auto-refresh.ts (a changed tick) and polls the monitor every 5 minutes (dashboard and management edits), waits a 60 s quiet period capped at 5 minutes from the first change, then submits trigger: "auto" sync jobs for each connected Home-initiated link that holds no job, is not backed off, and whose catalogSyncedEtag differs from the current etag (a fetch never counts as delivered). A busy link qualifies only when its parity appliedCodexSettingsDigest already equals the Home's and its runtimeStartedAt is not before lastApplyAt (runtimeCurrentDigest; otherwise the Child's sync restarts its runtime); idle uses the policy idleAfterMs everywhere, including the POST env and the status rows. Idle links get restart: "app-server" only under app-server-when-idle, and runSyncItem withholds that restart unless the link is still idle when the item starts and just before the restart; the host's autoSyncAllowed re-asks the idle-or-same-digest rule at item start. createLinkBackoff (shared with 070) backs failures off from 5 minutes doubling to 6 hours per link and key; busy neither grows nor counts; child-ocx-lacks-expect-link waits for another reported peer version (a report without one keeps it blocked), or 6 hours when none was known. Each sync group is admitted against the policy read just before it, and none after stop(); a quiet timer that fires during a pass reruns it; a Child runtime never evaluates. src/link/child-policy.ts keeps <configDir>/link/child-policy.json (ocx.link.child-policy/1: catalogFanout auto, childCodexRestart never, updates auto-when-idle, idleAfterMs 600000 within 60000–3600000), strict: an absent file is the defaults, a damaged one reads fail-closed (manual, off, warning policy-file-invalid) and is never rewritten on read. GET/PUT /api/link/children/policy (same principals as operations; PUT takes a non-empty subset, 400 invalid_body, 409 runtime_role_client on a Child, 503 policy_write_failed) and ocx link policy read and change it. wp5 starts no update. Coverage: tests/clients/link-child-ops-scheduler.test.ts, tests/clients/link-child-policy.test.ts, tests/server/link-child-policy-routes.test.ts, tests/cli/cli-link-policy.test.ts, tests/codex-integration/catalog-auto-refresh-link-fanout.test.ts, tests/lab/link-child-ops-no-timer.test.ts, tests/e2e-style/link-child-catalog-oracle.test.ts.
A Home-initiated link can list several SSH routes to the same machine in the optional routes field of its record (src/link/store.ts): direct routes first, the relay alias last, at most MAX_LINK_ROUTES (8); ocx link routes edits it (below). The list always contains alias, which stays the pinned host-key name and the destination of every exec operation (sync, update, observe, revoke); a list equal to [alias] is normalized away, so a one-route link keeps the exact record an older Home reads. A Home downgraded to a release without the field rejects a links.json that lists routes (strict RECORD_KEYS), the same trade-off as remotePlatform and childKind. Each route passes assertSshAlias and is placed after --. Coverage: tests/clients/link-routes-store.test.ts.
Every route is checked against the one pinned key: buildTunnelArgv and buildExecArgv take an optional destination, and buildRouteProbeArgv runs echo ocx-link-probe with StrictHostKeyChecking=yes against the link known_hosts file, all with HostKeyAlias=<alias> (src/link/ssh-argv.ts). A route that offers another key fails with Host key verification failed; nothing is learned or written. Command-line options win over ~/.ssh/config, which stays trusted operator configuration exactly as for the alias today. Coverage: tests/clients/link-routes-argv.test.ts, and tests/clients/link-routes-openssh.test.ts with ssh -G against a hostile per-destination config plus an opt-in real fixture (OCX_OPENSSH_ROUTE_FIXTURE=1) whose ProxyCommand runs a private sshd -i as the current user.
One-route links take the supervisor's existing path unchanged. A link with several routes is driven by src/link/route-rounds.ts through a host interface the supervisor (src/link/supervisor.ts) implements. A due attempt starts a round over the routes whose rest has ended, in list order: the pinned probe (ROUTE_PROBE_MS, 10 s on the supervisor clock, aborted through SshRunner.run's signal), then the -R tunnel on the first route that answers before that deadline (any later answer is a timeout), as a candidate. The candidate runs at LogLevel=DEBUG1 with a stderr tap (composeGrantTap in src/link/route-select.ts) that recognises OpenSSH's own remote forward success|failure for: listen 127.0.0.1:<tunnelPort>, connect 127.0.0.1:<listenerPort> line and otherwise keeps exactly the sshLineClassifies decision (or the open capture window's), so the exit class is the one ERROR-level output would give. The Child's sshd grants that loopback listener to one session only, so the granted candidate is the only tunnel that can carry the link. Only after the grant does the readiness proof run over the same route: the Child's port from ocx config show --json, the client /healthz identity, then the Child's own GET /v1/catalog carrying a fresh 128-bit id in x-opencodex-link-route-proof (remoteChildHttpArgv). The Child's relay forwards it with relay authentication; the hub-link admission in src/server/index/serve-options.ts takes the header off the request before dispatch and reports the id with the key it admitted, and the registry in src/link/route-proof.ts (owned by src/server/index/optional-listeners.ts) accepts it only for that link key, before its attempt deadline, and under the link's current tunnel generation. The candidate is proven when that admission and a 200 catalog with models on the same request coincide before both the attempt and the candidate deadline; attempts repeat every 2 s until the candidate deadline: READY_PROOF_MS (30 s) from spawn for the grant, then 30 s from the grant for discovery, health and the proof, so a slow route that spends most of its first 30 s on the ssh handshake still has the full budget for its readiness execs (a candidate's deadline is at most 60 s after its spawn: a grant line seen after the first 30 s, before the next tick stops the candidate, counts from the 30 s mark; the first 1 s supervisor tick at or after the deadline stops it, as below; not measured on a real slow route). Only then does a multi-route link read connected: neither the 5 s grace nor a passive catalog notification promotes it. A record without remotePlatform is discovered over the candidate's route.
A candidate without a proof at its deadline is a failed route and is stopped (SIGTERM, SIGKILL at 2 s); a request on it gets today's reconnect treatment, never replayed or duplicated. This differs on purpose from a one-route tunnel, which reads connected after its 5 s grace and is never stopped for lacking proof: a granted forward whose relay cannot authenticate or serve looks the same as a slow one, and keeping it would starve the next route; once a route is proven it is never stopped for lack of later proofs. A stop whose exit is not observed by 4 s halts the round (routeBlocked: stop_unconfirmed) until it is. Each route rests by its last outcome (routeCooldownMs): transient, readiness and grant_unobserved on today's 1–30 s backoff, forward 60 s, auth 5 min, host key 10 min; probe and tunnel failures count separately and only a proof clears the tunnel count. The link reports one class to the reducer, the most recoverable latest outcome over all routes (aggregateRouteClass), so it fails on hostkey only when every route refused the pinned key; the next attempt waits at least until a route is eligible. A link never returns to an earlier route by itself: a proven tunnel stays on its route until it fails, because moving a working link needs a drain protocol for the requests on it, which does not exist yet. A direct route that was down when the link connected therefore stays unused until the route in use fails, the Home restarts, or an operator reconnect (ocx link reconnect, which stops the tunnel and starts a round from the first route whose rest has ended, cutting requests on it as any reconnect does). A proven tunnel is never stopped for a non-failure reason: a route edit that keeps its route, or a failed ssh client re-check, applies to the next round. Multi-route links require a Home whose ssh -V reports OpenSSH; the check runs lazily, off the one-route path, and an unsupported client blocks new rounds (ssh_client_unsupported, re-checked after 10 minutes or a route edit). For a multi-route link the pidfile records the tunnel's start time; at startup, when a reload adds the link or gives it routes, or on a later tick once an unreadable pidfile has become readable, only a process whose pid, argv and start time match is signalled, its identity is re-read before SIGKILL, a live pid whose identity cannot be read is never signalled, and while an earlier tunnel may live (orphan-unverified) no candidate starts and reconnect answers false; every blocking pidfile stays watched without signals for as long as the same multi-route record instance and pidfile exist (a replaced pidfile adds its process to the watch, nothing is signalled after a replacement, and the block lifts only when every watched process is gone), and once its process is gone (or its pid names another process) the block lifts and the link's first round starts. Status shows such a link as failed with reason stale tunnel may hold the port (the supervisor's orphan-unverified state, mapped in src/link/status-projection.ts); the way out is to stop that ssh process (its pid is in the link's pidfile), and the watch lifts the block. Without a readable start time (a macOS or Windows Home, or a pidfile written by a one-route run on Linux) a matching process is never signalled, and a pid reused by an unrelated long-lived process keeps the block until that process exits. Deleting the pidfile while the Home runs does not help, because the watch keeps the processes it already has; the operator's way out in that case is to confirm no ssh for the link is still running, delete <configDir>/link/<id>.pid, and restart the Home, whose startup then finds no pidfile and starts the first round. A one-route link that gains routes, flagged or not, gets the same watch from its pidfile, or loses the flag when no pidfile is left (ENOENT), when it has no running child (its spawn failed), and observe-only once the reload has stopped its unproven child or when the same reload replaced its record (a failed pidfile write leaves an older pidfile that may name an earlier ssh; removing a link drops its flag, so a link added again may carry none); only a child in the connected state, which the link adopts as its tunnel, skips the check (for a one-route tunnel that state is the grace or catalog heuristic, so it does not prove the forward is held). A one-route child whose pidfile write failed is owned and watched all the same: the link reads failed, the next tick sends it SIGTERM and SIGKILL 2 s later, and it stays owned until its exit is seen (an exit still unseen at least 4 s after the SIGTERM is warned once, [link] tunnel exit unconfirmed link=<id>, while the link stays failed), so neither the retry nor a reload that gives the link routes starts a tunnel past it (such a reload waits for that exit, as any stop does). A stop, reload, removal or reconnect that drains any tunnel child (a one-route tunnel, a multi-route candidate or a proven multi-route tunnel) sends SIGTERM and, from the tick, SIGKILL once a 2 s grace has passed. The grace counts from the drain's SIGTERM, except for a one-route child the tick had already stopped, which keeps the tick's stop time (and gets no second SIGKILL if the tick already sent one). A multi-route candidate that route-rounds had already stopped starts a fresh grace, so its SIGKILL can come later than 2 s after its first SIGTERM, and one route-rounds already halted with SIGKILL gets a second, harmless one on the same unreaped process. In every case an ssh that ignores SIGTERM no longer holds the lifecycle queue for long; the stop still waits for the exit to be observed, so a process whose exit is never seen even after SIGKILL still holds it, and the tick warns once ([link] tunnel exit unconfirmed link=<id>, at least 4 s after the drain's stop time, unless the tick already logged this line for that one-route child; a candidate route-rounds halted with stop_unconfirmed, already warned as [link] route candidate exit unconfirmed link=<id>, logs it as well once drained); shutdown keeps the timer running until its stops are done, so it escalates the same way, including for a drain a reload or reconnect had already started. A pidfile that exists but cannot be read or parsed may still name the ssh holding the port, whether it is found at startup, when a link is added by a reload (which settles it from its pidfile as startup does), when a link gains routes, or by a watch whose processes have all ended (which then does not lift the block, and only observes whatever the file names once readable, as for any replacement): the link is flagged, the supervisor warns once (link id only), and the file is read again on every tick, so making it readable turns the block into the watch above, and deleting it once no ssh for the link is running lifts the block within a second, without a restart; a restart while the file is still unreadable flags the link again. A reload that changes a flagged multi-route link's record instance does not lift the flag either (a new listener port changes every instance and keeps every tunnel port, so the earlier ssh may still hold the Child's port): a link with a watch keeps it, with every process it already holds, re-keyed to the new instance and observe-only, so it sends no signal and removes no pidfile, also when its processes end; a link with nothing watched is settled again from its pidfile in the same observe-only way, which an unreadable file keeps through its later re-reads. Removing a link drops its block and watch. A pidfile that cannot be removed once its process is gone, or once this Home's own tunnel exited or was stopped (a read-only directory, a sharing lock), is left in place with a warning (link id only): the block it was lifting stays lifted, the tick goes on to every other link, the exit is still classified, and a stop, reload or startup completes. On Linux with a start time the next start sees that process gone; without one, a pid reused by another process blocks a multi-route link at the next start until that process exits. Stopping a link for a reload, removal or shutdown waits for its ssh to exit, as for one-route links; a candidate halted with stop_unconfirmed has already had SIGKILL, so that wait ends only when the process is reaped, and going on without it could leave two tunnels on the one forward. A tunnel is owned and watched before its pidfile is written; a failed write stops it like a failed candidate. While lifecycle work (a reload, stop or reconnect) runs, route deadlines and stops still advance but no new probe or tunnel starts (a probe that settles meanwhile waits without a deadline), and a changed or removed link's round is cancelled before the first stop is awaited. Coverage: tests/clients/link-route-supervisor.test.ts, tests/clients/link-route-select.test.ts, tests/clients/link-route-proof.test.ts, tests/clients/link-ssh-runner-abort.test.ts, tests/server/link-route-proof-admission.test.ts, tests/clients/link-route-proof-relay.test.ts.
GET /api/link/status?routes=1 (attachLinkRoutes in src/link/status-projection.ts) adds routes (each name, the end of its rest and its last outcome class and time), route ({name, index, since} of the proven tunnel, or null) and routeBlocked to links with several routes; the default document is unchanged, except that an unsupported ssh client reads as failed with reason ssh_client_unsupported. ocx link status asks for it and validates the fields strictly (src/cli/link.ts). Route names are SSH host aliases: this document is served to the Home's admin token or dashboard session and is not in the Child-readable relay list; diagnostics, history and warnings carry the link id and never a route name. Coverage: tests/clients/link-routes-status.test.ts.
GET /api/link/{id}/routes and POST /api/link/{id}/routes (src/server/management/link-route-edit.ts, dispatched from src/server/management/link-routes.ts before the DELETE /api/link/{id} matcher; admin token on loopback or dashboard session, the authority of reconnect) list and edit a Home-initiated link's routes and always answer {linkId, pin, routes}; ocx link routes [add|remove|order] (src/cli/link-routes.ts) drives them. The alias is the pin and cannot be removed; order must name exactly the current routes; a new route goes just before the alias unless a position is given. An add is stored only after the route proved the pinned key: checkRouteTrust (src/link/route-trust.ts) requires the whole link known_hosts file to hold only plain one-alias lines or well-formed hashed lines, and at least one line matching the alias, all with the same key (a plain name without case, or a hashed line of the lowercased alias: releases before 2.89 recorded the probe's line hashed on a Home whose ssh_config hashes known_hosts, and OpenSSH lowercases HostKeyAlias before it hashes it, which tests/clients/link-routes-openssh.test.ts shows against real OpenSSH; those releases also appended a hashed line on every confirmation, and a later one adds a plain line beside it, so several matching lines are one pin when they carry one key, as OpenSSH accepts any of them), whose key fingerprint equals hostKeyFingerprint (pin_mismatch for another key or none, trust_file_unexpected); the first such line is copied to a private temporary file, and the route probe runs against it (409 route_unproven with the class word in error.cause; runner exceptions are transient). Each edit holds the per-alias operation set and the link's Child-operations reservation that removal takes, from snapshot to reload; under the config mutation lock the commit re-reads the store and the trust file, requires the same identity fields, route list and pinned line (link_changed, pin_changed), and replaces only that record's routes in the fresh store, keeping its other optional fields. A supervisor reload that fails after that write answers 500 routes_stored_reload_failed with the stored linkId, pin and routes, because the running tunnels may still use the old list until the proxy restarts; ocx link routes exits 1 and says so. Removal only runs inside the runtime owning the store, which the spend-owner lease keeps unique per state directory; other writers are caught by the commit check. Apply stores a new Home-initiated link only if, under the mutation lock that writes the store, no such link owns the alias in any case (409 link_exists) and the known_hosts file passes the same whole-file check as a route edit (confirmedLineTrust; 409 trust_file_unexpected for a pattern, host-list, marker or malformed line anywhere in it, which OpenCodex never writes, so a one-route apply now refuses a hand-edited file too) with the confirmed key on every line for the alias (409 host_key_changed), since a later confirmation under another spelling replaces it. Only a record that passed both checks becomes the one cleanup handles: any failure before that, a thrown lock or store read included, revokes the issued key alone and never stores the record; when that key cannot be revoked, apply answers 500 key_cleanup_failed and the key stays listed for the operator to revoke. A store write that throws after both checks leaves its outcome unknown; when the key then cannot be revoked, compensateNewLink stores the absent record again only if the same checks still pass under the lock of that write (its stillValid guard), and otherwise answers key_cleanup_failed without storing it. When that locked read or write fails too, whether the link is stored is unknown: it answers 500 link_cleanup_uncertain, which tells the operator to check the link status and revoke a leftover link key, and keeps the in-memory compensation_failed marker for a link that is listed. ocx link add prints its own fixed guidance for key_cleanup_failed and link_cleanup_uncertain (HOME_CLEANUP in src/cli/link-enrollment.ts, never the server's message) with the code; it says enrollment did not complete, never that the Child is unlinked, and when the body is residual it adds that the Child may still be connected and the Child recovery step. confirm-host writes through putConfirmedKnownHost (src/server/management/link-route-state.ts), which refuses another key for an alias a Home-initiated link owns, ignoring case, inside the same locked section as the write (link_pin_conflict), and putKnownHost replaces every line OpenSSH would use for the alias, case variants and hashed lines included. The route commands are not served by the local control socket. Coverage: tests/server/link-route-edit.test.ts, tests/server/link-confirm-pin.test.ts, tests/clients/link-route-trust.test.ts, tests/cli/cli-link-routes.test.ts.
A Home-initiated link may carry explicitRoute {proxyCommand, user, port}, with an optional identityFile (link.route/2), and exclusive: true in its record (src/link/store.ts). No link has either until a caller passes them, and a Home downgraded below this release rejects a links.json that holds them (strict RECORD_KEYS, the trade-off of routes). The same strictness applies across versions on the status read: an older CLI's validateStatus (src/cli/link.ts, also used by ocx sync through src/cli/sync-link-status.ts) rejects a row carrying exclusive or explicitRoute, so an older ocx link status or ocx sync against a Home with an exclusive link fails for the whole document, every row included. identityFile repeats that trade-off one release later: a Home or CLI from before link.route/2 parses the route strictly (checkExplicitRoute accepts exactly its three keys), so it refuses a links.json, a tombstones.json, an operation journal reservation or a status row whose route names one; a link without it keeps the byte-identical record, argv and enrollment digest. exclusive means the link has exactly one route, its alias: the store refuses a routes list beside it and an explicitRoute without it, and accepts both only on Home-initiated links. The explicit route is fixed at enrollment; nothing edits it, so changing it means revoking the link and enrolling again. The supervisor's record instance includes explicitRoute, so a hand edit of it in links.json on a running Home restarts the tunnel on the new route at the next reload instead of leaving it on the old one; exclusive alone changes no tunnel argv and is left out, so toggling it restarts nothing.
Every SSH builder in src/link/ssh-argv.ts takes a required route (ExplicitRoute | null), so the type checker makes each caller decide. Home-initiated paths pass the record's route, or the pending enrollment's; Child-initiated (src/client/) and messaging (src/messaging/) commands pass null, which yields the argv a Home without this field builds. A route becomes -o ProxyCommand=<cmd> -o ProxyUseFdpass=no -o User=<user> -o Port=<port> on the command line, which OpenSSH reads before any ssh_config value, for the probe, confirm-host, apply (plant, connect, restart and the residual purge), revoke (disconnect and purge), platform discovery, observe, sync and update, ocx sync fan-out, ocx link cleanup and every tunnel start. A builder refuses a route with any destination but the alias. HostKeyAlias, the link known_hosts file and the other pinned options are unchanged. Every command of such a link also starts with -F none (commonOptions), so it reads no ssh_config at all, neither the owner's nor /etc/ssh/ssh_config nor anything they include: list-valued settings (Local, Remote and Dynamic forwards, SendEnv, IdentityFile, CertificateFile) would otherwise add to ours, SetEnv, IdentityAgent, GSSAPI delegation and Tunnel would apply, a configured RemoteCommand would make every exec fail, and no command-line option can remove a configured forward from the tunnel. The identity then comes only from the agent named by SSH_AUTH_SOCK in the Home process's environment and the default ~/.ssh/id_* keys (on macOS, UseKeychain and Apple's other defaults stop applying too), unless the route names an identityFile: then -o IdentitiesOnly=yes -o IdentityAgent=none -o IdentityFile=<path> follow the route's options, so ssh offers that key alone, in every command above, the tunnel, recover (the tombstone keeps the route) and a revoke's remote disconnect. The tunnel keeps its single -R with ExitOnForwardFailure=yes and no ClearAllForwardings, which would remove its own forward. A link without an explicit route reads ssh_config exactly as before. -F none exists since OpenSSH 7.1, below the client the link options already need, and Win32-OpenSSH treats it the same way. Coverage: tests/clients/link-explicit-route-no-ssh-config.test.ts (argv of every command, ssh -G against a hostile config).
src/link/explicit-route.ts owns the accepted form, refused rather than repaired, because ssh runs ProxyCommand as exec <command> through the user's shell. proxyCommand: 1 to 1024 ASCII characters, 1 to 32 words separated by exactly one space; the first word an absolute POSIX path (/ then letters, digits, ., _, +, -, /), so neither PATH nor an exec flag decides what runs; every later word only letters, digits and . _ / : @ + , = -, not starting with = (zsh expands a leading =). No quote, backslash, $, backquote, ; & | < > ( ) { } [ ] * ? ! # ~, no % (ssh expands %h, %p, %r and %n from values ssh_config controls), no tab, newline, NUL or non-ASCII character. user: a letter or _, then up to 31 letters, digits, ., _ or -. port: an integer from 1 to 65535. identityFile (optional): an absolute POSIX path of 2 to 1024 characters, / then letters, digits, ., _, +, -, /, already normal (no empty, . or .. segment, no trailing /), because ssh expands ~, % tokens and ${VAR} in IdentityFile and splits an -o value at whitespace or a quote; it reaches ssh as one -o IdentityFile=<path> argument. The path forms are POSIX only, so a Windows Home cannot use an explicit route yet; for identityFile that is enforced: a Windows Home refuses every identity file (below), because Windows resolves such a path against the current drive and owner and mode bits say nothing about the file's ACL. proxyCommand must not hold a secret: it is visible to every local user in the process list (ps), and it is written to the tunnel pidfile, the GET /api/link/status rows and ocx link status --json. A relay that needs a credential reads it from its own file.
The probe, confirm-host and apply bodies accept optional explicitRoute and exclusive (src/server/management/link-route-request.ts). A form outside the one above answers 400 invalid_explicit_route with the reason as its hint, a route without exclusive 400 explicit_route_requires_exclusive, and exclusive other than true 400 invalid_body, all before any SSH. The probe binds the values to the pending host; confirm-host and apply must name the same values, absent meaning none, or answer 409 route_mismatch, so a probe another caller made with another route cannot be confirmed or applied by mistake. ocx link probe and ocx link add send them from --proxy-command, --ssh-user and --ssh-port, which go together, --ssh-identity-file, which needs them, and --exclusive. ocx capabilities --contracts lists link.route/1 for these flags and body keys, and link.route/2 for identityFile and --ssh-identity-file, which is how a provisioning caller tells a Home that supports them (src/cli/capability-contracts.ts). A request that names an identityFile also checks the file from its metadata alone, never opening it (identityFileRefusal, identityFileProblem): probe and confirm-host before their SSH, apply after its operation replay and before its journal entry and any SSH. A missing file, a symlink or other non-regular file, a file not owned by this user or open to group or others (ssh ignores such a key of its owner), a file whose own directory is not owned by this user or root or is writable by group or others, /tmp included (identity_file_directory), a path that passes through any other directory (walked entry by entry as it resolves: each directory on the way, each directory holding a symlink and each symlink's target) that is not owned by this user or root or that group or others can write without the sticky bit, or through a symlink not owned by this user or root, which its owner could repoint even in a sticky directory (identity_file_ancestor; another user could otherwise put a file or directory at the key's path after the check), or an unreadable one answers 400 invalid_identity_file with identity_file_missing, identity_file_not_regular, identity_file_permissions, identity_file_directory, identity_file_ancestor or identity_file_unreadable (also for a path that is too long or has too many symlinks) as hint. A group-writable directory is refused even when the group is the user's own, as sshd's StrictModes does, so a home or state folder the Home user owns, created under umask 002 (per-user groups on Ubuntu or Fedora), is refused until chmod go-w on it (go-w rather than g-w, so a 0777 one is fixed too); that clears only the mode, and the folder's owner and every other component of the path must pass as well. The messages (IDENTITY_FILE_GUIDANCE) offer a path that passes, or chmod go-w on a private folder of the user's or of the tool that chose the key's path (moving a key a tool placed would strand that tool), and forbid any change to a shared or system directory such as /tmp, /var/tmp or /dev/shm (no chmod, no sticky bit) or to another user's symlink, even for a Home running as root, which owns them and could; the permissions text says to use a key of the user's own and never to chown another user's or the system's key, and the unreadable one says to use a shorter path without symlink loops and to fix read access only on the user's own file or folder; OpenCodex does not look up group membership. The message is the fixed text for the hint (IDENTITY_FILE_GUIDANCE), which says what to fix and names no part of the path, and ocx link probe|add prints that same text with the code and hint; on a Windows Home every identity file answers it with identity_file_unsupported_platform. It is a metadata check at request time: ssh opens the path later, so a change this user or root makes in between is not caught, and nothing binds ssh to the file that was checked. Because apply replays first, a repeat of an operation id answers its recorded outcome, or operation_mismatch for another key, whatever the file looks like now. Later commands do not check it again; ssh does, and a key it cannot use fails public-key authentication like any missing key (auth on the link, remote_disconnect_failed for a revoke, 502 remote_recover_failed with hint ssh_failed for recover). A forced revoke of an ephemeral Child whose key is gone leaves a tombstone that keeps the route and its key path; once the alias is enrolled again, recover answers alias_linked for it while that new link exists, and only a person can clear it then (ocx link recover --forget, after confirming the machine is clean or gone); once the new link is revoked and the old key is back, recover can clean the machine and settle it. The enrollment digest covers the whole route, so a repeat of an operation id with another key answers operation_mismatch. The dashboard sends no explicit route. POST /api/link/{id}/routes on an exclusive link answers 409 link_exclusive before any probe and again under the commit's mutation lock; GET answers with exclusive: true, and ocx link routes accepts it. GET /api/link/status rows of exclusive links carry exclusive and explicitRoute (the admin token or a dashboard session; the path is not in the Child-readable list), and every other row is unchanged. Coverage: tests/clients/link-explicit-route.test.ts (forms, store rules, argv, the supervisor's tunnel start, and ssh -G against a hostile ssh_config), tests/clients/link-explicit-route-paths.test.ts, tests/server/link-enrollment-explicit-route.test.ts, tests/server/link-route-edit-exclusive.test.ts, tests/cli/cli-link-explicit-route.test.ts.
ocx link add --operation-id <id> and ocx link revoke --operation-id <id> journal one enrollment or revoke in <configDir>/link/operations.json (src/link/operations-journal.ts), so a caller that loses the answer, or a process that dies, can settle exactly what that operation did. Nothing passes an operation id until a caller such as a provisioning hub does. On a Home whose journal file does not exist, an apply or revoke without one behaves and answers exactly as before: no lock file, no journal read, no new refusal, and the file is never created. links.json is unchanged. The file is a trust boundary like links.json: strict keys, a damaged file is an error and never an empty journal, atomic 0600 writes, and every read-modify-write runs inside the config mutation lock and writes only when the journal changed, so a read that finds nothing to do (an unknown id or an entry that cannot be resolved) never rewrites the file and never creates it. Its existence alone is what puts plain applies and revokes on the lock path (journalInUse). It holds no secret: link and key ids, ports, the public explicit route, effect marks and fixed error codes, never remote text.
An entry is created before the operation's first effect and reserves the identities its effects will create: the link id and the data key id (the key is issued under that id; issueApiKeyInProcess accepts a reserved UUID), and the explicit route and exclusive flag of the enrollment. The Child picks the tunnel port, after a plant on a bare host, so the port is recorded before the first Home effect rather than before the plant. Apply marks an intent before and a completion after each effect: plant, key_issued, link_recorded (the store write), child_enrolled (the connect, which carries the reserved link id) and relay_verified (restart and the authenticated catalog). Revoke marks remote_cleanup, key_revoked and record_removed; the first of them that applies (key_revoked for a force revoke) is written before the tunnel is stopped. A revoke intent that cannot be written stops before its effect with 500 operation_journal_failed and the operation id; a completion that cannot be written is logged and skipped (recovery already reads an intent without its completion as "may have happened"), so the link's sockets still close and the tunnel is not restarted toward a purged Child. A purge's completion stores {purged: true, linkId} as its receipt, and that one completion is strict: if it cannot be written the revoke stops before the key (unknown, tunnel left down toward the purged Child), so the link is never removed on evidence a crash would lose, because purgeDisconnectRemote succeeds only when the managed ocx-child reported purged:true over the link's pinned route. The Child's report names no link id itself; one that does needs detach --purge --expect-link (lane O5). A cleanup that failed before the Child's disconnect ran (platform discovery, the purge's runtime inspection, a full install, no planted binary to run), a link-bound refusal the Home recognises as one that changed nothing (exit 5 with full ocx's child_link_mismatch line and a no-write reason, or an ocx-child conflict or residual line naming this link: hints child_link_mismatch and child_residual; classifyBoundRefusal), and a refused unbound fallback (expect_link_unsupported) changed nothing there: the revoke restarts the tunnel and a journalled one settles failed_clean, except a revoke that resumes an earlier attempt's retirement, which keeps the tunnel down and settles unknown since that attempt may have run the cleanup (keepsTunnelDown). Any other cleanup failure (a timeout, any other nonzero exit including an exit 5 or 6 the Home does not recognise, child_disconnect_pending, no purge report) may have disconnected or purged the Child, so a journalled revoke leaves the tunnel down and settles unknown; a plain revoke restarts it either way, as before. A revoke past its remote_cleanup intent that settled unknown, or that is still open with no revoke in this process running it (it died), suspends its link: the supervisor starts no tunnel for it on a reload or after a restart (revokeSuspendsLink). A running revoke is registered as live; one that leaves the tunnel down drops that mark before it releases its supervisor hold, so a reload before its outcome is written already finds the link suspended, and only a revoke that restarted the tunnel itself stays live until its outcome is written (or it throws). A suspended link is visible: the supervisor's status carries the revoke, the status row reads failed since the revoke settled with reason revoke_unsettled (the row keeps its keys, so every CLI's strict check accepts it), and a reconnect (ocx link reconnect, the dashboard's Retry) stops nothing and answers 409 link_suspended with the operation id and state; the CLI builds the next step itself from the validated id and exits 5. Resolving the entry (ocx link operations resolve) reloads the supervisor and lifts the suspension. An open entry cannot be resolved (409 operation_not_resolvable): the way out (the Home stopped, or the revoke failed before writing its outcome) is to repeat the revoke with the same operation id and the flags of its first attempt, which recovers the entry from its own marks (the entry keeps only a digest of the flags, so a different --purge answers operation_mismatch), then resolve it if it ends unknown. A damaged journal suspends nothing (tests/clients/link-revoke-suspend.test.ts).
Outcomes are committed, failed_clean, residual and unknown (open while running). A live apply that answers 202 commits; one whose compensation could not finish (compensation_failed, key_cleanup_failed, link_cleanup_uncertain), or whose reserved key issue threw after the config may have been saved (key_issue_uncertain), is unknown; one whose answer is residual, or whose plant did not report completion (only the plant script's own hash_tool_unavailable refusal proves nothing was installed), is residual; an enroll entry also records childResidual whenever the Child may still hold an enrollment or a plant, whatever the outcome says about the Home's key, so a replay of an unknown outcome still answers residual: true with the Child's recovery step (the managed binary's detach --purge at every managed location, ~/.cache/opencodex/bin/ocx-child, %LOCALAPPDATA%\opencodex\bin\ocx-child.exe and, without LOCALAPPDATA, %USERPROFILE%\.cache\opencodex\bin\ocx-child.exe, when the entry shows a plant, since a disconnect would leave it). Every such step is bound to the operation's link id with --expect-link (src/link/residual-recovery.ts), which the answer carries as linkId, so a step read long after the attempt never removes a link the machine joined since. Journalled answers also carry residualKind (plant or enrollment), which ocx link add maps to its own guidance: a replay never probed, so the CLI cannot infer the Child's kind; anything else is failed_clean, meaning nothing this operation created remains and the Child was not left enrolled (a key issued and revoked before any Child contact counts as clean). A live revoke that answers 200 commits; a failed one is failed_clean only when none of the Child cleanup, the key revoke and the record removal had started, and unknown otherwise. The digest of an enrollment covers the alias, fingerprint, explicit route, exclusive and requireEphemeral; plant follows from the probe, which a repeat does not run. A revoke's covers the link id, purge and force.
A repeat with the same id and digest returns the recorded outcome without SSH; another digest or kind answers 409 operation_mismatch. A repeat that finds the entry still open holds the alias lock, so the process that wrote it is gone, and recovers by reserved id only: a completed relay verification committed (only its outcome write was lost); otherwise whatever carries the reserved link id or key id is undone (compensateNewLink or the key revoke), another Home-initiated link for the alias is not this operation's and makes the outcome unknown (the reserved key is still revoked when no record carries the reserved link id, since that id names only this operation's key), as does compensation that fails, and an enrollment intent or a plant makes it residual (O5 cleans that Child from the entry's reserved link id, alias and route). A recovered revoke reads only its own marks: it commits when its link is gone after its own removal intent (every other revoke of that link was refused while its entry was open, so the record went by its write); a link gone before that mark went some other way and is unknown; with the link still listed, any started effect (cleanup, key revoke, removal) is unknown and none is failed_clean. On a Home with a journal, any apply, with or without an id, refuses an alias that has an open enroll entry of another operation, and any revoke refuses a link that has an open revoke entry of another operation (409 link_operation_open).
A removal also writes its own retirement receipt (src/link/retirement.ts, <configDir>/link/retirement-<linkId>.json) after the Child cleanup and before the commit, naming the full record, the cleanup mode it ran (force, disconnect or purge) and, for a journalled revoke, its operation id. A removal interrupted after that point resumes from the receipt even once the record is gone, and a retry asking for another mode answers 409 link_retirement_mode_mismatch before anything runs, since it would skip the cleanup it asks for. A fresh removal continues a receipt only under the identity that claimed it, else 409 link_retirement_owned: while its owning entry is journalled only that operation (adopting it would credit another with a removal, or a Child purge, it never ran and clear the receipt the owner settles from); a receipt with no owner, or whose owning entry the journal has pruned, only a plain revoke, since no new journal entry (not even one reusing the old id) holds the evidence to settle it. The owner is the revoke of that same link under the receipt's id (retirementOwner), and the journal never prunes it while its receipt exists (ownsRetirement, passed as the keep pin of updateOperationsJournal), so recovery cannot lose it between two effect marks and its id cannot be reused meanwhile. A receipt is never evidence that the Child was cleaned up: while the record is still listed, a removal that meets a receipt runs the Child cleanup again unless the owning journal entry recorded it (cleanupRecorded: remote_cleanup done and, for a purge, the Child's purged:true for that link, the same evidence a replayed purge answers from), as a removal always runs it on dev. Once the record is gone the commit already followed a cleanup, and the receipt only drives the local post-commit work (route intent, listener, receipt removal), which a planted receipt cannot turn into a skipped cleanup. A plain --force may finish any receipt (it removes the link and its key locally, claims no Child cleanup, writes no journal and leaves an owning entry with its recorded outcome), which is the way out when a recorded purge cannot run again because it already removed the Child's executable; the mode check refuses only a retry whose stronger cleanup would be skipped. Its owner, if open (a dead process) or unknown, is fenced for the whole takeover (fenceRetirementOwnerForForce): reopened as an open, live revoke once that write is durable (resolve refuses it, the open-revoke guard exempts that one owner), its live mark dropped as the tunnel hold is released, and settled unknown and unresolved afterwards, so it is never credited with the force's removal and a link the force could not remove stays suspended. The force's refusals (another open revoke, a missing or changed link) run before it fences or disowns anything, and a takeover that stops before its removal reaches the tunnel (its first intent, written just before the hold and stopLink) gives the receipt back to its owner (reownLinkRetirement) and restores the owner exactly as it was, resolution included. A takeover that removes the link settles its owner before the receipt is cleared, so a crash after the clear cannot leave the owner open with its own removal intent for recovery to credit. A disowned receipt names its formerOwner: if the force dies (or stops) before committing anything, so the record is still listed and its key still present, that operation's replay takes the receipt back and finishes from its own evidence; once the force has revoked the key or removed the record, the receipt stays the force's and the owner is never credited. Every later plain force fences that former owner as it would the owner, and the journal pins it (like the owner) while the receipt remains. With force-revoke recovery (ocx link recover, tombstones): only a forced revoke that will remove a still-listed record writes a tombstone, so a resumed retirement never recreates one recovery may have settled (nor hits the tombstone limit for local follow-up work), and recovery refuses 409 link_retirement_pending under the alias lock while the link's retirement receipt is outstanding. Before changing anything the force then disowns the receipt (rewrites it without the operation id under the config lock, disownLinkRetirement), so if the force dies part way the owner's replay can no longer resume that receipt and be credited with the force's removal. A damaged journal does not stop that force (as on dev): the owner is left unsettled but the receipt is still disowned. From its first intent (written before stopLink, whether or not the Child cleanup is rerun) a resumed revoke runs its owner as an open, live revoke again (resolve refuses it meanwhile; the live mark is set only once that write is durable), and a resume that fails settles it unknown and unresolved (settleResumedRevoke), so no resolution can cover an attempt whose outcome was pending and the link stays suspended until it settles. A readable journal whose owner cannot be fenced refuses the force before the receipt changes hands. A resumed revoke never restarts the tunnel after a failed cleanup, even one that provably did not run this time (keepsTunnelDown), since an earlier attempt may have reached the Child; its owner settles unknown and the link stays suspended. Likewise a journalled revoke whose cleanup ran keeps the tunnel down when its receipt claim then fails because the record changed (409 link_changed, settled unknown); the only tunnel restarts in removeReserved are for a cleanup that provably never ran on a fresh revoke, a plain revoke, or a revoke that ran no cleanup. A replayed open revoke settles committed only when no receipt for its link is outstanding, and a resumed owner recorded failed_clean (a journal write failure right after its claim) settles committed from the resumed removal's marks. A repeat of a recorded revoke finishes its own outstanding receipt (the one naming its operation id and requested mode; another removal's receipt is left alone and the recorded outcome replays) and its post-commit work (route-intent reconciliation, listener, receipt removal) before it replays, then settles a revoke whose own removal intent preceded the record's disappearance as committed (settleRevokeAfterRetirement). That resume first takes the link's Child-job reservation, as a fresh removal does, so a job admitted after a restart gets 409 link_operation_in_progress against it rather than losing its key mid-run. When the record is still listed (the process died after the receipt claim), the resumed key revoke and record removal are marked on the same journal entry (resumedRevokeJournal), so a crash after them still settles committed. The resumed entry settles before the receipt is cleared, so a failed or interrupted settle leaves the receipt for the next repeat. Settling checks and writes in one journal section and drops a resolvedAt a resolve left on the entry, since the journal refuses a committed entry that carries one. Coverage: tests/server/link-revoke-operation.test.ts, tests/server/link-management-routes.test.ts, tests/clients/link-retirement.test.ts.
Enrollment and revoke of one alias take a per-alias lock file (src/link/alias-lock.ts): <configDir>/link/locks/<hash of the lowercased alias>.lock, private like the client lifecycle lock, held with SQLite BEGIN IMMEDIATE for the whole operation and released by the OS if the process dies. A second holder, in this process or another, polls on an injected clock for at most ALIAS_LOCK_WAIT_MS (120 s, a third of the CLI's 360 s enrollment deadline, which bounds the wait itself; it does not promise the waiter time to finish: an apply's own worst case can outrun the deadline, and then the CLI reports the outcome uncertain and a repeat with the same operation id replays it) and then answers 409 link_operation_in_progress; the wait also ends at the next poll once the request's own signal aborts (the client went away), with nothing changed; so a force revoke cannot run between a purge and its receipt, and a concurrent enrollment of the alias waits. An apply or revoke with an operation id always takes it, and once the journal file exists every apply and revoke does, so a force revoke cannot slip between a journalled purge and its receipt (that purge writes the journal before it starts). A Home that never journalled takes no lock. The first journalled operation on a Home still cannot interleave with a plain one that checked for the journal before the file existed: inside the runtime, every revoke takes the link's Child-operation reservation before anything else and every apply the per-alias operation set for its whole run, and a journalled revoke (or any revoke once the journal exists) takes that per-alias set too, so a second revoke of the link, or an apply and a revoke of one alias, answer 409 link_operation_in_progress instead of overlapping; and only the runtime holding the spend-ledger owner lease for the state directory (src/lib/spend-ledger-owner.ts) serves these routes, so no second process acts on the same store. Lock order: alias lock, the in-memory per-alias operation set, the config mutation lock. The set (serialized in src/server/management/link-enrollment.ts) is keyed without case like the lock and the journal, so two spellings of one alias are one alias everywhere.
requireEphemeral (ocx link add --require-ephemeral) runs a census before any plant, key or Child change: POSIX_FULL_INSTALL_CENSUS_SCRIPT in src/link/remote-plant.ts counts every runnable full ocx the gated argv could select (the served-runtime census, Desktop, system and every PATH directory) and, unlike runtime inspection, never stops at an ocx-child. Any one answers 409 full_install_present, also beside an ocx-child; an unfinished census answers 502 full_install_census_failed; a Windows Child answers 409 require_ephemeral_unsupported.
GET /api/link/operations, GET /api/link/operations/{id} and POST /api/link/operations/{id}/resolve (src/server/management/link-operation.ts, the authority of the other link routes) read the journal and mark a residual or unknown entry resolved; resolveLinkOperation is the call O5 makes once it proves a Child clean. Committed and failed_clean entries are pruned 30 days after they finish (also after a later revoke); residual and unknown ones stay until resolved and are pruned 30 days after that. Only unsettled entries (open, and residual or unknown ones not yet resolved) can block: at OPERATION_JOURNAL_MAX_UNSETTLED (256) of them a new operation answers 409 operation_journal_full before any effect, naming the way out (resolve each residual or unknown entry once its Child is checked, repeat an open one with its id first); marks on existing entries are always written. Each operation_journal_full carries a closed bound (unsettled or recent), an additive field an older CLI drops while still refusing; ocx link add and ocx link revoke with --operation-id print their own guidance for it with the code, adding (bound <bound>) when the Home sends it, and a journalled revoke that did not complete prints (code <code>) from the filtered body, preceded by (operation <id>: <outcome>) when the answer names this operation and a known outcome, and, when the outcome is unknown, telling the caller to repeat the same id first (if this id still owns an unfinished removal, the repeat resumes it; an unknown can also arise before any receipt was claimed) and, if it does not finish, to read its state (ocx link operations --operation-id <id>): for committed (a resume whose record was already gone settles committed, and clearing its receipt can still answer 503) to repeat it once more to clear what it left, for unknown or residual only then to check the Child and run ocx link operations resolve, for open (a resume while the record is still listed reopens the entry, and one that fails and cannot settle leaves it open, which resolve refuses) to fix what stopped it and repeat the same id; operation_journal_failed and link_changed add a next step: repeat with the same id (for operation_journal_failed after fixing the journal's disk, and a new id only through link_retirement_owned's guidance). For link_changed the text adds what a second link_changed means: the link may have been edited after its removal began, and a new id removes it only when no unfinished removal of the link remains, since a stale retirement receipt answers link_changed to every revoke until the record matches its copy again (#8678). A journalled revoke refused before it ran (operation_mismatch, link_operation_open) says nothing was changed, as a revoke's guidance for operation_journal_full does; an add's says only that the apply changed nothing, and its --require-ephemeral refusals only that the apply changed no key, link or Child file, since confirm-host already wrote the known_hosts pin (and a refused census still records its entry). link_operation_in_progress and a forced revoke's tombstone_limit say only that the attempt did not touch the link, its key or the Child: a resumed revoke may re-own its retirement receipt first (#8416). A fresh tombstone_limit records the refusal, while a resumed one (even one recorded failed_clean) can finish under its own id once capacity is freed and a new id is refused with link_retirement_owned, and a repeat can meet the live limit again, so its text says to free capacity and repeat the same id, to try a new id only if the refusal comes back although capacity is free, and, if the new one answers link_retirement_owned, that an unfinished removal holds the link: repeat the revoke that owns it or finish with ocx link revoke --link-id <link id> --force --yes without an operation id (tests/server/link-revoke-operation.test.ts). tombstone_write_failed makes no claim (the file may already be written). #8416's link_retirement_owned and link_retirement_mode_mismatch get closed texts that name the way out (the revoke of the link that owns it, in whatever recorded state, found by its linkId in ocx link operations --json, or --force --yes without an operation id; a plain revoke without --force only when no journalled operation owns the removal; for a mode mismatch the revoke that started it with its options, or the same plain forced revoke) and claim nothing about what an earlier attempt changed; any other code makes no such claim. Neither echoes the server's message. Settled entries (committed, failed_clean, resolved) past OPERATION_JOURNAL_MAX_SETTLED (256) are pruned before their 30 days, oldest first by when they finished or were resolved, but never before OPERATION_PRUNE_FLOOR_MS (the 2-hour OPERATION_REPLAY_MIN_MS plus the 120 s alias-lock wait and a minute). The guarantee a caller can rely on: a repeat with the same operation id and the same parameters (another set answers operation_mismatch) that reaches the journal within 2 hours of the answer gets the recorded outcome (its status, code and outcome; other fields of the original body are not kept), except that a revoke whose own removal was interrupted (its retirement receipt is still outstanding, #8416) resumes that removal first and answers that attempt, even after waiting on the alias lock. A transient refusal (link_operation_in_progress after the lock wait) changes nothing and is retried. After operation_journal_unreadable or a 5xx the caller repeats with the same id; neither proves that nothing changed, because a replay that follows a resumed removal can meet them after that removal finished, and the CLI says that a later link_not_found then means the link is already gone. Later the id may be treated as a new operation: an enrollment whose link still exists meets link_exists and a revoke whose link is gone link_not_found, but an enrollment whose link was removed meanwhile enrolls the machine again if it still answers with the pinned host key (tests/server/link-enrollment-operation.test.ts), so a caller must not repeat an id after the window expecting its old answer. Unsettled entries are never pruned and the 30-day retention is unchanged, so the window matters only after more than 256 settled entries. A new operation is refused for that reason only in a burst of OPERATION_JOURNAL_MAX_RECENT_SETTLED (1024) operations settled within that same floor (2 hours and three minutes), with a message to retry once the oldest is older than that, so a runaway loop that retries with a fresh id after each failed_clean is held for at most that window instead of locking the Home out for 30 days. This is also the journal's throughput limit: a burst of 1024 and about 500 journalled operations per hour sustained (1024 / 2.05 h). It depends on churn, not machine count: ten disposable machines that each live ten minutes make about 120 operations per hour, about 246 inside the window, a quarter of the bound. The file holds about 512 entries (about 0.5 MiB), at most about 1.5 MiB in such a burst, plus the entries an outstanding retirement receipt pins against pruning (at most two per readable receipt, every revoke entry of a link whose receipt cannot be read, and receipts have no cap of their own), and every whole-file rewrite under the config mutation lock stays small while receipts are settled. An unsettled entry is never pruned. ocx link operations [--operation-id <id>] and ocx link operations resolve --operation-id <id> --yes drive them (src/cli/link-operations.ts). ocx capabilities --contracts lists link.operation/1 for all of this: ocx link add|revoke --operation-id, --require-ephemeral, the journal and its routes. A damaged journal answers 500 operation_journal_unreadable naming the way out (keep a copy, move the file aside); a plain --force revoke still runs, without the open-entry check. A committed enrollment whose plant completed answers planted: true, live and on replay. With --operation-id, the server refuses an apply without fingerprint and ocx link revoke exits 4 on link_not_found (nothing was journalled) instead of reporting success. With --operation-id, ocx link add needs --expect-fingerprint and first reads the operation; a recorded one replays through apply without a new probe, which a host that is gone could not answer. Coverage: tests/clients/link-operations-journal.test.ts, tests/clients/link-alias-lock.test.ts, tests/clients/link-full-install-census.test.ts, tests/server/link-enrollment-operation.test.ts (live outcomes, replay, mismatch, the crash table at every boundary, retention, the lock), tests/server/link-revoke-operation.test.ts, tests/server/link-operations-routes.test.ts, tests/cli/cli-link-operation.test.ts.
What a journalled revoke's refusal may leave changed, per code, on a first and on a resumed attempt (#8416), and what the CLI prints for it (journalledRevokeError in src/cli/link.ts). A text may claim a state only when it holds in every row for its code; otherwise it names the next step only. Only a non-forced cleanup touches the Child.
| Code | Where it arises; what may already have changed | The CLI prints |
|---|---|---|
operation_mismatch |
Replay with another digest; this request changed nothing | Nothing was changed; the first attempt's flags, or a new id |
link_operation_open |
openRevokeConflict, only for an id with no entry; nothing |
Nothing was changed; repeat the unfinished revoke first |
link_operation_in_progress |
Alias-lock wait or Child-job reservation, fresh or resumed; at most the receipt was re-owned | This attempt did not touch the link, its key or the Child; retry |
operation_journal_unreadable |
First read, openRevokeConflict or foreignRetirement: nothing; a replay after a resumed removal answered 200: the removal finished |
No state claim; keep a copy, move it aside, repeat with the same id; a later link_not_found means the link is gone |
operation_journal_full |
openRevokeJournal, only for an id with no entry; nothing |
The bound's guidance; nothing was changed |
operation_journal_failed |
Opening the entry: nothing. The first intent (before stopLink): this attempt changed only its journal entry (a fresh one opened it; a resumed one may also have taken back its receipt, reownLinkRetirement) and a forced revoke's own tombstone, which it then drops at best effort (a busy config lock leaves it beside the live link, where recover refuses it as link_present); for a resumed revoke an earlier attempt may already have stopped the tunnel or revoked the key. A later intent comes after stopLink: the tunnel is down, and for the key_revoked intent or the purge's remote_cleanup completion the Child cleanup has run; at the record_removed intent the key is revoked too and the receipt stays claimed with the record still listed. Before the receipt is cleared (beforeReceiptCleared): the removal finished. A resumed owner can meet the same live failure again and keep its receipt |
Generic; fix the journal's disk, repeat with the same id; a new id only through link_retirement_owned's guidance |
tombstone_limit |
Before the first intent, fresh: settled failed_clean, nothing else. At the commit step (#8662: the tombstone this attempt kept was dropped by its owner and the cap refuses a new one): after key_revoked's intent and stopLink, before the key revoke, so this attempt stopped the tunnel and revoked no key, removed no record and never contacts the Child, its retirement receipt stays claimed (a repeat of the revoke resumes it), and a fresh one settles unknown. Resumed: an earlier attempt may have revoked the key, stopped the tunnel and dropped its tombstone; this attempt meets the limit before its first intent and its stopLink, so it wrote no tombstone or intent and stopped nothing, but it may have taken back a receipt a plain --force disowned (reownLinkRetirement), and an entry already open settles unknown with this code (settleResumedRevoke) |
This attempt revoked no key, removed no record and did not contact the Child; recover; repeat the same id; a new id if it persists while capacity is free; on link_retirement_owned, the revoke of the link that owns it in whatever recorded state, or a plain --force --yes |
tombstone_write_failed |
Before the first intent, or (#8662) at the commit step after key_revoked's intent and stopLink and before the key revoke, when the tombstone this attempt kept was dropped by its owner and a new one cannot be written or read. Either way the write's outcome is uncertain: recordTombstone can throw after its atomic write, so the file may remain; this attempt then owns it and drops it at best effort (a busy config lock leaves it beside the live link). This attempt revoked no key and removed no record; at the commit step it stopped the tunnel, its retirement receipt stays claimed (a repeat of the revoke resumes it), and a fresh one settles unknown |
Generic, no claim |
link_retirement_owned |
foreignRetirement, before the entry is opened |
No state claim; the revoke of the link that owns it, in whatever recorded state (ocx link operations --json shows linkId), or a plain --force --yes; a plain revoke without --force only when no journalled operation owns it |
link_retirement_mode_mismatch |
Unreachable with --operation-id at this head |
No state claim; the revoke that started it with its options, or a plain --force --yes |
link_changed |
readRemovalTarget meets an outstanding receipt whose copy differs from the stored record (it was edited after the receipt was claimed, for example by ocx link routes, which does not check for a receipt: #8678): nothing this time, and every revoke (a new id, this id's resume, a plain --force) answers it until the record matches the copy again. The claim (the record changed in another process before any receipt was claimed): a cleanup may have run, and the answer is journalled with no receipt for this id, so this id replays it while a new id reads the record afresh. The commit (the record or the receipt changed after this id claimed its receipt): a cleanup may have run, and while the receipt is still this id's its repeat resumes the removal before any replay (it may run the cleanup again) and meets the same comparison, answering link_changed again while the record differs from the receipt's copy (the stale receipt above) |
Generic; the link or its removal record no longer matches what the removal started from; read ocx link status, repeat with the same id; if that answers link_changed again, the link may have been edited after its removal began, and a new id removes it only when no unfinished removal of the link remains |
link_not_found |
readRemovalTarget, before the entry |
No revoke was journalled; exit 4 |
remote_disconnect_failed with hint child_link_mismatch, child_residual or expect_link_unsupported |
#8662: the Child refused a link-bound command in a way the Home recognises as changing nothing (classifyBoundRefusal), or the Home refused the unbound retry for a Child older than --expect-link (whose usage error came after an older full ocx's root preflight). Fresh: settled failed_clean, tunnel restarted. Resumed: an earlier attempt may have run the cleanup, so the tunnel stays down and it settles unknown |
For the first two, this attempt left the Child's link state as it was; for expect_link_unsupported, that it was not disconnected without the link check. Then the hint's remedy: a disconnect or detach is named only after the check that allows it (the machine shows this link's id or no connection), never for child_residual; every other branch says what not to do and to revoke with --force (after any enrollment of this link has finished, where the Child shows no connection). The next step is a new operation id after failed_clean, which this id only replays, and the same id after unknown |
remote_disconnect_failed with hint child_disconnect_pending |
An unfinished disconnect the Child could not tie to this link: it may be partly cleaned, so the cleanup counts as attempted; settles unknown with the tunnel down |
No state claim: ocx connect status cannot say whose disconnect it is, so finish it there only when the Child shows no connection; any link, a hub or another Home, an unreadable state or an unreachable machine: leave it and revoke with --force; the unknown outcome's conditional repeat guidance |
| Any other code | Disconnect and purge failures, key_revoke_failed, link_remove_failed, alias-lock failures, operation_interrupted, revoke_failed |
Generic, with this id's closed outcome; for unknown, repeat the same id first (it resumes a removal only if this id still owns one); if it does not finish, read its state: unknown or residual, check the Child and resolve; open, fix what stopped it and repeat the same id |
The Child relay requires a connected() result with kind: "match" from src/client/link-tunnel.ts before every fetch. The gate returns one typed LinkAdmissionResult containing the verdict and bounded timing/generation/check metadata; diagnostic callbacks do not supply admission or recovery decisions. The supervisor has one typed ownership dependency, inspectLoopbackListener. Before the first keyed readiness probe and after a tunnel restart, the supervisor asynchronously proves the exact 127.0.0.1:<port> LISTEN socket belongs to its SSH child; an adopted process also needs matching pidfile argv and start time. src/server/port-reclaim.ts uses Linux /proc/net/tcp{,6} plus the expected PID's /proc/<pid>/fd socket symlinks without external tools, macOS lsof and Windows netstat with bounded asynchronous execution. Every key-bearing probe and relayed request makes a fresh bounded asynchronous owner lookup; an adopted process also has its current argv and start time rechecked on every admission. An unreadable or timed-out identity denies only that admission and is retried; a confirmed mismatch releases the adopted PID without signalling it and lets the next tick start a fresh tunnel. An unknown socket-owner lookup likewise denies the current admission. A missing supervisor, or a failed or stopped tunnel, returns a retryable 503 without sending the link key or body to the persisted loopback port; its body carries code: "home_unreachable" under the fallback policy. A Home-initiated Child is the exception: the Home owns that link's ssh -R forward, so the Child has no sidecar, no supervisor and no SSH process whose socket it could prove. A join writes <configDir>/link/child-initiated.json (the link id, mode 0600) beside the sidecar and removes it with the sidecar, so a Child-initiated link that lost its sidecar is recognised (isChildInitiatedLink in src/client/link-state.ts; an unreadable marker counts as present) and gets no gate. Only a link-mode runtime with neither the sidecar nor a marker for its link id is treated as Home-initiated: src/client/runtime.ts passes it HOME_INITIATED_LINK_TUNNEL (src/client/link-relay.ts), which omits local process ownership proof and reconnect holding but still uses the connection-bound relay authentication below. A refused connection answers 503. A held request rechecks the verdict after its wait and before each retry.
The listener can change between an ownership check and the TCP connect. The data relay now authenticates that physical connection before sending the key or body (see Connection-bound relay authentication); this does not change the separate readiness/enrollment probes. A private Unix-socket forward could additionally remove competing TCP listeners from those probe paths.
A client connected with transport: "link" reaches its hub through an SSH tunnel instead of a public origin. Its serverUrl and managementUrl are both http://127.0.0.1:<link.tunnelPort>, and ocx connect --link --key-stdin accepts the data key on bounded standard input instead of issuing one over HTTP. The key is stored only in the service token file and is sent on readiness, catalog, hub-state and usage reads.
Codex keeps the standalone loopback routing: routingTarget in src/client/connect.ts returns the standaloneCodexRoutingTarget form for the configured port, root openai_base_url = "http://127.0.0.1:<port>/v1" plus the realtime override, with no provider table and no env_key. For the same port the join therefore writes the bytes the standalone injection wrote, and a Codex app launched without the shim's environment keeps working; an earlier env_key table is rebuilt into this form on the next sync.
src/client/link-ingress.ts is the link-mode data plane of the machine listener. A /v1/responses WebSocket upgrade answers 426 upgrade_required, which codex-rs maps to its HTTP fallback, unless the link WebSocket wants it (the Child's release gate is open, the Child has not opted out, and the Home's last applied answer did not say off, or none has been applied since the Child started; below), and nothing reaches the tunnel port for an upgrade it does not want. Every relayed route first passes the standalone loopback Host and Origin gate (isAllowedRequestOrigin in src/server/auth-cors.ts), so a rebinding or cross-site page gets 403 origin_rejected and nothing is fetched upstream. /readyz is answered locally and never relayed; in link mode it reports the link through src/client/link-readiness.ts (below). The link key is read from the service token file once, when the listener starts, and held in memory; the file must hold the key whose fingerprint the connection committed. While it does not, relayed routes answer 503 link_credential_unavailable without an upstream fetch, and the file is read again at most once a second; a valid key is never re-read. The key is never logged or returned.
src/client/link-relay.ts forwards exactly the linkRouteAllowed routes from src/link/routes.ts through the tunnel. It drops the caller's Authorization, Azure api-key, Anthropic-compatible x-api-key, Google x-goog-api-key, x-opencodex-api-key, chatgpt-account-id and cookie and sends the link key as Authorization: Bearer, the wire an env_key config sent; GET /v1/usage takes it as x-opencodex-api-key, the only header that route admits. The explicit provider forms matter because the Child and Home are separate credential owners: caller provider credentials never cross the tunnel merely because they are not Bearer tokens. The Home admits the link key and serves the Child with its own accounts. The request body is streamed chunk by chunk with the caller's Content-Length and a byte-counting cap at the inbound limit (resolveInboundBodyLimitBytes, 256 MiB by default); a larger declared or streamed body answers 413. A lone Transfer-Encoding: chunked without Content-Length is admitted as a standalone admits it, because the listener has already de-chunked the body; any other Transfer-Encoding, or one next to a Content-Length, answers 400. The Home's response headers may take up to 300 seconds, and a caller abort ends the wait sooner. SSE passes through chunk by chunk with caller-abort propagation and a 300-second idle limit, other response bodies stream under the same byte cap, and the relay answers 503 with Retry-After while the tunnel is down. The client supervisor is the relay's tunnel gate (LinkTunnelGate): only while the tunnel is connecting or reconnecting (including the start of the client runtime) does a relayed request wait, for at most 15 seconds (LINK_RELAY_HOLD_MS) from its first wait and with at most 64 requests waiting, before it is forwarded once; a connected tunnel costs one pending() call per request, and a failed one answers 503 at once. A forward whose connection was refused sent nothing, so while the tunnel reconnects it may wait again and be sent again inside the same 15 seconds, provided the streamed body was never read or cancelled; any other failure (a reset, a timeout, a failure after the body started) is never replayed. Both the Child's machine listener and the Home's hub-link listener bind with idleTimeout: 255, the public listener's limit, so a held or slow turn is not cut by Bun's 10-second default. Like a standalone data route, a relayed request then lifts its own idle timer (server.timeout(req, 0) in src/client/link-ingress.ts), so a quiet stretch longer than 255 seconds inside a long generation is not cut either; the relay's header deadline, SSE idle limit and caller abort bound the wait instead. Hub transport keeps the 4 MiB management-relay listener bound and its default idle limit. Link mode waits for the configured port without signalling its holder, then binds there or fails; src/client/runtime.ts passes the cached link key, tunnel status and tunnel gate through bindClientListener to every bind attempt. Link mode replaces the hub management relay with the read-only link management relay (see Child management relay and Home Codex settings) and refuses key rotation and revocation, which belong to the Home.
src/client/link-readiness.ts gives a link-mode Child's GET /readyz its verdict; /healthz keeps meaning only that the process answers. While relayed requests cannot be served, or the saved client owner or link differs from the runtime snapshot, /readyz answers 503 with Retry-After: 1, status: "pending" and link: { state: "unavailable", reason }, which the strict readiness contract in src/server/proxy-liveness.ts reads as ours and not ready; otherwise 200, status: "ready" and link: { state: "available" }. A reason is one fixed word. A Child-initiated link reads its supervisor: connected is ready unless the keyed probe last saw home_unreachable or unauthorized (home_not_ready stays ready, because relayed requests do not depend on the Home's own startup readiness); connecting, reconnecting and a failed state with a retry in flight read tunnel_connecting, failed tunnel_failed, idle or stopped tunnel_idle, an unreadable sidecar sidecar_invalid. A Home-initiated link has no local tunnel to inspect, so it runs probeLinkRelayPreflight from src/client/link-relay-transport.ts: the credential-free challenge every relayed request starts with, on its own connection, without a link key, data request or body, and without reading the response body. It sends x-opencodex-link-probe: 1 (LINK_RELAY_PROBE_HEADER), so the Home verifies and answers without reserving a session (see the reservation book below). A verified proof is ready, no answer within the five-second authentication deadline is home_unreachable, any other answer link_auth_failed, and a missing link key link_credential_unavailable with nothing sent. One preflight runs at a time, its verdict is reused for five seconds, and /readyz waits at most 500 ms for a running one before it answers link_unverified, so an unauthenticated local caller causes at most one preflight every five seconds. A link with neither a supervisor nor a Home-initiated gate reads tunnel_unavailable. Restart handoff and the update job judge a replacement by /healthz, so a Child whose Home is down still restarts. ocx connect status reads that verdict back through src/client/link-tunnel-status.ts (#8470): it asks the identity-checked live client listener's /readyz (port must match, and pid too when the liveness probe knew it), accepts a reason only from LINK_READINESS_REASONS, re-reads once when the answer is link_unverified, timed 5.25 s after the first read began so it lands after the preflight's five-second bound and inside its five-second cached verdict (a re-read timed from the first answer could miss the cache and start a second preflight), and reports linkTunnel as up, down or unknown; it never probes the tunnel itself and never changes state: "connected". Coverage: tests/clients/link-tunnel-status.test.ts.
Decision record: ADR-6032
Regression coverage lives in tests/clients/link-ssh-argv.test.ts, tests/clients/link-ssh-config.test.ts, tests/clients/link-tunnel-state.test.ts, tests/clients/link-tunnel-history.test.ts, tests/clients/link-readiness.test.ts, tests/clients/link-store.test.ts, tests/clients/link-boundary.test.ts, tests/clients/link-routes.test.ts, tests/clients/client-link-connect.test.ts, tests/clients/client-link-relay.test.ts, tests/clients/client-machine-listener.test.ts, tests/clients/client-link-status.test.ts, tests/clients/client-link-runtime.test.ts, tests/codex-integration/injection-link-websocket.test.ts, tests/clients/link-supervisor.test.ts, tests/clients/link-status-projection.test.ts, tests/clients/link-admission-wait.test.ts, tests/clients/link-fingerprint.test.ts, tests/cli/cli-link.test.ts, tests/server/link-management-routes.test.ts, tests/server/link-join-route.test.ts, tests/server/port-reclaim.test.ts, tests/server/link-listener-lifecycle.test.ts, tests/clients/client-link-teardown.test.ts and gui/tests/remote-link.test.tsx.
Enrollment cancellation in src/client/connect.ts reaches actual network requests and every subsequent write boundary. src/client/link-join.ts awaits that transaction's commit or completed rollback instead of racing a separate failure against its terminal result. An exit observed before commit aborts enrollment, drains local rollback, and only then revokes the issued key; an exit queued after the synchronous commit retains the committed link for restart. Readiness polling also observes tunnel exit while sleeping, rather than waiting out its deadline. tests/server/link-join-route.test.ts and tests/clients/client-link-connect.test.ts pin those boundaries.
On Windows, a successful append refreshes the existing ACL memo only after matching the path to the open file's identity and closing the handle. Repeated writes avoid redundant ACL subprocesses; replacing the file or changing its permissions still requires fresh hardening. Exits from a bound client restart drain the same bounded diagnostic queue as signal shutdown and standalone recycle.
The diagnostics.ts module in src/link/ owns best-effort local evidence in <configDir>/link/diagnostics.jsonl and its single rotated backup, diagnostics.jsonl.1, each capped at 256 KiB. The link directory is private (0700 on POSIX), files are private (0600), and Windows uses the secret-path ACL helpers. Diagnostics accept only fixed event/outcome/state values, generated UUID request/check IDs, numeric generation, elapsed time and attempt metadata, and a timestamp; they exclude secrets, request bodies, headers, URLs, account identifiers and raw errors. Each event/outcome pair emits at most once per second; the next emitted record carries the intervening suppressed count. A backward wall-clock adjustment starts a new rate-limit window, including when the writer queue is full, and preserves the suppression count. An asynchronous writer keeps at most 56 pending records, counts overflow as suppression, and yields while Windows ACL work runs. Write failures are also rate-limited and never change admission. Graceful signal shutdown and standalone recycling wait up to one second for queued diagnostics before exiting; stalled storage, forced termination and power loss can still lose records. Relay-generated 503 responses carry a server-generated x-opencodex-link-request-id for correlation; incoming caller IDs are not trusted as this identifier.
owner_lookup records only refused ownership checks; routine successful checks are silent. owner_lookup.elapsedMs measures the owner lookup itself. For auth_timeout, relay_retry and relay_failure report the actual transport-attempt duration in elapsedMs (normally about five seconds). A relay_recovered event with outcome recovered is emitted only after a retry receives an upstream response with upstream.ok (HTTP 2xx); it does not prove the streamed body completed. Rate limiting makes request-ID correlation sampled evidence, not a guaranteed trace of every failed request.
The Home writes tunnel_transition records into its own <configDir>/link/diagnostics.jsonl through the same sink, wired in src/server/index/optional-listeners.ts: previousState, state, the cause as outcome (the history's causes, or connected, spawned and stopped when nothing failed), attempt, the link's tunnelPort and, when a retry is scheduled, retryAfterMs. The supervisor's stop() drains the sink for at most one second. The one-per-second limit per event/outcome applies across links. The Child records its own -L tunnel the same way (#7297): src/client/link-tunnel.ts feeds every state change through a createTunnelHistoryBook keyed by one fixed key, with spawn_failed for a spawn that cannot start and the classified ssh stderr cause for an exit; the port is remembered past stop. Records carry the same fixed fields only, never argv, stderr, alias or link id, and reach the sink in a microtask after the transition, so a sink that re-enters the supervisor cannot interrupt one (tests/clients/client-link-tunnel.test.ts).
src/link/diagnostics-read.ts is the only reader of these files, and nothing in the proxy acts on what it reads. It opens diagnostics.jsonl.1 and then diagnostics.jsonl read-only and non-blocking, refuses a symlink (O_NOFOLLOW plus an lstat identity check against the opened handle), a FIFO or other non-regular file, and a symlinked link directory, and reads at most the newest 256 KiB of each, dropping the cut first line. It never creates, hardens, truncates or chmods anything. Every line goes back through the writer's allowlist, linkDiagnosticMetadata in diagnostics.ts, plus the sink's timestamp (a non-negative safe integer) and suppressed (a positive one); a line longer than 4 KiB, malformed JSON, a non-object or a record outside the allowlist is counted in skipped and never echoed, so a tampered file cannot put free text on a terminal or the dashboard. Records come back oldest file first in line order, filtered by an optional since timestamp, and limited to the newest 1–1000 (default 200). Three surfaces read it: ocx link diagnostics [--limit <n>] [--since <iso-time|epoch-ms>] [--json] (src/cli/link-diagnostics.ts), which reads the local file directly, needs no running proxy, is exempt from the Codex-shim repair preflight and so from the config-root hardening wait (src/cli/preflight-exemptions.ts), prints the file path, and says no diagnostics are recorded yet when both files are absent; GET /api/link/diagnostics (src/server/management/link-diagnostics-route.ts, dispatched from link-routes.ts with the same authority as GET /api/link/status but before the link lifecycle is resolved, so it answers without a supervisor), which returns the same ocx.link.diagnostics/1 document without the path and rejects a limit outside 1–1000 or a malformed since with 400 invalid_query; and the connected Child's machine listener, which serves the same read to its dashboard session only and never relays it to the Home. The route is not in the link management-read allowlist, so a Child cannot read a Home's file. The dashboard's Remote Link page shows it in a collapsed Diagnostics section that loads only when opened, and gui/src/remote-link-diagnostics-api.ts checks each record against the same closed vocabulary again before rendering. Coverage: tests/clients/link-diagnostics-read.test.ts, tests/cli/cli-link-diagnostics.test.ts, tests/server/link-diagnostics-route.test.ts and tests/clients/client-machine-listener.test.ts.
src/client/link-relay.ts shares one preflight recovery attempt per request between an owner-lookup deadline and the typed LinkRelayAuthTimeoutError from src/client/link-relay-transport.ts. Only expiry of the credential-free authentication deadline before data dispatch produces that type; arbitrary aborts, authentication failures and transport errors do not qualify. Recovery obtains fresh owner proof where required and opens a fresh physical socket, authenticating it before sending any link credential or body. It never caches owner approval, bypasses security, restarts SSH or OpenCodex, or replays a dispatched body. The owner-lookup and authentication deadlines remain two and five seconds respectively, per attempt. Existing reconnect holding remains separate.
This recovers those transient preflight deadline paths and records bounded evidence for future failures. It does not establish the historical cause of #7297; timing clusters and synthetic reproductions alone do not classify past production failures.
The production relay's complete-send boundary is src/client/link-relay-transport.ts. A dedicated HTTP agent opens exactly one TCP socket. Before any admission header or body is sent, a credential-free, five-second challenge proves the stored link/key identity using direction-separated HMACs from src/link/relay-auth.ts. The data request uses that same authenticated socket, with a one-use session nonce; a closed socket cannot be silently replaced. A peer lacking this protocol is refused with an upgrade/re-link error, never retried through an unauthenticated send. That refusal is a 503 with code: "link_relay_auth_failed" (src/client/link-relay.ts), never home_unreachable: the peer answered, so it is not a Home outage and must not open device-local fallback (fallback policy). Home-initiated reverse tunnels use the same check. LinkRelayDeps.fetchImpl is a trusted complete-send test seam, not a user setting; production supplies no substitute. Bun's Node HTTP compatibility is confined to this leaf so streamed request caps, backpressure, credential replacement, decompression, response status, caller cancellation and SSE limits remain owned by the existing relay.
A Home advertises LINK_RELAY_AUTH_VERSION as relay-auth/<n>, and the management relay as link-management-relay, in its /readyz features (Runtime, src/remote/features.ts), so a peer can detect a relay-auth mismatch without comparing release versions.
The relay copies the Home's response headers except content-encoding, content-length and hop-by-hop headers, so the Home's x-opencodex-served-by on GET /v1/models reaches the Child's caller unchanged and the Child never stamps its own; tests/server/served-by-header.test.ts relays through the Home's real link listener and through a fake Home naming another id (Runtime).
The Home learns each Child's version only from what the link already carries; it opens no SSH session for it. src/link/peer-report.ts owns the report. fetchBoundLinkRelay adds x-opencodex-link-peer-version (the package version) and x-opencodex-link-peer-features (the Child build's buildPeerFeatures()) to the credential-free relay-auth challenge. A Home older than the report ignores both headers, and a Child older than 2.91.0 sends neither. src/server/index/link-relay-sessions.ts records the report into the listener lifecycle's in-memory LinkPeerObservations only after the caller proof verifies, so an observation is bound to the link key; it is the Child's own claim, used for display and never for admission. A challenge for a link and key pair this Home issued that names another relay-auth version cannot be verified and is refused as before, but it is recorded as a refusal so status can name the mismatch. Observations are bounded to 256 links and start empty after a Home restart.
src/link/compatibility.ts publishes LINK_PROTOCOL_RELEASES, the first release of each link protocol part (relay-auth/2 2.69.0, link-management-relay 2.84.0, link-peer-report/1 2.91.0), and linkPeerStatus turns an observation into compatible, degraded (the Child reports a feature the Home does not serve), incompatible (a refused relay-auth version newer than the last verified challenge) or unknown (nothing observed, or a reporting Child whose version or feature list was missing or rejected). Only a Child that sent neither report header gets versionRange from its relay-auth release up to the report's release. GET /api/link/status?compatibility=1 adds local and, on a Home, a peer block on every link (attachLinkPeers in src/link/status-projection.ts); without the query the document stays the exact K16 shape an older ocx link status validates key by key. The CLI sends the query and accepts either shape. Home /readyz advertises the report as link-peer-report/<n>. Coverage: tests/clients/link-peer-compatibility.test.ts (verdicts, header parsing, a real Home lifecycle against a current and a pre-report Child challenge, the CLI round trip) and tests/server/link-management-routes.test.ts (the opt-in query).
GET /api/link/status?children=1 adds a child block to every row on a Home only (attachLinkChildOps in src/link/status-projection.ts, wired by attachLinkChildOpsForState); without the query the document is unchanged, and ocx link status sends it and reads the block tolerantly (src/cli/link-status-child.ts: unknown keys dropped, an unknown enum value read as unknown, a known key of the wrong type rejected). Its sources: runningVersion from the peer report; installedVersion, installKind and update from the last observe in child-observations.json (null until one ran, so the default entry a carried-only write creates never reads as "no route"); versionSkew from installed or running against the Home version (unknown for an ocx-child); activity from src/link/child-activity.ts, a per-link book the hub-link lifecycle owns, counting every relayed POST linkRouteAllowed admits except token counting, from session validation until its response body ends, aborts or fails (a legacy key admission counts against every link, and only once its response status is known and below 400, so an unauthenticated or refused request never counts; missing activity reads as not idle; idle is no turn for 10 minutes); catalog from src/link/child-catalog-freshness.ts, where the relay hashes a 200 GET /v1/catalog body the Child read to the end and reports {linkId, keyId, etag} after the stream closed (cancel, abort, HEAD and a query report nothing), the pull recorder keeps it in memory and writes catalogPulledAt/catalogPulledEtag at most once per link per 30 s on a microtask (a ?children=1 read also flushes pulls held inside that window, best effort), and the revision monitor re-hashes the Home catalog only when its size or mtime changed and saves homeCatalog only when the etag moved (a held lock keeps it in memory and the next read retries). The verdict compares etags (catalogPulledEtag or wp3's catalogSyncedEtag against the current one), never times, so a restored older file reads behind; a pull proves a fetch only, and the enrollment check's own fetch counts. patchChildObservation keeps an existing entry's observedAt for a carried-only patch. codex is null until the Child reports its Codex state. Coverage: tests/clients/link-child-activity.test.ts, tests/clients/link-child-catalog-freshness.test.ts, tests/clients/link-child-observations.test.ts, tests/server/link-child-ops-routes.test.ts, tests/cli/cli-link-check.test.ts.
src/server/index/link-relay-sessions.ts owns a per-listener reservation book. A valid caller proof reserves its peer socket for five seconds (at most 256 outstanding reservations), except a probe-only challenge marked x-opencodex-link-probe: 1: the Child's readiness preflight closes after the proof, so the Home answers it with the same listener proof and creates no session and no drain lease, and its nonce redeems nothing. An older Home ignores the marker and reserves as before. Otherwise the data request must consume that nonce on the same peer address/port while its link and credential remain valid. Current and unexpired pending-rotation fingerprints use the same expiry rule as ordinary API-key admission, and the server signs with the fingerprint that matched. The nonce is not an alternative data-plane credential: dispatch still applies its ordinary key and route policy. Removal, rotation expiry, abort and commit are observed anew at dispatch. Replays and a different socket are refused.
Last-link close() refuses new challenges/work, retains both the bound port and hub-link ingress identity through pending reservations and active response bodies, then closes. An abandoned proof expires; EOF, error and cancellation release an active response. Process stop() remains forceful: it retires reservations and closes every socket, which the client cannot reconnect. The regression file tests/clients/link-relay-bound-transport.test.ts exercises the actual sockets, shutdown, same-connection proof, refusal to reconnect, bounded reservations, current/pending key lookup and response decoding. This is port-replacement protection, not a claim of encryption against an active transparent local intermediary. SSH continues to own remote-host authentication/encryption; enrollment and supervisor probes are separate boundaries.
The explicit ocx link revoke --force --yes branch in src/cli/link.ts sends only the server's force boolean and rejects redirects. It labels remote cleanup from invocation intent: skipped after an observed retirement receipt, unverified when the link is already absent, with a remote ocx disconnect recovery hint. Ordinary revoke retains its existing request and idempotency behavior. The CLI does not infer a remote attempt, confirm remote disconnect, or change enrollment/trust authority.
GET /api/link/status?traffic=1 adds traffic to every link row (#3023); without the query the document is unchanged, so an older ocx link status that validates rows key by key keeps working. ocx link status sends the query, validates the block exactly (src/cli/link-status-traffic.ts) and accepts its absence from an older runtime. src/link/traffic.ts owns the book: per link since, epoch, generation, requests, incomplete (ended before the response body did, or on a Child before its upload finished), toHomeBytes, fromHomeBytes, toHomeSource and toHomeUncounted, plus the book-level untrackedRequests repeated on every row. Counting is a plain integer addition per body chunk on the JavaScript thread, with no lock, log line, timer or disk write, and no request scans the book. A book holds at most 256 links and never more: a new link takes the place of the link idle longest (the first key of an insertion-ordered map of links with no request in flight, so making room is O(1)); when every tracked link has a request in flight there is no place, and that request is not measured and counts only in untrackedRequests. Telemetry never refuses or slows a request. Every time a link's counters start from zero, at its first request or its first after the book forgot it, it takes a new generation from one monotonic counter per book; a link the book does not hold reads zeros under the generation of the last forgetting. The book lives in memory; a restart starts a new one with a fresh random epoch, so generations from two process lifetimes never compare equal under it. A consumer subtracts two reads only when both epoch and generation match; since is the wall-clock time of that start, for people, and can repeat or go backward with the clock. A Child counts in its connection-bound transport (fetchBoundLinkRelay in src/client/link-relay-transport.ts) into the process book childLinkTraffic, so relayed requests and the Child's own reads of the Home (linkBoundFetch and fetchLinkHomeManagement in src/client/link-home.ts) count alike: a request from the moment its data request starts on the authenticated socket, each request-body chunk or buffered body once its socket write completed, and each response-body chunk as it is read off the socket's stream, before any adapter queue or gzip, deflate or Brotli decoding, so bytes read before a cancel still count. A refused challenge, an expired preflight or a refused connection sent no data and counts nothing (toHomeSource: "relayed"). The transport ends a request only once both its response and its upload settled, and counts it complete only when both finished, so a Home that answers in full before reading the whole body still leaves it incomplete. A write still pending when a request ends is not counted, even if part of it reached the Home, so while incomplete > 0 toHomeBytes is a lower bound of the request bytes that crossed, and with incomplete at 0 every request that has ended had its body counted in full. A write still in progress counts only once it completes, so a read taken mid-write lags (it can show incomplete 0 while the Home already holds a prefix of that body), and a delta between two reads measures completed writes. A write completes once the operating system accepts it, and how much it accepts at once is the OS's choice: Linux and macOS accept what fits their send buffer, while Windows can take a whole large nonblocking send into its buffer (it took 48 MiB bodies at once on CI), so a body can count in full, and its upload as finished, as soon as it is written, even when the Home reads only part of it. Response bytes are counted as they arrive and need no such caveat. The Home's hub-link listener lifecycle owns its own book (createLinkTrafficBook("content-length") in src/server/index/link-listener.ts), and src/server/index/link-relay-sessions.ts counts only requests on a verified relay session: response-body bytes as they stream (a request aborted before its response headers stays incomplete), and on the request side each request's declared Content-Length, because wrapping Bun's native request would lose the identity requestIP and timeout need; a body without Content-Length adds to toHomeUncounted. A legacy key admission without a relay session is not attributed to a link. Neither side counts HTTP headers, relay-auth challenges, SSH or TCP framing, or bytes a relay between the machines adds.
A connected Child serves status from its machine listener (src/client/machine-listener.ts) to its dashboard session only, and ?traffic=1 there adds the Child's own link as a row (projectClientLinkRow in src/client/link-status.ts) with its counters: a sidecar for this link names the alias, direction and tunnel state; an unreadable sidecar, one for another link, or a missing sidecar while the durable child-initiated.json marker names this link (or cannot be read) makes the row failed (sidecar_invalid, sidecar_mismatch, sidecar_missing) with alias unknown; otherwise no sidecar is a Home-initiated link that reads idle since it connected, under the Home's recorded name (linkHomeName) or unknown, never the loopback URL the Child connects to; ocx link status on a Child reads local files and cannot show them. Coverage: tests/clients/link-traffic.test.ts (real Home sessions and the real bound transport: a streamed request and SSE response, an aborted response, a request without Content-Length, refused requests, a refused challenge and connection, a gzip response, a Child's own Home read, a Home request aborted before its headers, the hard bound with links in flight and untrackedRequests, eviction never touching a link in flight, a new generation under a repeating or backward clock, a new epoch per book, a buffered body whose write never completes, a Home that read a prefix before the cancel, one that read a prefix and then answered in full, and a read taken while a large write is still pending), tests/clients/link-traffic-child-status.test.ts (the Child's machine listener), tests/server/link-traffic-status.test.ts (the plain document's literal bytes, the opt-in document, CLI validation).
The kind of SSH path a link takes (a relay ProxyCommand, a ProxyJump host, or direct) is not reported: it can be learned only by running ssh, whose Match exec can run commands and stall, or by matching OpenSSH's configuration reader exactly, and neither belongs in a status read. The link's alias, already on the row, names the relay route where one is used (Pen aliases are pen-<name>).
The Home counts a link socket in the same per-link traffic book, in the Child's units: one request per socket, begun when the gate opens an admitted socket (a refused or failed upgrade counts nothing), toward the Home the payload bytes of every message that reached it (counted in the gate's message handler before authorization, so a fenced socket's last frames count, as the Child counts every write that completed), from the Home the payload bytes of every message Bun took for the socket, written or queued under backpressure (sendTextFrame in src/server/ws-bridge.ts; a send Bun dropped is not counted), without framing, handshake or control frames, and complete when a close frame ended it from either side (a socket dropped without one, close code 1006 or 1015, is incomplete). Completeness is decided differently on the two sides: the Home counts any close frame as complete, while the Child also counts its own overload (1001 to the Home), binary (1003) and protocol (1002, 1009 and the like) closes as incomplete, so one socket can read complete on the Home and incomplete on the Child. Link sockets exist only while the switch and the release gate are on, so with the gate closed nothing changes; a local socket has no flow and is never counted. An upgrade the gate does not handle (the switch or the release gate off, or a request outside the Codex shape) continues as an ordinary relayed request and is counted like any other, including its route-table 404. Relayed HTTP is measured as #8584 defines it. The WebSocket's own live counters (upgrades, refusals, closes) are the row's ws block under ?transport=1. tests/server/link-websocket-traffic.test.ts covers at the gate one socket's payload bytes each way and its completion on a close frame, a drop (1006) counted incomplete, a send Bun dropped against one it queued, a refused and a failed upgrade counting nothing, and a local socket never counted (it does not drive a fenced socket's frames or a 1015 close), and tests/server/link-websocket-transport-status.test.ts checks through a real Home that a link WebSocket turn counts one request of exactly the create's bytes toward the Home and the payloads the Child read back, with a relayed HTTP request on the same link counted on top.
A Child compresses a request body with zstd before it crosses the link only when its Home accepts it (#3023). The capability is link-request-zstd/1 (src/link/request-compression.ts), listed in PEER_FEATURES (src/remote/features.ts, so GET /readyz names it) and in LINK_PROTOCOL_RELEASES. A Home names its served features on every relay-auth answer in x-opencodex-link-home-features (the homeFeatures option of src/server/index/link-relay-sessions.ts, wired to buildPeerFeatures by src/server/index/link-listener.ts); the header sits beside the proof on the authenticated socket and is not covered by it, which costs nothing: a wrong value can only change whether the next body is coded, and these routes decode a zstd body through the shared reader on every Home that speaks relay-auth/2. The Child's transport records the list per link after each verified answer (src/client/link-home-features.ts, at most 256 links), from the data path and the readiness probe alike.
src/client/link-relay.ts prepares a body only when all of these hold: compression is not disabled in the relay deps, the request is a POST to /v1/responses, /v1/responses/compact, /v1/messages or /v1/chat/completions (the routes whose Home handler decodes Content-Encoding through readBoundedJsonRequestBody), it carries no Content-Encoding, it declares a Content-Length above 1 KiB and at most 16 MiB (LINK_REQUEST_COMPRESSION_MAX_BYTES) and within the body limit, and the Home's last verified answer for this link named the capability. It then reads exactly the declared bytes into one buffer, which is the whole bound on what it holds (a body that runs past its declared length, or ends cleanly before it, is answered 400 and nothing reaches the Home; a streamed body keeps the caller's Content-Length, so there a short body is a short upload the Home sees), and compresses them once with Bun.zstdCompress at level 3 (src/client/link-request-compression.ts): one frame per body, no dictionary and no context kept across requests or links, so the link key, which travels in a header, never shares a compression context with request text. The zstd form is kept only when it is smaller and stays within the Home decoder's ratio bound (compressedBodyDecodeLimit, the policy link-request-zstd/1 stands for), so a Child never sends a coded body its Home would refuse; such a body goes as it is. All bodies being prepared share one allowance (linkRequestPrepareBudget, 128 MiB): each reserves twice its declared length for as long as the relay can still send it: until the Home answered (a refused connection or a timed-out preflight sends the prepared bytes again) and the transport flushed that body to the Home's socket or tore the request down (onBodySettled; a Home may answer before it reads an upload, so response headers alone do not free it). The relay then drops its references with the charge, and the transport keeps no reference to a body once its write completed (it never retains the request init in the listeners that live until the connection closes). A body that does not fit at once streams unchanged instead of waiting, and a request cancelled while its body is being compressed keeps its charge until the compressor returns, then releases it without sending. After this connection's proof, fetchBoundLinkRelay asks selectBody for the body with the features this very answer named: the zstd form with content-encoding: zstd and its own Content-Length when the answer names the capability, the original bytes otherwise. A compression failure, a larger result, or a Home that stopped naming the capability sends the original bytes. Every other body, including the first one a Child sends before it has heard its Home, a chunked upload and one above the bound, streams exactly as before, and an older Home never names the capability, so it never receives a coded body. A buffered body can be sent again after a refused connection, which a streamed one already could while untouched. The SSH tunnel itself is never compressed (no ssh -C), which would mix the link key and outside text in one compression context. A Home names the capability only while linkRequestCompression (top-level config.json, src/types/link.ts) is absent or true: linkHomeFeatures in src/remote/features.ts, wired through the listener start context by src/server/index/optional-listeners.ts and read at start, leaves it out when the key is false, and its Children then send identity bodies exactly as before; the Home still decodes a zstd body from any client, and GET /readyz still lists the build's features. The residual threat is a length oracle: someone who can influence repeated requests and measure their sizes can test guesses at a secret that shares a body with their text, because a correct guess compresses better; on a link that is the relay operator or anyone on the path, since the tunnel hides content but not sizes. It is on by default because the attack needs both chosen text inside the conversation and per-request sizes, while compression roughly halves the bytes toward the Home; an operator whose threat model includes such an observer turns it off. The Child leaves optional features it uses only when its Home names them (LINK_OPTIONAL_CHILD_FEATURES in src/link/compatibility.ts) out of its peer report, and a Home never counts one against itself, so a Home that predates per-body zstd does not report a compatible Child as degraded. Coverage: tests/clients/link-request-zstd.test.ts (real hub-link sessions and the bound transport: a compressed body that decodes to the same bytes and counts on both ends, the bodies that stay as sent, an older Home, a Home that stops naming the capability, a failing compressor, an identity body to a capable Home, three uploads the Home has read while it holds its response headers, a cancel during compression, the Home's linkRequestCompression switch through a real listener lifecycle), tests/clients/link-peer-compatibility.test.ts (the Child's report leaves the optional feature out, and a Home never counts it against itself), tests/usage/request-body-log.test.ts (a Child-compressed body's log row carries its wire size, zstd and its decoded size).
src/server/index/link-websocket.ts admits a Child's Responses WebSocket on the Home's hub-link listener. It is off unless both websockets and linkResponsesWebsocket are true (linkResponsesWebsocketEnabled in src/config/feature-flags.ts). This build also keeps a release gate closed (linkWebsocketTransportReady in src/server/index/link-websocket-limits.ts is false), so the switch admits and advertises nothing whatever the config says: the gate opens only once the measured memory acceptance, durable activity holds and the release tests with the pinned Codex client are in place. While off, a link upgrade takes the route table's existing 404 and the relay-auth 204 is unchanged. While on, the verified 204, data and probe answers alike, adds x-opencodex-link-ws-state: on and x-opencodex-link-ws-proof, an HMAC under the fingerprint over the nonce, both identities and the value (linkWebsocketStateProof in src/link/relay-auth.ts); an answer without the pair means off.
Only a request on a relay session that src/server/index/link-relay-sessions.ts has just consumed and verified on the same connection reaches the gate, and only with the exact Codex shape: GET /v1/responses without a query, Upgrade: websocket, a Connection naming upgrade, version 13, a key, and no Origin, subprotocol or extension. Any other upgrade, and every upgrade without a session, keeps the 404. One synchronous function then checks the abort signal, the switches and the shutdown drain, requires the upgrade's credential to be the key the proof was made with (401 otherwise), runs the authorization check below, and takes in order a 32 MiB transport reservation from a 768 MiB budget, a slot under the per-link (8) and overall (24) caps, and a slot in the shared 128-socket gate, which may reclaim only an idle link socket (tryReserveCodexWebSocket({ reclaim: "link" }) in src/codex/websocket-registry.ts; below). A refused reservation or cap answers 426 link_websocket_capacity. A lease bundle owns the session's drain lease and every reservation: a refusal, abort, throw or failed upgrade releases them once in reverse order, a successful upgrade hands them to the socket, and only its close callback releases them, so last-link close() neither hangs on a half-made socket nor misses one. Every cap, budget and interval is a named value in src/server/index/link-websocket-limits.ts.
The link listener's websocket handler wraps the shared Responses handler with 8 MiB messages, an 8 MiB backpressure limit that never closes the socket, no idle timeout and no compression. On a link socket the shared SSE pump (pumpResponsesSseToWebSocket in src/server/ws-bridge.ts) waits for Bun's drain after any send that leaves more than 1 MiB buffered, before the next event of the same upstream chunk, so the send buffer stays under 1 MiB plus one event of at most 4 MiB; it feeds the SSE framer at most 256 KiB of a chunk at a time; a cancel or close ends the wait, and a socket that does not drain for 300 s is closed with 1011. Local sockets keep the single synchronous loop. While the feature is on, every link turn, a link socket's and a relayed POST /v1/responses alike, is paced (#8601): its turn observer carries pacedReads (linkTurnPacesReads in src/server/link-turn.ts), and the passthrough relay reads the upstream only as the client drains, one 256 KiB slice ahead (src/lib/paced-stream.ts), where every other request lets inspection read 32 MiB past its client. A Child that stops reading therefore holds about one upstream chunk on the Home. Translated adapters, which do not take the passthrough relay, are not paced. A JSON upstream answer turned into Responses events (sendResponsesJsonAsLinkEvents) is paced the same way on a link socket: each event waits for send room under the same stall deadline. The turn's cancel (supersession or close) ends that wait, and only a current, uncancelled turn's stall closes the socket. An event larger than the 4 MiB client frame limit, the terminal included, which repeats the whole output, is never sent: the turn ends incomplete with the 502 error frame the SSE pump sends for an oversized upstream frame. Local sockets keep the synchronous JSON delivery. The memory acceptance (design 6.3) is tests/e2e-style/link-websocket-rss.test.ts, opt-in with OCX_LINK_WS_RSS_ACCEPTANCE=1. The 32 MiB reservation bounds what the WebSocket transport itself adds, so the test runs one stalled load twice, each Home in its own process: 24 link sockets each send a near-maximum create whose reader stalls, upstream chunks run from 1 byte to 16 MiB, each upstream stream is held after 16 MiB, and the sockets are cut in a seeded random order; the second run sends the same load through the HTTP link relay. It passes when both Homes reported their own stall and the WebSocket run's peak-over-idle RSS minus the HTTP run's is at most 24 reservations (768 MiB). The WebSocket Home reports every link pump waiting for a drain; the HTTP Home reports every relayed response waiting for its reader after handing it a chunk (setLinkRelayWaitObserverForTests in src/server/index/link-websocket-limits.ts, called from src/server/index/link-relay-sessions.ts only while a test installed it; Bun stops reading a body whose socket does not drain, so a stalled Child leaves its relay waiting). The Child's HTTP relay has the same test-only signal (setLinkChildRelayWaitObserverForTests in src/client/link-websocket-limits.ts): only while a test installed it, src/client/link-relay.ts passes each relayed response body through a stream that reads only when its reader does, and reports the wait from each chunk handed over until the reader asks again or cancels, or the relay itself ends (the Home's body finished or failed, a caller abort, the SSE idle limit), after which it never reports a wait again; otherwise the response body is the same stream as before (tests/clients/link-relay-wait-observer.test.ts). On the Home, every reader waiting at once can come before the load's sustained stall: a paused reader's kernel buffer still takes bytes in flight, so a pump's wait can end without its reader resuming, and a relay also waits in the gap between two chunks. Because the peak is read after the cut, a run cut too early reports a lower peak; for the WebSocket run that makes passing easier (the measured difference is the true one minus the WebSocket run's shortfall plus the HTTP run's). The fixture (tests/fixtures/link-ws-rss-home.ts) therefore announces the stall only after a quiet window of 2.5 s of wall time in which every reader stayed waiting and VmHWM did not rise; a reader that moves or a rising peak restarts the window. A load that has not settled 180 s after the idle reading fails the run: the deadline never announces. Each run prints the window and its settle time. These are the only timers this change adds (the fixture already sampled RSS every 2 ms where the kernel peak is unavailable), and they exist only in that opt-in fixture: it is a physical measurement that already runs on wall-clock time. The settle check is armed at the idle reading, so the warm-up turn cannot trigger it, and neither run waits for the upstream's hold point: a link turn reads its upstream only as its reader drains (#8601), so a stalled load stops pulling before it. The peak is Linux VmHWM, reset after the idle reading; a sampled peak cannot pass. Only that canonical load can report an acceptance; any other OCX_LINK_WS_RSS_* setting (socket count, input size, chunk size, one transport alone, a free-running upstream, a heap breakdown at the stall) runs a diagnostic that prints its numbers and asserts no budget. Where a near-8 MiB create was held (#8600, from heap snapshots at the stall and the code path): both transports keep the pipeline's parsed body, which the continuation store needs, and the serialized outbound body, which a retry needs; before #8601 both also kept a re-parse of the outbound body and read 32 MiB ahead of the stalled reader. The WebSocket path added its own copies: the shared handler parsed the frame, serialized it again into a synthetic request (a transient copy), whose body Bun kept for the whole turn (about 8 MiB per socket), and the pipeline read that body back into a second parsed body (about 8 MiB per socket, plus the decoded string while parsing). A link socket now hands the pipeline the create it parsed (attachParsedRequestBody in src/server/request-decompress.ts binds it to that turn's own request, and preparation takes it once and admits it with admitParsedJsonRequestBody under the same limit and budget accounting); a request the pipeline builds from it, a combo child, a policy fallback, a compaction recovery or a retry, is another request and reads its own body (tested end to end for a combo child in tests/server/link-websocket-upgrade.test.ts, and at the binding, with a request built the way policy fallback and compaction recovery build theirs, in tests/usage/request-decompress.test.ts). A local socket still sends a request body. What remains WebSocket-only is the design's transport buffers (one event of at most 4 MiB waiting for the drain and the socket's own send buffer), the parse of a first create when the handshake carried no thread id, and the HMAC over the canonical input for the hold fingerprint, both transient. Measurements on a 4-core Linux VM, peak-over-idle RSS per socket. Before these changes: the canonical acceptance at 24 sockets measured 117.0 MiB for the WebSocket and 70.2 MiB for HTTP, a difference of 1,123 MiB against 768 MiB (a second run: 1,235 MiB), and four-socket pairs at the canonical settings differed by 31.0 to 47.1 MiB per socket. Only the settled runs below count as evidence. Four earlier canonical runs, each a single run on a successive commit of #8625 and cut at the first moment every reader waited (WebSocket minus HTTP 356, 154, 33 and 179 MiB), are not release evidence: their WebSocket peaks varied by about 235 MiB, the same order as their headroom, and an early WebSocket cut biases them toward passing. Settled runs, three single runs on commit 2e8b3f383 (the quiet-window fixture), 24 sockets, peak over idle per socket: run 1, WebSocket 54.4 MiB and HTTP 46.4 MiB, difference 193 MiB, settled after 6.6 s and 7.9 s; run 2, 58.6 and 46.7 MiB, 287 MiB, 5.9 s and 8.1 s; run 3, 52.1 and 46.7 MiB, 129 MiB, 5.9 s and 8.1 s. All three passed against 768 MiB, with each Home reporting its own settled stall. The reservation, the 768 MiB budget and the 24-socket cap are unchanged. The release gate stays closed whatever the acceptance reports. The Child's memory acceptance (design 6.3 and 11.2-9) is tests/e2e-style/link-websocket-rss-child.test.ts, opt-in with OCX_LINK_WS_RSS_CHILD_ACCEPTANCE=1, built the same way: the real Child listener and relay in a process of their own (tests/fixtures/link-ws-rss-child.ts), the Child's gate opened there through the relay's ready seam, a stand-in Home in the test process, and 8 Codex-side sockets (the Child's cap) that each send a near-8 MiB request and stop reading, once over the link WebSocket and once over the Child's HTTP relay (which accepts bodies up to 256 MiB, so both loads carry the same request). The WebSocket Child reports every Home leg paused (more than 1 MiB waiting toward Codex); the HTTP Child reports every relayed response waiting for its reader through setLinkChildRelayWaitObserverForTests (the relay underneath reads one Home chunk ahead), the stand-in Home answers a relayed request only once its whole body (or the whole create) has arrived, and the test requires no wait open at the idle reading and, through the peak reading, never more waits than the load has nor a wait ending that was not open. It has the Home fixture's settle rule, deadline and VmHWM peak, and passes when the WebSocket run's peak over idle minus the HTTP run's is at most 8 reservations (256 MiB). Every message, of any type, re-runs the authorization check before the shared handler sees it: an uncached, fail-closed links.json read still names the link and its key, and the key still has the fingerprint the session was proved with. A failure fences the socket, cancels its turn and closes it with 4401. Native steering and injection are unavailable on a link socket: response.inject and response.steer get native_control_unavailable_on_link, and a create never opens a native channel.
DELETE /api/link/:id, which ocx link revoke calls, closes that link's sockets with 4401 in the same task as its commit, including a socket already upgraded but not yet opened, which closes as soon as Bun opens it, and last-link close() refuses further link upgrades and closes every link socket before it drains. Process stop() stays forceful. A change made any other way (the API-key routes, a rotation that expires, another process, a hand edit) stops new turns at the next frame and running turns at the sweep, which runs every 30 s only while a link socket exists. Switching either setting off starts a drain on each open link socket, at the first sweep or frame that sees it off. The drain never cancels a running turn: a response.create that arrives while a turn runs is dropped and the socket closes with 1000 after that turn; with no turn running, a create is admitted only when it returns a result the socket is waiting for (returnsPending in src/codex/websocket-activity.ts) and nothing is untracked, so the tool loop that was running continues; any other create, and every create 60 minutes after the drain began, fences and closes the socket. A draining socket that becomes idle closes. Outside a drain, a link socket idle for 10 minutes closes with 1000, and one older than 60 minutes closes at its next idle moment so it re-authenticates; a busy socket is never closed on a timer. When the shared 128-socket gate is full, a link upgrade may reclaim the least recently used idle link socket (tryReserveCodexWebSocket({ reclaim: "link" })), never a local one. A link socket counts in the per-link activity book (beginSocket in src/link/child-activity.ts) while it is busy, that is while a turn runs, a client tool or approval result is pending, results are untracked or it is closing, so activity-gated Child restarts wait for it; the busy-to-idle transition stamps the last data end. A socket that closes while busy ends its count. Coverage: tests/server/link-websocket-upgrade.test.ts (a real Home: switched-off characterization, a turn, S2, revocation, same-size and same-mtime edits, native control, close and stop) and tests/server/link-websocket-gate.test.ts (each lease transition, caps, budget, no eviction, the sweep on an injected timer, an upgrade racing last-link close()), and tests/server/link-websocket-drain.test.ts (each drain row, the deadline, idle and age closes, socket activity, link-only reclaim), and tests/server/link-websocket-pump.test.ts (the 5 MiB bound with a stalled reader, framing slices, the drain wiring, the stall bound).
A link socket that closes while busy, for any reason except revocation, leaves a durable activity hold (src/link/ws-activity-holds.ts, <configDir>/link/activity-holds.json): the HMAC of its own thread id (the thread-id handshake header, else the first create's client_metadata.thread_id) under a private key in <configDir>/link/activity-ref-key, the hashed result keys it was waiting for, one running mark per turn that was running (a fingerprint, or unknown; below), and whether its results were untracked. Holds count in the link's activity inFlight, one each and one more for an unknown link, so the existing activity gates withhold automatic and activity-gated Child restarts. A later request from the same link and own thread that returns pending results, over the link WebSocket once every admission accepted the turn and its pipeline answered ok (a warmup, a refused or a failed create releases nothing); on the link socket itself, a turn that is refused or fails puts the results it returned back to pending, so a later busy close still leaves a hold, while local sockets keep settling results when a turn begins; a call that reuses an id while an older turn on the same socket could still put that id back makes the socket untracked, because the key cannot tell the two calls apart; and an upstream output item never settles a link socket's pending client call, since provider-executed calls never become pending or a relayed POST /v1/responses (whose body copy is taken only while the link has a releasable hold and read only after the handler answered below 400: a refusal, the inbound body budget's 503 included, cancels the copy unread (#8608); an accepted copy is decoded through decodeRequestBody under the listener's resolved inbound limit, the same maxInboundBodyBytes resolution the handler uses, so a body the Child sent zstd-coded (#8594) releases like a plain one and a body that does not decode releases nothing), removes those keys; a hold ends when no key, running mark or untracked flag is left. A running mark carries the fingerprint of the cut request. src/server/link-turn.ts is the seam through which prepareResponsesRequest reports a link turn's input right after continuation expansion and before upstream I/O, and the final request log reports its terminal status and provenance. A link socket passes one observer per turn in the handleResponses options; each writes and clears only its own entry in a per-socket map keyed by the handler's turn id (pending, then { n, inputHmac, turnRef } over the canonical JSON of the expanded items and Codex's client_metadata.turn_id, or unknown; done at its own terminal; removed by its own cleanup). The close snapshot turns each pending or unknown entry, and each running turn without an entry, into an unknown mark. A relayed HTTP request reaches the seam only while its link holds a fingerprinted mark, through a random per-request token header that the relay sets after removing any peer copy and the pipeline removes before anything is forwarded; the relay's decision, made when the handler answers or when the request aborts first, drops a token preparation never claimed. A mark is released only by a later request from the same link that is an inference request (generate is not false) of the same own thread and turn, whose first n expanded items have the same HMAC, and whose terminal is completed with upstream provenance (rules (a)-(d) of the design). Each stored mark has its own random id, and a release removes only the marks that request matched at its expansion, never an identical mark created after it; a failed, synthesized or unattributed terminal, another turn or thread, and an input that does not extend the cut one never match. Unknown marks, holds without a thread id and untracked holds are released only by the operator; nothing releases a hold on a timer, and link removal deletes the link's holds. The operator's view and clear (owner decision D8): ?children=1 status rows carry child.holds (each hold's reasons, pending-results, running-turn, running-unknown, untracked or no-thread, its counts and since, plus the link's unknown reason) only while something holds that link, so the default document and the children document of an unheld Home are unchanged; nothing hashed leaves the store. ocx link activity clear --link-id (POST /api/link/children/activity/clear, exactly { linkId }, the Child-operations auth, refused on a Child runtime) forgets one link's holds and unknown state: the holds file is written without the link before memory forgets it, so a failed write clears nothing (503), and an all-links unknown state from unreadable links is first written per link, so a clear while links still cannot be listed is refused (503). In a process that has not armed the fence (no link socket since start), the clear sets the fence running first; the durable holds write is the commit point. If it fails, even after its rename, the fence stays running, the clear answers 503 and the next start keeps every link unknown. Once it succeeded the clear is committed and the fence is set clean; a clean write that fails can leave it running (every link unknown at the next start) or clean (the committed clear), and neither contradicts the answer. An armed process keeps the fence running until its graceful stop. The other half of D8 is the explicit restart override: ignoreActivity: true on a Child operation (ocx link sync --link-id <id> --restart-* --ignore-activity, ocx link restart-codex ... --ignore-activity) is accepted only as true, on a sync or restart-codex operation with a restart and exactly one link id (an update keeps its gate); the engine copies it onto the job only for a manual request, and the runners then skip the activity gate (relayed requests and holds alike) for that item, logging the link id and its in-flight count. Platform, Child-kind and sync refusals still apply, the scheduler and ocx sync --restart-children never set it, and a job without it is unchanged. Request-log fields (design 9.1): while the link WebSocket feature is on (switch and release gate), a link request's final row carries an optional link object, validated by src/usage/link-log-fields.ts for the in-memory log and usage.jsonl alike: transport (websocket for a link socket turn, http for a relayed POST /v1/responses), continuation (chained when previous_response_id was expanded locally or left for an upstream that owns the history; miss with the replay failure's reason, or missing when preparation refuses an unexpanded continuation; full otherwise, reason first for a socket's first turn and client for the rest and for HTTP), and threadRef/turnRef, HMAC-SHA256 under the activity reference key with a domain of their own per kind and the link id as a separate field, so one id gives unrelated refs on two links; never the raw ids. The relay sets its turn token on every relayed POST /v1/responses while the feature is on, and only while marks exist otherwise; with the switch unset no link row gains a field. Requests that never reach preparation, and local requests, carry none. Live counters (design 9.3): GET /api/link/status?transport=1 (what ocx link status asks for) adds to every current link row ws: { open, upgrades, refused{reason}, closes{reason}, holds }. The gate counts admitted upgrades, refusals by reason (aborted, draining, unauthorized, unavailable, memory, cap, failed) and closes by reason (its own fences revoked, disabled, idle, age, else the peer's code: client, dropped, other); open is the current socket count and holds the link's hold count. They are in memory, display only, reset at restart, and dropped with the link; without the query, and with the feature off, the documents are unchanged. Release gate (design 11.1 item 13): tests/e2e-style/link-websocket-release-gate.test.ts drives the pinned Codex binary (OCX_LINK_GATE_CODEX, codex-rs built at openai/codex 88c5856; every Codex scenario is skipped with that reason when it is absent, and the pin test always runs) through tests/fixtures/link-child-relay-standin.ts, a test-only stand-in for the Child's relay (challenge, bearer upgrade, its own 101 with the 8 MiB limit, 426 to Codex on a Home refusal). The scenarios are: the switch going off under a running turn (it finishes, its tool result is admitted, the idle socket is fenced, and the next session ends on HTTP); the drain past its deadline within one session (a zero deadline through the test-only setLinkWebsocketLimitsForTests: the session's tool-result create is fenced on the socket, never reaches the upstream, and the same request completes over HTTP after Codex's retry is refused an upgrade); a request over 8 MiB going over HTTP, and a Home restart mid-turn completing. The 503 sequence while the Child's tunnel reconnects runs the real Child listener (lane T6) with a tunnel gate the test drives: two 503s and then the socket on the WebSocket, and, separately, 503s through Codex's whole retry budget and then the same request once over HTTP. A run counts as release evidence only when the sha256 of the executable it runs equals the digest recorded for openai/codex 88c5856 in tests/fixtures/link-gate-codex.json, which changes only through review: the test resolves OCX_LINK_GATE_CODEX once to an executable file, copies it read-only into a scratch directory, and both hashes and spawns that copy by absolute path (tests/helpers/link-gate-binary.ts, covered by tests/e2e-style/link-gate-codex-binary.test.ts). The fixture records the digest of one attested build, codex-rs at 88c5856 for x86_64-unknown-linux-gnu, whose note lists the builder, date, toolchain and command; it is one attested build, not a reproducible one, so a build for another platform, with another toolchain or in another build directory may have another digest. Any binary whose digest differs runs as a smoke check, and each Codex scenario's title says which. The stand-in records each create forwarded on a socket and each HTTP request, so the oversize case proves the request never went on the socket (with a control run that drops the advertised limit) and the restart case proves the stalled request was the socket's. The stand-in also records the Origin header of every upgrade Codex sends, and the drain scenario asserts there was none (U11). tests/e2e-style/link-websocket-child-gate.test.ts runs the drain, the drain past its deadline, the request over 8 MiB (11.2-8 (a), against the real Child's own 101) and the Home restart through the real Child listener and relay instead of the stand-in, with the same labels (tests/helpers/link-gate-codex.ts); it reads the Child's link WebSocket diagnostics and the Home's request-log rows. tests/e2e-style/link-websocket-child-restart.test.ts measures a Child restart under a running turn, in #8658's order (drain with 503s, listener closed, a replacement bound with no advertisement remembered), under the Child's rule since #8682 (the replacement tries the Home unless it holds a cached off, so it tries when it has seen no advertisement since start) and under the rule before it (426 until a preflight teaches it the advertisement), kept for comparison, each also with the listener closing when the drain starts, a change still only proposed, all modelled through test seams. It asserts only that each turn completes and prints, per case, the transport the turn and the next session ended on. tests/clients/link-ws-autobahn.test.ts is the Autobahn fuzzingserver client conformance run of design 3.3 for the Child's Home-facing leg: the Child's 101 check and a real HomeLeg with an echo stub in place of Codex, against a fuzzingserver on loopback (the test refuses any other host). The recording script is tests/fixtures/link-ws-autobahn/run.sh (the script is in the tree, its outputs are not): it starts crossbario/autobahn-testsuite@sha256:519915fb568b04c9383f70a1c405ae3ff44ab9e35835b085239c258b6fac3074 with the spec beside it (every case), publishes it on 127.0.0.1:9001 only, and removes the container after the run. It is opt-in (OCX_LINK_WS_AUTOBAHN_URL and OCX_LINK_WS_AUTOBAHN_REPORTS), and it judges every case against the reviewed rules in tests/fixtures/link-ws-autobahn-expectations.json: deflate cases unimplemented, a Home message over the size or 4,096-frame limit 1009, other binary messages 1003, everything else passing. The clear is logged with the link id and counts only, and nothing calls it automatically. The management routes reach the store through the optional-listener facade (src/server/index/optional-listeners.ts forwards linkHolds, clearLinkHolds and linkTransport); a listener without the clear hook answers 503 activity_holds_unavailable, never an empty success. Before the first link upgrade of a process, <configDir>/link/ws-run-state.json is written durably as running (a private temp file, content fsync, rename, and a directory fsync whose failure fails the write; Windows has no directory fsync); if that write fails, link upgrades are refused. The holds file and the reference key use the same write. A graceful stop writes the holds and then clean; an all-links unknown state that is still only in memory is first written per link, and if the links still cannot be listed the fence stays running. A start that finds running or an unreadable fence makes every link unknown, persists that, and only then cleans the fence; if links.json cannot be read at that moment every link counts as unknown and the fence stays running. A reference key that cannot be loaded or replaced does not stop the Home: every link stays unknown and nothing is cleaned. a corrupt holds file, overflow (64 per link, 1,024 in total), a failed hold write or a replaced reference key also make the link unknown. No file is read or written unless a fence exists or an upgrade arms one. Coverage: tests/clients/link-ws-activity-holds.test.ts (fence, corruption, overflow, write failure, key rotation, release rules, SIGKILL at each durability point) and tests/server/link-websocket-holds.test.ts (busy, idle and revoked closes, release over the WebSocket and over HTTP, a fence that cannot be written).
Child side. src/client/link-ws-advertisement.ts keeps, per link and in memory only, whether the Home advertised the link WebSocket on its last applied relay-auth answer. relayChallenge in src/client/link-relay-transport.ts takes a number from one process counter when every preflight starts (data requests, catalog reads, the parity report and the readiness probe alike), and noteHomeConnection applies the answer only when its number is higher than the last one applied for that link, so a late reply to an earlier preflight, from whichever Home or Home process, never overwrites a newer one. An answer without the state pair, or whose x-opencodex-link-ws-proof does not verify for that nonce and both identities, applies as off; a restart starts with no answer (unknown), a relink is a new link id, and at most 256 links are kept. While its entry stays cached (an eviction returns the link to unknown), a cached off holds until a newer preflight's verified answer replaces it, also after a Home key change on the same link id; a relayed request, catalog read, route-intent poll, parity report or readiness probe is such a preflight. src/client/link-ws-transport.ts opens the Home leg of one link socket: a preflight and the upgrade on one SingleConnectionAgent connection, never a second; the upgrade proceeds only when that preflight's own answer is a verified on, carries the session nonce, the link key as a Bearer and a fresh Sec-WebSocket-Key, and drops Origin, Sec-WebSocket-Extensions and Sec-WebSocket-Protocol; a 101 counts only on the authenticated socket with Upgrade: websocket, a Connection naming upgrade, the right accept value and no extension or subprotocol (a 101 the HTTP parser does not take as an upgrade is a broken handshake too). It checks the Child's release gate itself before any I/O (disabled), and the upgrade has its own 8 s bound after the preflight (timeout). Every failure is a closed word (disabled, credential, preflight, not_advertised, home_refused, handshake, timeout, aborted) and destroys the connection; nothing retries. The handed-off socket carries an error listener until its owner adds one, so an early reset is never unhandled. The Home leg's frames go through src/client/link-ws-framer.ts, a client framer written here because Bun resolves ws to its built-in shim, whose framer classes are not implemented: masked client frames, and on the read side RSV bits, opcodes, control-frame size and fragmentation, continuation order, minimal lengths, UTF-8 of whole text messages and close reasons, wire-valid close codes (1012-1014 included), unmasked server frames, a message limit and a fragment limit, each violation mapped to the close code to send (1002, 1007 or 1009). The Child's caps, budgets and timeouts are named in src/client/link-websocket-limits.ts, with a Child-side release gate (linkWebsocketChildReady) that stays false until the framer's client conformance run and the Child's memory acceptance have passed. src/client/link-websocket.ts carries Codex's upgrade over the link once the gate is open and the Home's last applied answer for the link was on, or no answer has been applied since this process started (unknown: a restarted Child or a new link tries, and the upgrade's own preflight decides, so an old Home or a Home whose switch is off costs each upgrade tried before its first answer applies (up to the 4 setting up at once) one preflight round trip before today's 426 and a not_advertised diagnostic, and later upgrades answer 426 from the cache while that off entry stays cached; a preflight that gets no verified answer applies nothing and leaves the link unknown, so the next upgrade tries again (a setup that fails after a verified answer, for example when the connection closes before the upgrade, has already applied it), and a later answer or an eviction can change the entry) (design 3.2); with the gate closed, the Child's opt-out or a cached off the ingress answers today's 426 byte for byte and opens no connection, except that during a restart drain (below) an open gate sends an upgrade with a cached off to the 503 (the opt-out still answers 426). A tried upgrade passes, in order: no Origin header at all (a local one included; a non-local one still gets the 403); the link key; the Child's caps (8 sockets open or setting up, 4 setting up) and a 32 MiB reservation from a 256 MiB budget; the tunnel gate, where a Child-initiated tunnel that is connecting or reconnecting is held up to 3 s and then answered 503 with link_websocket_unavailable and no Retry-After (the only transient answer, never one of the codes codex-rs treats as terminal), a failed tunnel gets 426 and a Home-initiated link goes straight on; then the Home leg above; all within an 8 s budget. Every other failure, the budget included, is the same 426, so that Codex session uses HTTP (C1). The local 101 carries x-codex-websocket-max-message-bytes: 8388608, so Codex sends a larger request over HTTP. The listener's websocket handler (8 MiB messages, an 8 MiB backpressure limit that never closes, no idle timeout, no compression) relays whole text messages both ways and never parses or writes one of its own: a binary message from Codex closes both legs with 1003; a Home message over 4 MiB + 64 KiB, or any frame the framer refuses, closes Codex with 1011 and the Home with the framer's code; close codes are relayed (1000-1003, 1007-1014 and 3000-4999); toward Codex 1005, 1006, 1015 and a dropped Home connection become 1011, and toward the Home Codex's drop (1006 with no reason) ends the Home connection without a close frame, Codex's close without a status (1005) becomes an empty close frame, and a 1006 Bun reports with its own reason (an oversized message it refused with 1009) or 1015 becomes 1011; pings stay on their hop; a second message from Codex while the first is still unflushed toward the Home closes both legs (1013 to Codex, 1001 to the Home), and the Home leg stops being read while more than 1 MiB waits toward Codex, resuming on Bun's drain. A close the Child starts on the Home leg waits up to 5 s for the Home's close, and a close the Home starts is ended by the Home or after the same bound; a socket keeps its slot and reservation until its Home connection has actually closed. A close decided before Bun opened Codex's socket is applied when it opens; Bun 1.4 runs open inside server.upgrade, and if a Bun ever upgraded a socket without opening it, the leg stops after the 5 s close bound so it cannot hold its slot. A local upgrade that fails (server.upgrade returns false or throws) closes the Home leg, counts only the handshake fallback (no close, no request in the traffic book) unless Bun had already opened Codex's socket, which then closes on both legs and also counts as a stop, and frees the slot when the Home connection closes. Ownership checks are raced against the setup budget. Any stop of the listener, graceful or forced, first aborts every upgrade still in setup and closes every link socket with 1001 on both legs, so a drain never waits on an idle Codex socket. A Child restart drains earlier: the tunnel supervisor tells the relay when its stop() begins, or when the link ended (its sidecar gone or naming another link, or the connected link id unreadable for several checks in a row) and the runtime is about to recycle, before it signals the tunnel, and from then on open link sockets close with 1001 on both legs and every upgrade, new or still in setup, gets the transient 503 (counted as draining; while draining the Home's last advertisement no longer sends an upgrade to the 426, though a closed Child gate or the Child's opt-out still does), so Codex retries on its WebSocket session instead of falling back to HTTP at once; each 503 spends one of Codex's stream retries, as a refused or dropped connect does, and so does each refused connection while no listener is bound (about 1.4 s at stream_max_retries 3, the gate's setting; Codex's default is 5; measured with the pinned codex-rs 88c5856; see devlog/_plan/261113_link_websocket_gate_opening/000_plan.md, item 3), so the session stays on the WebSocket only when the replacement accepts the upgrade within that budget, and otherwise moves to HTTP for the rest of it (measured with the pinned Codex in tests/e2e-style/link-websocket-child-restart.test.ts, the restarts modelled through test seams, with the replacement trying the Home while it has no answer yet: a restart whose drain answers 503 for 5 s ends the interrupted turn on HTTP at 3 retries and on the WebSocket at 5, a 1 s restart ends it on the WebSocket at both, and the next session uses the WebSocket in every case; measured on one Linux x86_64 machine, printed, not asserted); the Home's activity gate keeps its automatic restarts away from busy links (design 5), and a socket Codex loses mid-turn closes the Home leg at once, which the Home records as a hold. The Child never re-sends a frame: a turn cut by a tunnel drop is retried by Codex alone, on a new socket whose first request is full, or over HTTP once its retry budget is spent. A link socket counts in the Child's traffic book as one request: message payload bytes, toward the Home once the socket write completed and from the Home per complete message, without framing, handshake or control frames, complete when a close frame ended it (a socket Bun reports closed with 1006 counts incomplete: for Codex's drop the Home counts it the same way, while for Bun's own close of an oversized message the Home receives 1011 and counts it complete, so the Child is stricter there); HTTP requests are counted exactly as before, and per-body zstd stays HTTP-only. Diagnostics are fixed words in the existing sink: link_ws_open, link_ws_close (why: client, home, home_dropped, binary, protocol, overload, stopped) and link_ws_fallback (why: origin, credential, cap, memory, tunnel_failed, tunnel_connecting_timeout, preflight, not_advertised, home_refused, handshake, timeout, aborted, draining); the dashboard's diagnostics table does not list these events yet. linkResponsesWebsocketOptOut: true in a Child's config keeps that Child on HTTP whatever its Home advertises (owner decision D4; read once when the listener starts; anything but true follows the Home). GET /api/link/status?transport=1 on the Child adds websocket: { open, settingUp, upgrades, fallbacks{reason}, closes{reason}, fromCodexBytes, toCodexBytes } (design 9.3): in memory, display only, reset at restart; with the Child gate closed nothing is counted, and with it open the opt-out and a Home whose last applied answer was off (outside a restart drain, where it counts draining and writes a diagnostic) count as local_optout and not_advertised without writing a diagnostic (a Home with no answer yet that answers off counts not_advertised through the upgrade's own fallback, which writes one). A non-local Origin is refused (403) by the ingress before the relay and is not counted; fromCodexBytes counts text messages arriving while the Home leg is open (a binary message, or anything after the Home leg began closing, is not). The shape differs from the Home's on purpose: the Home's ws is per link row and counts what the Home did with upgrades (refused), while a Child serves one link, so its counters are one top-level object, and its fallbacks count the attempts the Child itself sent back to HTTP (or told to retry), with reasons only a Child can see (its opt-out, its tunnel, its own setup). Without the query the document is unchanged. Coverage: tests/clients/link-ws-advertisement.test.ts, tests/clients/link-ws-framer.test.ts, tests/clients/link-ws-transport.test.ts, tests/clients/link-ws-relay.test.ts, tests/clients/link-ws-fallback.test.ts, tests/clients/link-ws-relay-edges.test.ts, tests/clients/link-ws-unknown-try.test.ts and tests/clients/link-ws-child-status.test.ts (against tests/fixtures/link-ws-standin-home.ts, a raw-TCP stand-in Home, with a client that follows codex-rs's retry and fallback policy in tests/helpers/link-ws-child.ts), and tests/e2e-style/link-ws-child-home.test.ts (a real Home: a relayed turn counts as the link's activity while it runs, and a socket lost mid-turn leaves the Home's hold). The release gate's 503 sequence (below) runs the real Child listener.
ocx status --json reports localCredentials: {count, generation} from src/local-credentials/ on any runtime. inventory.ts counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex auth.json and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than file throws, and status omits the field instead of reporting zero.
generation.ts keeps local-credentials.json (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: src/oauth/store.ts, src/codex/account-store.ts, src/config/persist-unlocked.ts, src/codex/main-account.ts, the native-profile writers and the provider keychain factory in src/providers/api-key-resolve.ts. The token lc1:<epoch>:<sequence> therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (publication-fence.ts), the OS lock of local-credential-publication.sqlite, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every keychain: and gcp-sa: account config.json references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (references.ts), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. restoreProviderKeyFromKeychain in src/providers/key-store.ts saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. runtime-observation.ts gives startServer and startClientRuntime the same observer, which takes an OS lease (observation-lease.ts), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by credentialWatchEventAction in inventory.ts: an event on a scanned file, on a whole-listing-verified directory such as .opencodex-native-main-profiles, or on credential-write residue, and a rename on a tracked target or ancestor entry, always reconcile, while an ancestor directory's change is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a real ancestor whose name looks like a short name or like write residue keeps its identity check. A name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit; Windows may report either name of an entry, and a removed entry's short name cannot be looked up) is resolved by identity against the install snapshot: it reconciles when the alias is a target, when any target or ancestor of that parent no longer has its install identity, or when it renames a tracked ancestor, and is otherwise ignored, so the observer's own save under a short spelling cannot re-arm the loop. The residual is transient: an add-then-remove of a target, or a rename-and-restore of an ancestor, that completes before its notifications are handled and whose notifications carry only short names that no longer resolve leaves the generation where it was. A differently spelled event on a credential path therefore never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran.
A link-mode Child reports its current tunnel connection as link.connectionGeneration in a ready /readyz; src/cli/status-link.ts reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form <runtime instance>:<sequence> and come from one process-wide sequence (nextLinkConnectionGeneration in src/link/relay-auth.ts), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (linkConnectionProof in src/link/relay-auth.ts), and the Child keeps the value from the latest authenticated answer (src/client/link-connection.ts). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: tests/cli/local-credentials.test.ts, tests/config/local-credential-generation.test.ts, tests/clients/link-connection-generation.test.ts, tests/clients/client-link-tunnel.test.ts, tests/clients/link-readiness.test.ts.
Async credential-publication acquisition in src/local-credentials/publication-fence.ts uses
nonwaiting SQLite attempts separated by timer yields within the existing monotonic 10-second
writer budget. Retry delays start at 25 ms and grow to at most 100 ms, capped by the remaining budget.
Contention never runs the publication callback before the exclusive lock is acquired;
permanent SQLite errors and expiration still fail closed. tests/config/local-credential-fence.test.ts
checks HTTP liveness with separate reader/writer processes, deadline refusal, reentrance and cleanup.
tests/codex-integration/native-profile-healthz.test.ts also switches a real encrypted profile
through the production management route and probes the production /healthz before releasing a peer's publication fence.
src/lib/gui-pair-intent.ts separates private-home write authority from runtime-state reads. The standalone ocx gui pair CLI publishes only a SHA-256 commitment; its random verifier stays in the CLI alongside a capability binding PID, listener, origin, nonce and expiry. src/server/gui-pair-delivery.ts consumes the commitment before minting a grant. Records are regular, bounded, single-link, owner-write-protected files, with required Windows ACL hardening on publication. Missing intent never falls back to runtime attestation or admin-token authority; CLI cleanup retains unsafe or corrupted replacements. This establishes intent-namespace write authority under the protected-home assumption, not human presence or protection from unrestricted owner-level writes; runtime readers cannot derive the verifier. Consumption acquires a per-capability .consuming directory with atomic mkdir before opening the commitment, preventing cooperating Windows consumers from treating multiple successful unlinks as exclusive consumption. Only its creator releases an identity-matching empty lock in finally, including proof/record refusals. Existing locks are retained; crash residue refuses that capability. Online sweeps never reclaim locks by age because a delayed owner may still be active. A fresh command uses a new capability.
On Windows, src/lib/windows-owner-acl.ts independently verifies the intent directory and record at every consumption, including CLI disposal. Publication retains required ACL hardening but performs no verification subprocess. After the record matches its proof hash, consumption closes the descriptor and, while holding the consume lock, reads the owner and the DACL as Windows PowerShell Get-Acl reports it through a trusted PowerShell executable with a fixed encoded script and environment-only paths. Each entry must be owned by the serving token's User SID. Administrators ownership is also accepted only when that SID is the same token's default Owner and its Administrators role is enabled; filtered or deny-only membership is insufficient. That DACL is .NET-canonicalized: compatible entries for the same principal merge and entries that grant nothing drop, but another principal's entry is never folded into the user's and the owner is unchanged, so the policy is the effective DACL rather than the on-disk bytes. In either case the protected, present, non-null DACL must contain one ordinary non-callback AccessAllowed FullControl ACE for the token User and may also contain one ordinary allow ACE each for SYSTEM (S-1-5-18) and BUILTIN\Administrators (S-1-5-32-544), whose effective holders (LocalSystem or an elevated administrator token) can already take ownership of any object and are outside this boundary, while a filtered administrator token holds Administrators only as deny-only and gains nothing from the allow ACE (a hardened directory on a GitHub-hosted runner carries both); every ACE has directory child inheritance or no file inheritance. Foreign, inherited, denied, callback, object and unsupported ACEs refuse. The strict numeric/SID line protocol rejects incomplete, malformed, duplicate, out-of-order and trailing output. Resolution/spawn errors, unsuccessful exit and timeout fail closed without logging paths, SIDs or ACL output. Redemption never hardens or changes ownership. After verification, the directory device/inode and record identity are checked again before unlink. A refusal retains the record, releases the owned lock and mints no grant; restore a private configuration home and run a fresh command to recover.
This Windows check covers the intent directory and record, not ancestor replacement, parent delete rights, privileged restore authority or handles opened before permissions became private. Current ACL snapshots cannot revoke previously granted handle access, and final check/unlink is not atomic with path substitution. A matching commitment, including one in an unsafe subtree, reaches a synchronous verifier that can block the server for its bounded five-second subprocess interval inside the CLI's ten-second fetch timeout; executable resolution and spawn setup precede that timeout. CLI disposal may pay one additional verification.
The same module also offers per-entry confined policies evaluated by the identical line protocol in one batch read: "write-private" and "trusted-ancestry" both require the serving token or a kernel-equivalent owner (SYSTEM, Administrators or TrustedInstaller), with no untrusted effective write, delete or owner/DACL control. Untrusted read grants are allowed. "trusted-ancestry" accepts broad create-only grants on kernel-equivalent-owned existing ancestors and skips inherit-only templates, which grant nothing on the inspected ancestor; descendants are checked separately. Callers must use "write-private" or the stricter "private" policy for direct parents of newly created objects. "write-private" also evaluates inherit-only allow ACEs because new children receive those grants before hardening, and refuses untrusted write or create templates. Deny ACEs are accepted because they only remove access; allow ACEs must stay ordinary non-callback records. An async verifier reports through the shared bounded-subprocess deadline rather than holding the event loop. The Codex CLI update job store consumes these policies for ancestry and non-hardened verification; the strict per-entry "private" rule is unchanged.
Publication/consumption reject symlinked homes and intent directories; ancestors and final check/unlink are not pinned by atomic directory handles, so adversarial replacement needs path-write authority outside the runtime-reader threat model. A capacity-refused mint burns its intent; a fresh command can retry after capacity recovers. Headless services need no terminal, password, biometric API or security key. Hub authorization and redemption/CSRF/expiry/role-change/paired-join checks remain. Capability version v2 rejects mixed CLI/server versions for standalone and Hub commands. Coverage: tests/gui/gui-pair-delivery.test.ts, tests/gui/gui-pair-client.test.ts, and real headless CLI/server tests/gui/gui-pair-http.test.ts. Stale pairing-intent cleanup considers records older than sixty seconds and examines at most 256 directory entries per command; fresh and foreign entries count toward that budget. Unexamined records are left for later best-effort sweeps, without a guarantee that a record behind persistent foreign entries will be reached. This bounds per-entry metadata/deletion work, not directory-enumeration memory or individual filesystem-call duration.
The async ACL verifier drains its stdout while the child runs, so a large verification batch cannot fill its output pipe while the caller waits for exit. Paths travel as base64 UTF-8 lines on stdin to avoid Windows environment-block limits; candidate text is never PowerShell source. A deadline still rejects late output.
The link retirement record module stores a private, atomically published record per link id. Claim compares the complete persisted link under the config mutation lock; clear compares the complete recorded identity and nonce and verifies that the retired id is still absent under that lock. Malformed records are refused, and an existing record is never overwritten. Deletion keeps the record until reconciliation, supervisor reload, and listener reconciliation complete. A retry can resume from the record when the link has already been removed; changed identities and malformed records are refused. Last-link closure and enrollment compensation serialize their listener reconciliation. After an awaited close, a newly present link causes the listener to restart; a failed close or restart retains the retirement record for retry.
src/server/management/link-enrollment.ts owns probe, host confirmation and Home apply; src/server/management/link-route-state.ts supplies shared authorization and lifecycle compensation. The three routes accept the existing dashboard authority or trusted loopback admin authority; Tailscale identity sessions remain refused. Join remains operator-paired only. CLI ocx link probe <alias> requests the optional includeVersion: true offer; legacy dashboard offers retain their three fields. Version probing executes against the temporary learned key and persists no trust. Confirm rechecks the pending generation and five-minute expiry after its SSH await, then updates the latest known_hosts atomically under the config lock. Same-alias operations fail fast on overlap; apply pins a private immutable host file and consumes confirmation before issuing a key.
src/link/remote-probe.ts separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child config show --json preflight in src/server/management/link-child-preflight.ts permit 4 MiB stdout, because no smaller command reports port, runtimeRole and ephemeralChild on every supported Child: full ocx has config get, the lightweight ocx-child binary answers only config show --json. A Child exceeding even that cap answers 502 child_preflight_output_limit instead of a generic preflight failure. Coverage: tests/clients/link-ssh-runner.test.ts, tests/server/link-enrollment.test.ts. A config show that exits non-zero answers child_preflight_failed with the Child's own validation reason as the hint: a full ocx Child then runs config validate --json (16 KiB cap), and only its error string, which names fields and expected types and never configured values, is read through boundHint. A Child without that command, or one whose validate fails or times out, keeps the last config show stderr line (sshFailureHint), or names the exit code when stderr is empty; a config show that exits 0 without a usable port names the command. The CLI (src/cli/link.ts errorCodeBody) keeps exactly that hint, bounded and redacted again with boundHint, beside the error code, and still never echoes the rest of an error body (#8357).
Before issuance, apply requires curl and effective standalone Child configuration plus a native Child health response on its configured port with restartCapability: v1. Already-client, mismatched-port and pre-capability runtimes are refused without enrollment. Thus a capable older standalone Child can be linked by a newer Home. Home calls existing ocx restart exactly once after connect --key-stdin; no raw stop/start or timeout replay. After client-role health, Home registers a fresh expected-key observer and asks Child's credential-free loopback /v1/catalog. Both a complete HTTP 200 JSON catalog and matching authenticated Home callback must occur within the relay window. Enrollment admission cannot satisfy this check. Curl ignores curlrc, proxies and redirects. Phase-specific failures compensate Home state; after possible remote commit, residual: true and fixed Child disconnect/restart guidance remain even if compensation fails. This is not remote rollback.
src/cli/link-enrollment.ts binds probe/confirm/apply to one identity-checked live Home, rejects redirects, and caps the entire flow including response-body reads and interactive confirmation at 360 seconds. Non-TTY add requires an exact SHA256 fingerprint; TTY requires explicit yes. Mismatch stops before host trust or key issuance. Strict response parsing and sanitized errors never print a link key; a timeout reports uncertain outcome. Coverage: tests/server/link-enrollment.test.ts, tests/cli/cli-link-enrollment.test.ts.
Client-side status supplies initiation identity from src/client/link-status.ts: a matching valid sidecar establishes Child initiation and SSH alias, while missing sidecar plus absence of any Child marker establishes Home initiation. Unreadable/mismatched evidence, including a marker naming a different link, emits no synthetic direction. Link-mode ocx status reads Home state (served on hub-link by both standalone and Hub Homes) by asking the identity-checked local Child without a credential; the Child's connection-bound relay sends the key. A missing, unsafe, or mismatched CLI token file does not block this credential-free read; the saved connection owner must still match after Child discovery. It never sends that key directly to the saved tunnel URL. On an older Home returning a bounded 404 body with error.code: hub_state_not_a_hub, the Child treats it as role absence rather than version skew and suppresses old cached Hub projection. Other errors retain existing owner-bound cache fallback. Verified Child observations update the matching active link by ID even when the local store contains records; unrelated records and persisted compensation failures retain their own state. Home remains the transport label in either role.
A link Child relays every model request to its Home, so everything the Home's proxy applies per request (effort caps, the sub-agent roster and fallback, the injection prompt, sidecar and compaction routing, the catalog) already follows the Home. Two things did not: the Codex settings the injector writes into the Child's own config.toml, and every management read. Both now cross the link, and nothing else does.
src/link/management-routes.ts is the shared, default-deny table. A Home management path is readable over the link only when it is listed in LINK_MANAGEMENT_READ_PATHS (the reads the dashboard polls except the model list, the header quota strip, the passive /api/quota-pool read GOAT consumes (quota pool), and the account, usage, catalog and preset reads the CLI makes; combos and routing profiles stay out until their projections are reviewed for decision-endpoint configuration), the method is GET or HEAD, and the query carries no refresh, reload, force or quota key, which would make the Home refresh credentials or probe upstream quota. /api/models, /api/model-presets, /api/subagent-models and /api/subagent-model-fallback are absent because they run live provider discovery and can persist an initial model selection; the Child's Codex picker already shows the Home's catalog, and the preset preview only decides a write a Child cannot make. /api/injection-model stays readable because the Child's dashboard and ocx effort show the Home's delegation model and effort from it, Every link read is a passive read (runPassiveRead in src/lib/passive-read.ts, entered by src/server/index/link-management.ts): it shows what the Home already knows and does not make the Home act on providers, accounts, processes or files. Inside it fetchAllModels and the management model rows return no routed models, so /api/injection-model and /api/sidecar-settings list native options plus the persisted picks; fetchProviderQuotaReports returns the reports the Home holds, without a probe, token refresh, account snapshot or publication into routing evidence; the Codex account list uses the cached main-account info and pool quota, without a token refresh, WHAM probe or native-main claim; the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh (which runs the Codex binary and persists the runtime) are not started, and a native-main fence raised for unknown service ownership is reported as it stands instead of re-inspected (a successful re-inspection activates the native-main lifecycle and its recovery); hardenConfigDir, hardenExistingSecret and backupInvalidConfig do nothing, so reading a credential store neither tightens its permissions nor backs up a malformed copy; and memory-budget enforcement triggered by a read (the usage caches publish and enforce) demotes only stores registered as passiveEvictable in src/lib/app-owned-memory-stores.ts, whose eviction only releases memory (today the two usage caches), because demoting other stores can spill a continuation to disk or rewrite a replay snapshot; the rest of the budget is enforced by the Home's own next enforcement. The Home's own dashboard and traffic keep those caches current. tests/server/link-management-relay.test.ts reads every allowlisted path, GET and HEAD, against a Home with a pending provider, a quota-probed provider, a canonical OpenAI provider, a pool account with an expired token, a malformed OAuth store and a native-main ownership fence. Inside a passive read it records calls to fetch; child_process execFile, execFileSync, spawn, spawnSync, exec and execSync; Bun.spawn, Bun.spawnSync and Bun.write; the node:fs writers writeFileSync, appendFileSync, copyFileSync, renameSync, chmodSync, mkdirSync, rmSync, unlinkSync, writeSync, ftruncateSync, truncateSync, linkSync, symlinkSync, utimesSync and openSync for writing; and the matching node:fs/promises writers, and asserts there are none. Writes through other APIs (callback fs, file handles, Bun file writers, SQLite) are caught only as file changes: the test compares the size, mtime and mode of every file under the Home's config and Codex homes before and after the reads, along with the config file bytes and the live config, and checks that the ownership fence was not re-probed. A separate case reads /api/usage against a ledger row, a persisted Antigravity replay session, an undeclared cache and a continuation store far over the memory budget, waits past the replay snapshot debounce, and checks that enforcement ran without demoting any of them, while the Home's own enforcement demotes all three. The same reads made by the Home then back up the store, probe the quota and the account, re-probe ownership and decide the pending provider, which proves the fixture is live. Work a read schedules for later is still attributed to the read when it runs; the test waits past the responses snapshot debounce (2 s) and checks again after the Home reads, but it cannot observe work delayed beyond that. /api/logs and /api/diagnostics/project-config are deliberately absent: the first carries request detail, the second describes files on the Home. The Home's hub-link listener admits /v1/link-management/<path> and /v1/link-management/codex-settings (linkManagementRouteAllowed) in addition to the data-plane table; the Child's own data-plane ingress keeps the data-plane table, so a local caller on the Child cannot reach these reads without the Child's dashboard session.
On the Home, src/server/index/link-management.ts runs after the link-key admission in src/server/index/serve-options.ts. It builds a fresh loopback request with no caller header, cookie, Origin or credential and no principal, so no consent- or capability-gated branch is reachable, calls the management handler, and returns only content headers with Cache-Control: no-store. The relayed projections are the dashboard's own: provider and key-pool rows report presence and masked identities, never a credential value.
On the Child, the dashboard's shared plane is the listener's /api/machine/hub-relay base. /api/machine/status advertises it with managementTransport: "relay" and a link block (linkId, the Home's name, readOnly: true), and the shared plane bootstraps /api/machine/hub-relay/opencodex-session, which the listener answers with its own session, so the browser never holds a Home credential. src/client/link-management-relay.ts requires a Child dashboard session, refuses anything outside the table with 403 link_child_read_only, and sends the rest through relayLinkDataRequest with the management route table, so the link key is attached only after the connection-bound proof described in Connection-bound relay authentication. The dashboard shows "Managed by Home over Remote Link", and its header quota strip reads the Home's quota.
The hub management relay advertises relayTargetCheck: "expected-target-v1" in /api/machine/status when its listener supports the optional x-opencodex-expected-target request field. relayHubManagementRequest validates one canonical HTTP(S) origin of at most 512 UTF-8 bytes against the same immutable HubRelayTarget snapshot used for forwarding, before reading the request body; malformed, duplicate or oversized values answer 400, and a mismatch answers 409 target_changed with Cache-Control: no-store and no upstream call. The field is stripped before forwarding. An absent field keeps the legacy caller contract, link mode stays read-only, and a legacy relay preflight is explicitly weaker than the negotiated check: it is a pre-send observation, never an atomic forwarding guarantee.
src/cli/link-child.ts applies the same table to the CLI: runtimeRequest, the account client (LINK_HOME_BASE in src/cli/account-api.ts) and the observation commands either read the Home through fetchLinkHomeManagement in src/client/link-home.ts or refuse with linkChildManagementRefusal, the one message that names the Home and the link and states that no request was sent. ocx usage reads /v1/usage through linkBoundFetch; ocx status reads /v1/hub-state through the local Child listener, names the Home and the link, and lists the Codex settings the last sync applied; ocx provider list|show and ocx config get label their output as local and inactive, ocx config set|unset|import write locally with the same note, and ocx provider add|remove|set-default are refused because they could only change inactive providers. Account commands classify providers from the Home's /api/providers projection (accountClassificationConfig). src/cli/account-affinity-reset.ts refuses a Link Child before liveness discovery or dispatch and includes the Home/link guidance in both text and JSON errors; no provider roster is needed for that refusal. Coverage: tests/cli/cli-link-child.test.ts.
src/link/codex-settings.ts owns the Codex settings document. It carries exactly the OpenCodex config fields the injection plan reads that are policy rather than listener facts: fastMode, multiAgentMode, webSearchSidecar.enabled, and the effective managed sub-agent defaults (syncCodexSubagentDefaults with injectionModel and injectionEffort). Root model_reasoning_effort and the desktop.* picker keys are Codex app settings OpenCodex writes on no machine, so they are not part of it. Port, hostname, the Desktop authless and client compaction routing forms, and resume history stay the Child's because they describe how the Child's Codex reaches the Child. The document rides on its own read rather than in the catalog because the catalog bytes are written verbatim to model_catalog_json. The parse is strict. syncConnectedClient and link-mode connect in src/client/connect.ts overlay it on the Child's config before injection. A fresh read is recorded in <configDir>/link/home-codex-settings.json (0600, keyed by link id, removed on disconnect) only after the injection that applies it succeeds, because ocx status reads the record as applied; connect with --no-sync records nothing. A failed read reuses that record, and a Home without the route leaves the Child's local values and says so. ocx sync and ocx status list the applied values; the record's host name is also how a Home-initiated Child, which has no sidecar alias, names its Home.
The multiAgentMode policy also preserves v2-plaintext through projection, strict parse and the Child overlay, rather than dropping it as an absent mode. It follows the same authenticated Home-policy boundary; no credential or resume-history field is added. Older Children reject the unknown enum through their existing strict parser, so both endpoints need support before this mode can be applied consistently.
Coverage: tests/server/link-management-relay.test.ts (the real hub-link listener: admission, refusals, masked projections, the settings document), tests/clients/client-machine-listener.test.ts (the Child relay and session bootstrap), tests/clients/link-home-settings.test.ts (parse, overlay, injection and record fallback) and tests/cli/cli-link-child.test.ts (the CLI reads, refusals and Home wording).
src/link/codex-settings.ts also owns appliedCodexSettingsDigest (OCX-6): lowercase hex SHA-256 of compact JSON written in a fixed key order (version, then settings with fastMode, multiAgentMode, webSearchSidecarEnabled, subagentDefaults as null or {model, reasoningEffort}). It is a published contract that GOAT recomputes byte for byte, so it is serialized by name, not by a canonicalizer, and tests/clients/link-codex-settings-digest.test.ts pins two golden digests computed outside the code. The Child's record carries appliedAt, stamped when an apply writes a different digest and kept when the same digest is re-applied; a record from before OCX-6 reads its fetchedAt. Before an apply writes different settings, withdrawChangingHomeCodexSettingsRecord removes the old record, so a failed injection or record write reports no digest instead of the previous one. A Home reports the digest of the document it serves right now, which is what P6 compares a Child against. A failed record write is an error on ocx sync and a warning after an enrollment connect has committed (the committed connection is never rolled back for it). Every apply, ocx sync and the link branch of connect, holds the apply lock (withLinkApply: the lifecycle lock primitive on its own path under <configDir>/link/, outermost, busy_timeout = 0) from the Home read through injection to the record write, so a record always names what its own injection wrote. L cannot be held across injection because injection takes N and the order is N -> L -> C. Coverage: tests/clients/link-apply-record.test.ts.
When sync preserves an external Codex provider without applying configuration, it withdraws even a same-digest apply record under the current connection guard and reports Codex route conflict; other client routes still reconcile. tests/clients/route-intent-codex-transitions.test.ts covers the record and parity report.
ocx status --json reports appliedCodexSettingsDigest on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child lastApplyAt and runtimeStartedAt as epoch ms (src/cli/status-codex-settings.ts). The client runtime's /healthz reports startedAt. After ocx sync records an apply, src/cli/link-apply-restart.ts restarts the client runtime through handleProxyRestart, the ocx restart path, only when the live client runtime started before appliedAt or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to ocx service restart. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result.
The client listener serves that bound restart (src/client/machine-listener.ts, src/client/runtime-restart.ts). /healthz answers the attestation challenge with the secret the runtime publishes in runtime-port.json and advertises restartCapability: "v1"; POST /api/system/restart admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, src/client/runtime.ts omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers 409 restart_supervised naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after ocx disconnect or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (readClientConnectionStateWithEvidence, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (classifyClientRestartSupervisor): manager-launched evidence is the current markers in the environment only (isMarkedSupervisedServiceChild: the managed marker or the Windows wrapper marker); a legacy Windows registration recognised only from its parent's command line counts as a bare OCX_SERVICE=1; relaunch needs the service diagnostic to be viable and running. Manager-launched without that is service-unproven. service-unproven keeps the runtime and is refused at admission even when the connection ended; on this PR's recycle path only a process whose owner is proven to be none detaches, carrying no supervisor marker. A bare OCX_SERVICE=1 is outside that path: with a viable service it is refused, and otherwise it takes origin/dev's replacement restart, which spawns a detached replacement even while a legacy manager still owns the process (#8545). A marker proves only that the process was started with it, so no supervisor marker may reach a process that supervisor did not launch: src/lib/supervisor-markers.ts holds the one list, and every detached runtime launch (companions, recycle, replacement, detached and post-update starts) and every shell a runtime hands to a tool clears it. The ended-connection recycle needs positive ownership evidence (restartOwnerProven: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: ocx restart refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and ocx disconnect restarts only a runtime whose record states one it handles (restartOwner: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own ocx restart). Owner evidence for those runtimes is a separate design (follow-up issue). ocx disconnect (src/cli/disconnect-runtime.ts) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (recoveryAdvice) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets ocx status inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own ocx restart. No read fences a process that runs after the command has exited. Right after its connection commits, connectClient (and each connected ocx sync injection) takes over a Codex journal another owner wrote (adoptJournalForClient in src/codex/journal.ts), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (CLIENT_RESTART_BUDGET_MS, 90 s from acceptance) covers a 20 s listener drain, a replacement ocx start that must answer by 70 s (spawnReplacementStart with notReady: "terminate", which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. requestBoundSystemRestart sends the request to a client target only while CLIENT_RESTART_REQUEST_MIN_REMAINING_MS remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: tests/clients/client-runtime-restart.test.ts, tests/server/restart-replacement-not-ready.test.ts, tests/cli/link-apply-restart.test.ts.
The Home learns a Child's apply from the Child (src/link/child-parity.ts, src/client/link-parity-reporter.ts). The linked client runtime POSTs {linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence} to /v1/link-management/child-parity through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and src/server/index/link-management.ts handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 8 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by runtimeStartedAt, then by sequence (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. GET /api/link/status and ocx link status --json carry lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest and reportedAt (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: tests/server/link-child-parity.test.ts, tests/clients/link-parity-reporter.test.ts.
src/link/route-intent.ts owns the version-1 Home route intent document, the dependency-free routable client roster and the strict parser. The document contains only client switches, model-name policy, stable Aside user-id selection and each client's source (builtin, default, device, and native only in the Home's own document, which the wire parser refuses and the local receipt parser accepts through allowNative); it carries no local paths, ports, profile slots or credentials. Child builtins enable Codex and Claude only. On the Home (deviceId === "home"), the native clients in HOME_NATIVE_ROUTE_CLIENTS (Codex, Claude, Grok, Claude Desktop) project from the Home's persisted switch with source native (only an explicit false is off), ignoring defaults and devices.home; only decidedClient takes decidedIntent or, absent that, the default Children inherit (the builtin until a default is saved). This does not opt Child devices in. The native adapter (src/client/route-intent-apply.ts) treats a native row it is not deciding (LocalRouteIntentOptions.decidedClient) on a machine that is not a connected Child as the live switch: it never calls persistNativeRouteIntent for it, and an enabled Grok or Desktop row gets startup scope only: Grok returns current without writing when localClientSyncAllowed refuses (a gated hub or a sibling), and Claude Desktop refreshes through the writer's refresh-only path when the library is gateway_ours, updating only the applied marker, and otherwise returns current without creating a profile or changing desktopMode. A refresh-only Desktop profile that inspects as gateway_drifted, unsafe or broken (against the durable applied marker, not the caller's snapshot) is a conflict, with or without overwrite, only when OpenCodex owned it: the selected entry is an OpenCodex entry, or ownership is unreadable and a committed applied fingerprint says OpenCodex wrote the profile. A refresh never takes one over. A library OpenCodex never owned (unreadable _meta.json, an applied id outside the safe pattern, another app's missing or unparseable profile) is current with no write, so a full Home sync never fails on it or suggests an overwrite. Only the decided client gets fresh writes. Regression: tests/clients/route-intent-home-consent.test.ts. Children never read the Home's native switches: a device override, then the saved default (state.defaults), then the builtin, with unspecified device policy fields inherited from the Home. homeNativeRouteDefaults gives the values the Home's switches are captured as: Codex and Claude from the switch (only an explicit false is off), Grok and Claude Desktop always off. A Home decision for a native client is its switch: recordHomeNativeDecision (src/routing/route-intent-state.ts) keeps no devices.home entry, setRouteClientIntent/clearRouteClientIntent refuse one, and initializeHomeRouteIntentDefaults drops any an earlier version saved. ocx integrations native <client> on|off (src/cli/inspect.ts) calls the native route, which captures defaults from the durable switches, writes the switch and runs the client's own transition, including Desktop first-party; on a Child it refuses through the route-to refusal with the Home device command. No-op native enables preserve absent persisted flags and their in-memory projection. Projection is pure: its caller supplies the state as the fourth argument. File integration ownership migration belongs to the Home management owner, not this projection. Regression: tests/clients/home-route-intent-upgrade.test.ts, tests/routing/route-to-home-off-review.test.ts.
A full Home sync (syncHomeRouteTo with no decided client, from ocx sync or ocx route to sync) on a Home whose existing Aside ownership cannot be named by stable userIds and that has no saved Aside decision leaves Aside exactly as it is, saves only the native defaults, applies every other client and calls onAsideUnresolved (ocx sync prints HOME_ASIDE_UNRESOLVED_MESSAGE), so a Home upgraded with such profiles keeps a working sync; a plain default write that decides Aside still refuses. An explicit --device home Aside write saves devices.home.aside and applies it even when the Child default is unresolved. Before the Home changes ownership, it captures resolvable Child defaults; if ownership cannot be resolved, it saves only native defaults. A Home override without a saved Aside default makes that default unresolved: ownership may already have changed, so an inheriting Child read refuses and capture remains owed. An explicit Child Aside override remains projectable. Status reports the Home decision and unresolved inheriting Children separately; a full Home sync continues to apply the Home decision. Coverage: tests/routing/route-to-home-sync-review.test.ts and tests/routing/route-to-home-decisions.test.ts.
src/routing/route-intent-state.ts stores version-1 defaults and device maps in <configDir>/route-intent.json. Missing state reads as empty; malformed, oversized or non-regular state refuses rather than silently enabling builtin routing. Updates hold the config mutation lock across read, validation and atomic write, preserve disjoint client/device entries and remove only the named device override on inherit. Toggle updates preserve omitted client policy knobs. Retirement removes both device policy and revision entries; writes and enrollment prune entries for no-longer-live peers while retaining defaults and Home state. State is never read or written on module import.
src/routing/home-route-intent.ts bridges existing ownership records into Home defaults without adopting unowned blocks. The native defaults Children inherit are captured once by captureHomeRouteDefaults (src/routing/home-route-capture.ts), which fills only missing native defaults from the durable switches and never on a Child: initializeHomeRouteIntentDefaults derives them in the same single write as the ownership defaults (an Aside refusal writes nothing). Capture happens at these points only: a Home default write (ocx route to <client> on|off with no device) and Child enrollment (seedRouteDeviceClients, from both link enrollment routes) capture always; the hub-link listener (src/server/index/link-listener.ts) captures before it binds when the Home has a link, and a failed capture (a busy lock, a damaged file, unresolved Aside ownership) is owed and retried with backoff until it succeeds or the listener closes or stops, and on each ensureStarted (tests/server/link-listener-lifecycle.test.ts), because the Child's read never captures. The listener's capture is captureChildRouteDefaults (src/routing/home-route-intent.ts): it runs the full initializeHomeRouteIntentDefaults, so the file clients' ownership defaults are frozen with the native ones and a Home upgraded from a version that saved only the four native defaults cannot have a later Home-only toggle move a Child's default; when existing Aside ownership cannot be resolved it saves only the native defaults and stays owed; and every Home-only decision captures first only when the Home has a link (unreadable trust metadata counts as linked; a link whose childKind is ephemeral does not count, because ocx-child never reads route intent): setHomeIntegrationEnabled (ocx restore, ocx restore back, CLI and dashboard Desktop apply), Messages off (src/server/management/protocol-routes.ts), the Claude toggle (PUT /api/claude-code), the native route (ocx integrations native, PUT /api/native-integrations/<client>), a --device home route write and the Home's own apply (syncHomeRouteTo), each through the whenLinked option. On a Home with no link nothing reads or writes route-intent.json for these decisions: initializeHomeRouteIntentDefaults returns at once under whenLinked, the native toggle skips it and recordHomeNativeDecision, and a --device home native route write skips reconciliation, initialization and that bookkeeping, so each only changes the switch; the Home's own apply (syncHomeRouteTo) saves no defaults there and only drops a legacy devices.home native entry when one is present (dropLegacyHomeNativeOverrides). Saving ownership defaults there would pin clients the dashboard later enables, because ocx sync applies saved route intent to every Home client. So a Child enrolled later inherits the switches as they stand at its enrollment, whichever writer moved them, unless a Home default write already saved the defaults; until a default is saved, a Home inherit for a native client takes the builtin. Reads never capture: projectChildRouteIntent, the link route-intent read and the GET /api/route-to status rows are pure. When route-intent.json cannot be read or the config lock is contended, a Home-only switch writer refuses before the switch moves with { ok: false, reason: "unavailable", retryable, message } naming the file to fix or remove (route_intent_unavailable from Messages off and the Claude toggle; one complete ocx restore --json envelope), because writing the switch first would change what Children inherit once the file is repaired. readRouteIntentState raises RouteIntentStateUnreadableError, whose message names the file and says to fix or remove it; on a linked Home the native toggle answers it as metadata_unreadable (500), and every route-to write, including a --device home write on a Home with no link (its apply projects from the file), as route_intent_unavailable (500, desiredCommitted). Regression: tests/routing/route-to-home-native-truth.test.ts. src/codex/desired-state.ts itself stays free of link state because it is on the always-on path (tests/lab/core-link-boundary.test.ts). Afterwards only a Home default write changes them. Regression: tests/routing/route-to-child-defaults.test.ts. On the Home, the persisted native switch (clientIntegrations.codex, .grok, .claude-desktop, claudeCode.enabled; absent is on) governs, because ocx restore, ocx restore back, Messages off, ocx claude desktop apply and agent settings change it without recording route intent; only the client a Home write decides (decidedClient) is written through its switch, with the default for a default write and the explicit value (onlyIntent) for --device home. A full Home sync (ocx sync, POST /api/route-to/sync) decides no client and never writes a Home native switch. src/routing/route-to-local.ts projects from the durable switches, not the server's in-memory snapshot. A single-client Home write materializes only that client (onlyClients) and keeps the other clients' previous receipt outcomes whose client intent digest is unchanged (retainSkippedOutcomes); sync materializes every client. Regression: tests/routing/route-to-home-live-flags.test.ts. Provisioning seeds device overrides only for an explicit client selection; absence inherits current and later Home defaults. Retirement, default capture and explicit selection updates share one reentrant config-lock section. Each device-client on/off/inherit increments a sparse revision exposed as optional clientRevisions; defaults do not. A direct Child's explicit initial selection remains local and is reported only for display. A matching revision preserves it across sync; a new Home decision supersedes it, including inherit. The initial local selection is recorded before the connection commit, with compensation if commit fails. Older Home issue commands are retried without the selection argument only after a known empty-output usage refusal. Reports never mutate Home policy. First-party Claude Desktop already routes through OpenCodex: an enabled route intent is current with no native or file write, and a disabled one is a conflict, before native intent changes, whose message names the existing mode switch (ocx integrations native claude-desktop off on the Home, ocx claude desktop apply --gateway on a connected Child). A gateway write whose post-write recheck finds first-party still reports the conflict; a client-specific CLI mutation warns about unrelated conflicts while preserving their reported status, and sync remains strict across all clients. A Home write (default or --device home) answers 409 route_apply_unresolved when applyHome's own outcome for the decided client is conflict or failed, with that outcome in the Home row instead of the rebuilt row's stale, so the CLI exits nonzero and prints the outcome message (the mode switch, for first-party Desktop). Any other failure after a write answers 500 route_to_failed whose desiredCommitted is true, with a message saying so, when the write had already saved route policy or moved the Home's native switch (the durable switch is compared with its value before the write); otherwise it is false and nothing was saved (tests/server/route-to-routes.test.ts). Its desiredCommitted is true, and the message starts "Route policy was saved", when the write saved route policy before the apply (a default, or a devices.home override for a client that is not native). A Home native decision is its switch, and most refusals come after the apply saved it (an external Codex provider, a failed injection, an incomplete Grok block, a changed Desktop profile), so the route compares the durable switch before and after the apply: when it moved, desiredCommitted is true and the message starts "The Home's switch was saved, but applying it ended in ", the same truth PUT /api/native-integrations reports; when it did not (first-party Desktop refuses before the write, or the switch write itself failed), desiredCommitted is false and the message starts "The Home did not apply". A Home-only file integration write is the Home's decision for that client (src/server/management/home-integration-decision.ts): the dashboard toggle and ocx integration client enable|disable (PUT /api/client-integrations/<id>), an undo (POST /api/client-integrations/restore), and every Aside profile toggle or restore (/api/client-integrations/aside[/profiles[/<id>[/restore]]]). Before the write, on a linked Home an unreadable route-intent.json refuses by name (route_intent_unavailable), while on a Home with no link the write proceeds and records nothing, because ocx sync cannot read that file either and so cannot undo it (tests/routing/route-to-home-decisions.test.ts); and on a linked Home initializeHomeRouteIntentDefaults saves every default Children inherit, so the write cannot move a Child's default. After it succeeds, once homeRouteIntentInUse (a saved default or a devices.home entry, the condition under which ocx sync applies route intent) holds, it is recorded as a devices.home entry, so ocx sync keeps it and Children keep their defaults; without route intent nothing is written. A file client's entry is the toggled value, or after a restore the ownership it left; Aside's (recordHomeAsideDecision) names the stable userIds of every profile the Home owns afterwards (deriveHomeAsideIntent, the derivation the upgrade bridge uses). An Aside change that may enable a profile without a stable userId is refused before anything changes (409 route_intent_aside_unresolved); a record that fails after the write answers 500 with integrationChanged: true. Native clients and a selected Hermes profile are not recorded. The plain CLI toggle is the same Home-only decision; ocx route to <client> on|off writes the Children's default, and on a Child the plain toggle is refused with that Home command. Regression: tests/routing/route-to-home-decisions.test.ts.
The authenticated GET /v1/link-management/route-intent is separate from the management relay and the Child data-plane route list. src/server/index/link-management.ts resolves exactly one current link from the admitted key id and projects that link id's effective policy. Every query, including a device selector or refresh request, every other method and every upgrade is refused. The read emits Cache-Control: no-store and does not mutate state. Missing or ambiguous link identity refuses; unreadable state returns a fixed unavailable response.
src/server/index/route-intent-watch.ts adds optional closed headers on that same GET: x-ocx-route-intent-watch: 1 and an optional x-ocx-route-intent-after lowercase SHA-256 digest. A differing or absent digest returns immediately. A matching digest waits for a Home notification or at most 30 seconds, then repeats the listener's link-key admission against current policy (serve-options.ts passes a readmit callback; a key revoked during the wait is refused with 403 link_key_required), rechecks current link ownership and projects fresh policy. Successful reads advertise watch support in the same watch header; watch responses identify changed, notified or timeout in x-ocx-route-intent-wake. An unchanged timeout needs no apply, but an explicit notification and a changed digest still request sync. A peer without the new response header retains the previous conservative behavior. src/link/route-intent-watch.ts holds at most one waiter per link and 256 total in process memory; duplicate/capacity admission refuses, cancellation removes the waiter, and notification neither persists state nor acknowledges an apply. Home device mutations notify that link, default mutations notify every linked device, and Child-initiated explicit sync notifies an active waiter while returning pending; without a watcher it directs the caller to the Child or the next periodic tick. Conflict overwrite still requires explicit local Child authority.
src/client/route-intent-watch.ts exposes startRouteIntentWatch(connection, key, { requestSync }) and stop(). Each GET uses the existing connection-bound relay, resolves a key getter afresh for rotation, pins the starting connection identity, refuses redirects and consumes at most 64 KiB with an 8 KiB response-header bound and a 40-second total read budget. A Home without watch support leaves reconciliation to the existing periodic tick. Failed reads retry after five seconds; immediately returning supported peers cannot create a tight loop. Watch notifications retain one pending apply while requestSync is busy and retry rejected applies. The runtime owner supplies the same serialized scheduler used by periodic sync, reports successful applies through the existing parity reporter, and stops the watcher with the runtime. Standalone recycle also stops the reporter, watch and tick before listener shutdown or replacement handoff; watch cancellation releases the outstanding Home read. The watch itself writes no client files or receipts and adds no Child-to-Home mutation endpoint. The periodic tick (src/integrations/route-intent-tick.ts) runs a full syncConnectedClient every 60 seconds even when nothing changed. Each apply first clears the Home proof record and writes a withdrawn receipt, because a failed or interrupted apply must not leave an earlier proof current, then rewrites both; unchanged writers make no client-file change, but the receipt and record are rewritten, and a parity report sent during that window carries the withdrawal until the next report. Avoiding the rewrite would mean knowing an apply changes nothing before running it, which the withdraw-first rule forbids. Native switches on a Child follow the Home: a local ocx restore is reverted by the next tick unless the person records ocx route to codex off --local. A tick can change the saved selectedClients, so /api/machine/clients reports the saved selection while it still belongs to the served connection (src/client/machine-api.ts). The tick does not restart the runtime after it applies changed Home Codex settings: the parity report keeps the runtime's real start time, so status shows that the runtime predates the apply, and ocx sync (or the Home fan-out) restarts it as before.
The served roster a connected Child materializes from is the Home's own GET /v1/models list, so loadConnectedRouteModels (src/integrations/route-intent-connected.ts) reads each row's capability fields there — capabilities.input_modalities, capabilities.context_length/max_output_tokens, supports_reasoning, supports_thinking_toggle, supports_reasoning_effort, default_thinking_enabled, the reasoning_effort ladder (bare values or Grok's {value, default} options) and the top-level context_window/context_length/max_output_tokens mirrors — and maps them into ExportModel, the same projection toExportModel makes for /api/models. The row is remote input, so every value is validated: an integer window, a boolean axis, a modality from the exporters' vocabulary or a declared effort, with malformed entries dropped individually and nothing synthesized for a field the row omits. Without this the file integrations a Child materializes from Home intent (Pi, OpenCode and the rest) fell back to text-only with no reasoning controls. Fields the served row does not carry — maxInputTokens, supports_reasoning_summaries and supports_tools — stay absent and the exporters apply their own rules. The Grok adapter carries the ladder and its declared default into the Grok config, and the Desktop adapter passes each routed window, which decides supports1m. A combo row the route catalog does not list is accepted only as an expanded selector of a listed one, <base>--fast or <base>--<declared effort> as the Home's /v1/models publishes them, and takes that base's canonical key; an expanded selector whose base is unlisted is left out on its own, while any other unlisted combo still rejects the roster. Coverage: tests/clients/route-intent-connected-capabilities.test.ts.
routeIntentDigestPayload serializes compact JSON in the order version, clients, sources; each map follows ROUTE_CLIENT_IDS, each policy uses enabled, optional modelNames, optional accounts, and account lists sort lexically. routeIntentDigest is lowercase SHA-256 of those UTF-8 bytes. Home name/version labels are excluded; policy sources are included. The builtin golden digest is pinned independently in tests/clients/route-intent.test.ts.
src/client/link-home.ts fetches this document through the existing connection-bound relay with manual redirects, the existing read deadline and a 64 KiB body bound. Only 404 means unsupported; malformed documents and other failures remain explicit failures. The local <configDir>/link/home-route-intent.json receipt is link-bound, bounded and private. It stores the document, fetched/apply timestamps, appliedRouteIntentDigest and routeClientOutcomes. Only a complete roster of current dispositions with no effective local opt-out of a Home-enabled client claims the digest; incomplete, unavailable, failed/stale/conflicted or locally diverged applies record a null digest. Reapplying the same completed digest keeps the original apply timestamp. The materializer owns receipt creation after local work and removal on disconnect. Connect also writes <configDir>/route-intent/unapplied.json, bound to the link id and connectedAt, in the block that commits the connection; the first route apply removes it under L before changing any client. With a matching proof (--no-sync, an older Home) and no receipt, an ownership record predates the link, so disconnect keeps it as the person's standalone integration instead of refusing; unowned routing residue still refuses, and without a proof the whole missing-receipt refusal stands. Disconnect removes the proof with the other receipts. Effective Codex enable is committed under the existing connection/runtime/local-opt-out fences before injection reads its durable gate, including fresh Link enrollment over a standalone disabled flag. Compensation is armed before enable publication; failed enrollment, including a post-publication bookkeeping failure, restores the recorded standalone value (below) under the connecting-owner guard while retaining disjoint config changes; no-sync enrollment leaves the flag and native files alone. Once the connection commits, its route apply can no longer reach that compensation: lifecycle lock contention or a connection another operation already changed only prints the reconciliation warning, and the next sync applies route intent (tests/clients/route-intent-connect-post-commit.test.ts). Derived selectedClients records desired selection, not completed restoration: disconnect still drains a journal owned by the connected client after a failed off apply and refuses another key's journal, malformed or invalid ownership, and injected routing without a recovery journal. Connection transport and receipt helpers import only intent data at load time; device writers load on demand during apply or cleanup, keeping local provider dispatch outside the Child startup graph.
src/client/route-intent-native-originals.ts owns the standalone native originals record, <configDir>/link/native-intent-originals.json (0600, no secrets). A Child's link write through persistNativeRouteIntent (the materializer's native adapter while connected, the fresh-enrollment Codex pre-commit and the connected sync's Codex enable) passes recordStandalone; Home writes do not. Per field (clientIntegrations.codex, .grok, .claude-desktop, claudeCode.enabled, claudeCode.authModeMigratedAt, apiSurfaces.messages) it keeps the value before route intent first changed it, with absence as null, plus the last value this writer published and any pending value. The original is captured once and never overwritten, and the record is not bound to a link, so a crashed enrollment followed by a different link still restores the first standalone value. Under L then C, the original and pending value are written before the atomic config write and marked published after it; a no-op toggle records nothing. Disconnect (in its final locked block, before removing_token, for any transport) and failed enrollment restore a field only when its current value is the writer's published or pending value, deleting the key when the original was absent and pruning an emptied parent; any other value is the person's own edit and is kept. The record is removed only after the restored config is read back and verified, so an interrupted restore is retried by the next disconnect. A malformed or unsafe record refuses both capture and restore. ABA limit: a person who sets a field to exactly the published value cannot be distinguished from the writer, and that field is restored. The Child's local opt-out list (src/routing/route-to-local.ts, local-off.json) is the person's own choice and survives disconnect. Desktop first-party mode records nothing here: enable is already current and disable is a conflict before any native write. A failed enrollment whose restore throws keeps its service token and pending-connect marker, exactly as disconnect keeps the token until the restore succeeds, and names the record. On a disconnected machine with no unfinished disconnect receipt (absent or complete), plain ocx disconnect drains a lone record (drainLoneNativeIntentOriginals in src/client/connect.ts): it restores and removes the record under L then C, then removes the token only while the pending-connect marker proves it is the enrollment's own, and on failure refuses with the record path and the recovery step. A record left by a crashed enrollment is therefore restored by the next failed enrollment, disconnect, or plain disconnect on the disconnected machine; ocx disconnect --orphaned-token still does not touch it. Coverage: tests/clients/route-intent-native-originals.test.ts, tests/clients/route-intent-codex-transitions.test.ts, tests/clients/route-intent-connected-review.test.ts, tests/clients/route-intent-codex-connect-disabled.test.ts.
src/client/route-intent-file-originals.ts owns <configDir>/link/file-intent-originals.json (0600, no paths or secrets): the file clients, excluding Aside and the native clients, that had an ownership record at a link's first apply, with their modelNames, bound to that link id. applyConnectedRouteIntent captures it under L before any materialization and only while no link-bound route receipt exists, so a client a link created is never recorded; that receipt is read strictly, so a corrupt one refuses the sync here instead of reading as a first apply; a record naming another link is stale and is replaced. Disconnect's route cleanup (src/integrations/route-intent-disconnect.ts, the standalone set of cleanupRouteIntent) leaves recorded clients out of the off pass: one still owned, or classified other than absent (a block the person wrote), is kept; an absent one is applied again with the receipt's last served roster, which the next standalone start refreshes from the local catalog through refreshOwnedCatalogIntegrations; an uninstalled one is a no-op. A local opt-out takes the client out of the restore set. The final locked block removes the record beside the route receipt, and phase gating never reruns the cleanup. A malformed record refuses disconnect. Aside profiles keep the ordinary cleanup. Known limit: a Child whose link-bound receipt predates this record never captures one, so its disconnect removes link-owned file integrations without restoring the ones it had standalone; the user guide tells the operator to set those up again. Coverage: tests/clients/route-intent-file-originals.test.ts.
The Child parity report adds the optional paired fields appliedRouteIntentDigest and routeClientOutcomes; legacy reports remain accepted. Outcomes use the closed client/disposition schema and optional source: local for a Child opt-out. Only Aside slot summaries such as u0,u1 survive as wire targets, and an apply selecting no profile (--accounts none) carries no target; arbitrary paths and diagnostic messages remain local. Sender, receiver, in-memory store and CLI all apply that projection. src/client/link-parity-reporter.ts validates the current link's Home proof against its strict common receipt and current local opt-outs on each report tick. Missing or mismatched proof sends explicit null/empty withdrawal fields. HTTP 400/413/422 schema rejection retries the base report once, while later ticks keep probing extended support; authentication and transport failures do not trigger that fallback. ocx link status --json preserves these fields while accepting older runtimes that omit them.
Each outcome may carry intentDigest, SHA-256 of its canonical client intent and source, so one unavailable client does not make another successful client stale. Optional initialIntent carries only an enrollment boolean paired with initialRevision; Home status applies that display overlay only while the revision matches. Revision metadata invalidates enrollment choices but is excluded from the document digest, whose payload remains policy plus sources.
Coverage: tests/clients/route-intent.test.ts, tests/clients/route-intent-state.test.ts, tests/clients/route-intent-parity.test.ts, tests/clients/link-home-route-intent.test.ts, tests/clients/route-intent-reporter.test.ts, tests/server/link-route-intent.test.ts, tests/cli/cli-link-route-intent.test.ts, tests/clients/route-intent-watch-client.test.ts, tests/server/route-intent-watch-boundary.test.ts and tests/server/route-intent-watch-waiters.test.ts.
native/ocx-child/Cargo.toml and native/ocx-child/src/lib.rs build a standalone lightweight Rust binary (ocx-child) for disposable SSH devboxes, containers and VMs that do not run a persistent Bun OpenCodex install. Remote candidate discovery in src/link/ssh-argv.ts and src/link/remote-windows.ts tries ocx-child first when $OPENCODEX_HOME/child/state.json is present (preserving an active ocx-child enrollment even if full ocx is installed later), and otherwise tries every full ocx path before falling back to ocx-child in ~/.local/bin, ~/.cache/opencodex/bin (plus %LOCALAPPDATA%\opencodex\bin on Windows) and PATH, keeping Windows encoded PowerShell below POWERSHELL_ENCODED_MAX (8000).
When allowMissing: true is passed to POST /api/link/probe (with includeVersion: true) or POST /api/link/confirm-host, src/link/remote-plant.ts inspects the remote target triple (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64) and classifies runtimeKind as "full", "ephemeral", or "missing", while src/link/child-binary-source.ts resolves a non-symlink ocx-child artifact (OPENCODEX_CHILD_BIN_DIR, native-bin/ sidecars next to the sources or the executable, host-matching local Cargo release builds of native/ocx-child in a source checkout, or SHA256SUMS-verified $OPENCODEX_HOME/cache/ocx-child/<version>/, an operator's, and then $OPENCODEX_HOME/ocx-child-builds/<version>/, the Home's own fill; an empty OPENCODEX_HOME counts as unset). No package ships native-bin/ sidecars, so a packaged npm or desktop Home answers plantable: false with plantUnavailableReason: "artifact_not_found" until an operator provides a build or the cache is filled; src/link/child-binary-cache-fill.mjs (plain ESM, Node and Bun) fills it in the background when a Home or standalone runtime starts (src/server/index.ts, trackBackgroundWork("ocx-child-cache-fill"), never for a client runtime and never delaying listen), so an update by any installer is covered once the updated runtime starts. It reads the SHA256SUMS file a release places beside the packaged ocx-child sources (CHILD_RELEASE_DIGESTS_PATH, lines <sha256> ocx-child-<target>[.exe]) from the running package tree, which its installer verified, and the version from that tree's package.json. It fills only on evidence of an upgrade: ocx-child-builds/.versions/ holds one empty marker per version, <version>.filled when its fill completed and <version>.baseline when it ran and fetched nothing (a first start, or a package without digests, silently, so the first release that ships digests sees an upgrade); markers are only ever added, so the newest recorded version never goes down whichever fill writes last, and only a newer running version fetches. A first start with no record (a fresh install) records its version and fetches nothing, a restart of the same version fetches nothing, and a record or a cache directory for a newer version means a downgrade; a source checkout (detectInstallFromPath answers source: a developer's server or the test suite) and Windows, which has no mode bits to prove the cache private, never fill. It fetches each listed build from the GitHub release v<version> of lidge-jun/opencodex (32 MiB cap, 20 s per build) and publishes into $OPENCODEX_HOME/ocx-child-builds/<version>/ only builds whose SHA-256 equals that digest, plus a SHA256SUMS naming them (lines for targets the release does not list are left as they are: the fill manages only the builds its digests name). A listed build that cannot be verified loses its manifest line, and an empty manifest is removed, so nothing stays trusted under an older manifest; the fill deletes no build file (unlisted, the reader refuses it; only a manifest that would list nothing is removed), and the record is not advanced, so the next start retries. A digest file that is there but unreadable or unusable is not an absent one (invalid_digests, one warning): nothing is written and the record stays, so a repaired package still fills. Overlapping fills cannot lose a build: the manifest is rebuilt from the builds verified on disk when it is written, and a completed record whose manifest no longer covers the package's digests is repaired on the next start by re-listing the builds still verified on disk; a same-version start never downloads. ocx-child-builds/.fill.lock (linked whole from a private temp, naming its process, reclaimed once that process is gone or after an hour) keeps concurrent starts from downloading twice (busy); it is an optimisation, not the guarantee. ocx-child-builds, ocx-child-builds/.versions and the version directory must be real directories (created 0700, never symlinks) owned by this user and writable by nobody else, and every directory above them must be owned by this user or root and writable by nobody else unless sticky, so nobody else can substitute both a build and its digest line, and an existing build counts only when it passes the reader's file checks as well as the digest. Each file is written to an exclusive 0600 temp and renamed, and the target directory's identity is re-checked before and after every write, so a directory swapped in during a download receives nothing. A package without that digest file (every release so far) fetches nothing; on its first start it creates ocx-child-builds/ and .versions/ (0700) and writes its baseline marker, and a later start of the same version decides from the record alone, before taking the lock, and writes nothing. src/link/child-binary-cache-start.ts is the startup step: a client runtime does nothing; otherwise the fill runs its gates first (including the Home directory's own ancestor checks) and, just before it creates ocx-child-builds/, calls the step's ownership claim (claimChildBinaryCache). The manifest lists ocx-child-builds only once OpenCodex has created it: an absent directory is created there (exclusively, 0700) and then registered (registerOwnedConfigPath), and removed again if the registration does not succeed (only a directory that call created is removed), so ocx uninstall removes it and a listing names a directory OpenCodex made or adopted; a Home whose ownership metadata disappears between the check and the registration (an uninstall running) stops the fill (uninstall_in_progress). One race remains, and uninstall is not changed for it: an uninstall that runs between the claim's mkdir and its registration finds the new directory unlisted and reports partial ("unowned files remain", exit 1); the claim then removes that directory. A rerun of ocx uninstall ends removed when one of its own cleanup steps records state first, since a registration in the now empty Home starts ownership afresh; otherwise it answers "config ownership metadata is missing or invalid" until the user deletes that empty folder. The race is rare because uninstall first proves the proxy is down. An unlisted directory (configPathOwnership) is adopted only when it is empty, a real directory, owned by this user and writable by nobody else, which is what an interruption between creation and registration leaves; any other one was not created by OpenCodex, and the fill leaves it alone (foreign_directory). A Home that is still empty gets its ownership metadata first (initializeConfigOwnership). The operator's cache/ is never claimed, read by the fill or written, so uninstall keeps every file there, including files added after a fill. An uninstall in progress stops the start before the fill writes (uninstall_in_progress), as does a full or busy manifest (ownership_unavailable, retried at the next start); a directory the claim created for that start is removed again; a Home without ownership metadata fills as before, since its uninstall removes nothing. The step also passes the running code's own version (packageVersion()), so a package.json that differs, as during an in-place install, records nothing (version_mismatch). OCX_DISABLE_CHILD_CACHE_FILL=1 turns the fill off (disabled): nothing is read or written. A failed publish removes its temp file, counts come from what is verified on disk, a same-version repair reports nothing as failed, and a version directory removed by hand after a completed fill stays removed. Old version directories are not pruned yet (one directory of up to five builds per upgraded release); pruning, keeping one for a downgrade, comes with the release change that ships the digests. Coverage: tests/clients/link-child-binary-cache-fill.test.ts; the CLI, the remote_plant_unavailable message (plantUnavailableGuidance) and the #remote sheet name those locations. On a bare host (runtimeKind: "missing"), POST /api/link/apply requires explicit plant: true, streams the verified binary over SSH stdin into $HOME/.cache/opencodex/bin/ocx-child (0700 atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with sha256sum, shasum or openssl dgst, and with none of them the plant exits before installing or running anything and Home answers 502 remote_plant_failed with hint hash_tool_unavailable) or %LOCALAPPDATA%\opencodex\bin\ocx-child.exe (inside OcxJob on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via remotePurgePlantedChildArgv if enrollment fails. When the planted binary cannot run, that purge deletes it only if no recovery record remains ($OPENCODEX_HOME/child/state.json, codex-journal.json, attach-rollback.json, or $CODEX_HOME/.ocx-child-owner.json); otherwise it keeps the binary so detach can finish, names the record on stderr, and exits non-zero, and Home answers with residual: true and the detach --purge recovery step. A live apply that may have left the Child connected answers the same closed words a journalled reply carries, residualKind (plant when only the managed binary's purge removes it, else enrollment), plus residualInstall (ephemeral or full) for an enrollment, remotePlatform when the Child's platform is known, the attempt's linkId (fixed when apply starts, before any plant, so a plant whose rollback failed before the link was stored names it too), and a recovery line (liveResidual in src/server/management/link-enrollment.ts, built by src/link/residual-recovery.ts) whose every step is bound to that id with --expect-link: it acts only while the Child's records name that link or nothing at all and leaves a Child that another link, a hub or another Home owns. A build older than the flag refuses it before acting; then a full install falls back to a check of that id and a lightweight Child is left. Without an id nothing can be bound and the Child is left. The purge names both locations a Windows plant uses (%LOCALAPPDATA%\opencodex\bin\ocx-child.exe, or %USERPROFILE%\.cache\opencodex\bin\ocx-child.exe without LOCALAPPDATA); a journalled plant reply names no platform, so the page lists every managed location; the Remote Link page and ocx link add map those words to their own text and never show the server's.
When detached, ocx-child config show --json reports {"port": <listenPort>, "runtimeRole": "standalone", "ephemeralChild": true}, recognized by isDetachedEphemeralChildConfig in src/link/ephemeral-child.ts. During POST /api/link/apply, src/server/management/link-enrollment.ts accepts that clean detached state without a pre-running standalone daemon, maps foreign listeners and external ~/.codex/config.toml ownership (ocx-child-conflict:*) to 409 child_port_busy and 409 child_external_provider_conflict (502 child_preflight_failed on an SSH transport drop), persists childKind: "ephemeral" | "full" on LinkRecord (src/link/store.ts, projected on GET /api/link/status in src/link/status-projection.ts alongside gui-session homeCodexSettingsDigest), enrolls through connect --link --key-stdin, starts ocx-child serve via restart, and verifies role: "client" /healthz plus an authenticated /v1/catalog callback.
ocx-child serve binds 127.0.0.1:<listenPort>, enforces linkRouteAllowed and linkManagementRouteAllowed, rejects Upgrade (426 on /v1/responses WebSocket upgrade) and Content-Length + Transfer-Encoding smuggling, strips caller credentials, authenticates each upstream socket with link-relay-auth v2 before sending the single bound request, and posts OCX-6 LinkChildParityReport updates. Child state and a 0600 two-tier undo journal live under $OPENCODEX_HOME/child/: ocx-child sync --expect-link <id> --json refreshes opencodex-catalog.json and models_cache.json, rolls over serve when startedAt < appliedAt, and emits ocx.sync.fanout/1 JSON (or exit 5 child_link_mismatch JSON on stderr); ocx-child detach / disconnect stops the owned daemon, restores pre-attach bytes or surgically strips journaled values across #1798 comment-stripping rewrites (failing closed with exit 4 on conflicts), leaves zero background daemon, removes the emptied child state directory (including the released daemon start gate), and with --purge (DELETE /api/link/:id { "purge": true } or ocx link revoke --purge) also removes the binary when it resides inside the managed plant directory (~/.cache/opencodex/bin/ocx-child or %LOCALAPPDATA%\opencodex\bin\ocx-child.exe, refusing symlinks and foreign paths). Home runs that purge only through the managed planted binary (purgeDisconnectRemote and remoteManagedPurgeDisconnectArgv in src/link/remote-plant.ts, never the generic candidate resolver, which could pick a ~/.local/bin copy that cannot purge itself) with --json, and answers 502 remote_disconnect_failed while keeping the link record when the managed binary is missing (hint managed_child_missing) or the report lacks "purged": true (hint purge_incomplete). Both revokes name the link: an ordinary revoke runs disconnect --expect-link <id> (a full ocx checks it as ocx sync --expect-link does, under its client locks before anything is written; ocx-child's detach checks its state and ownership record), and --purge runs the managed binary with --expect-link <id>. A Child whose refusal the Home recognises (exit 5: another link, a hub, no connection, a client-initiated link, a pending operation or unreadable state; exit 6: an ocx-child residual) changed nothing, so the Home answers 502 remote_disconnect_failed with hint child_link_mismatch or child_residual and keeps the link, and restarts the tunnel unless the revoke resumes an earlier attempt's retirement (then the tunnel stays down and a journalled revoke settles unknown); a revoke delayed past a re-enrollment therefore cannot disconnect the newer link. A Child older than the flag rejects it as a usage error (exit 2) before acting on the link (an older full ocx still runs its root preflight, including the Codex-shim auto-restore, first). The wrappers never exit 2 themselves: the POSIX scripts exit 125 or 127 (ordinary) or 125 or 1 (managed purge), a failed exec 126 or 127, and the Windows wrapper 1, 126 or 127. The Home then reruns the command without it, unbound, as before, but only while this link still exists and no other Home-initiated link may reach the same machine: one with the same pinned host key (the machine enrolled under another alias or address) or one naming this alias as its alias or among its routes (effectiveLinkRoutes), re-read just before the retry. Enrollment refuses a second link with the same primary alias, so the key and the routes are what can find it; otherwise it answers hint expect_link_unsupported with nothing changed. Between that re-read and the retry an older Child keeps the old race, and the guard cannot see the machine enrolled with another Home since, or under a new host key, or joined to this Home by itself (it counts only Home-initiated links; a client-initiated record has no host key): revoking a stale link to an older Child that now belongs to another Home still disconnects that Home's link, as every revoke did before. A full ocx Child with no connection and nothing pending refuses (disconnected, hint child_link_mismatch) and the Home keeps the link: answering success there could let the Home remove a link whose enrollment wrote its record before sending the connect, which would then attach that link with no record and no tombstone. So after a full ocx disconnect that finished but whose answer was lost, or one another link's bound call finished, a bound retry keeps the link, where dev's plain retry succeeded; --force removes it, and is safe only once any enrollment of this link has finished: before that, a full Child that shows no connection may be this link's own enrollment between its record and its connect, and --force then leaves that enrollment a residual Child with no record and, for a full Child, no tombstone (the enrollment reports the residual). An ocx-child keeps the race the full-ocx refusal closes: its bound detach with nothing attached succeeds (NotAttached, exit 0), so an ordinary revoke can commit while a Home-initiated enrollment of a lightweight Child has written its record but not yet attached, and that attach then lands with no record and no tombstone. This is unchanged from before this change and is a known limit. A bound exit 5 whose reason is disconnect_pending (an unfinished disconnect the Child could not bind to this link) counts as cleanup attempted (hint child_disconnect_pending), never as failed_clean. On Windows the managed purge wrapper sweeps its old temp files after the command even on a refusal; nothing else is written. A record without childKind (enrolled before it existed) is inspected first; a full install answers 409 purge_unsupported. While attached, every slug of the injected Home catalog is Child-owned in models_cache.json, whatever that entry's current content, because Codex rewrites the cache and may re-serialize it: when the cache changed outside the Child, sync writes the Home version of each catalog slug and keeps entries with any other slug, and detach puts back the pre-attach version of each injected slug, or drops it when the pre-attach cache lacked it. The private catalog backup that tells them which slugs were injected names both the previous and the new catalog from before the cache write until after it, so an interrupted sync cannot leave previous Home entries looking like local ones. Shared golden vectors in tests/fixtures/link/ocx-child-conformance.json lock TypeScript and Rust parity.
CODEX_HOME ownership (native/ocx-child/src/codex_home.rs): the lifecycle lock is $OPENCODEX_HOME/child.lock (outside child/, so it keeps one inode while detach removes that directory) and the journal lives under $OPENCODEX_HOME/child/, but config.toml, opencodex-catalog.json and models_cache.json live in CODEX_HOME, which two ocx-child installs with different OPENCODEX_HOMEs can share. Every lifecycle command that writes those files (attach/connect, sync, restart, detach) therefore also takes a fixed lock file .ocx-child.lock inside the canonical CODEX_HOME (never deleted or replaced, opened like every lock file without following a symlink or reparse point, and on Windows refused unless an existing one already carries the owner-only DACL, locked with the same flock/LockFileEx as the local lock) and checks a durable ownership record .ocx-child-owner.json there (schema ocx.child.codex-home-owner/2: random owner id, canonical OPENCODEX_HOME, directory identities of both homes, phase attaching/attached/detaching/cleanup-complete, and linkId, the link the attach was for; attach writes it through CodexHomeGuard::acquire_for_attach while holding both locks, in the attaching phase, before any CODEX_HOME write, and a refused attach puts back the exact record it replaced. /1 records (no linkId, written by an older ocx-child or by the Continue adoption of a pre-ownership attachment) are still read; a /2 record without a valid linkId, or a /1 record with one, is unreadable; an older ocx-child refuses a /2 record as an unsupported schema; no credentials; the owner id is mirrored in $OPENCODEX_HOME/child/codex-home-owner-id). Lock order everywhere is local lifecycle lock, then the CODEX_HOME lock, then the daemon start gate. Taking a lock never changes the permissions of a directory ocx-child did not create: FileLock (native/ocx-child/src/platform.rs) creates a missing parent private (0700, or a protected owner-only DACL on Windows) and leaves an existing OPENCODEX_HOME, which a full OpenCodex install may share, or CODEX_HOME exactly as it is; the lock files themselves are created owner-only (0600, or an owner-only DACL on the file alone). Coverage: native/ocx-child/tests/relay_and_lifecycle.rs (pre-existing 0755 homes across attach, sync, and detach) and the lock_parent_tests unit tests in platform.rs. attach takes it before the rollback snapshot and holds it through rollback or commit; sync from before journal application through the state write; detach through restore, cleanup, and release, which durably writes cleanup-complete before removing the record so a crash at any step leaves a record only the owning home's detach resumes. Ownership is never taken over by PID or timeout: a record owned by another OPENCODEX_HOME, an unreadable or corrupt record, or a lock I/O error refuses without mutation and names the recovery step. journal::apply_attach_or_sync and offline_detach take the CodexHomeGuard as a parameter, so they cannot run without the lock. The guarantee is scoped to ocx-child against ocx-child; a full OpenCodex install sharing the homes is still refused by assert_no_full_ocx_conflict, which does not take this lock. Coverage: native/ocx-child/tests/lifecycle_guards.rs (two real processes, two OPENCODEX_HOMEs, one CODEX_HOME).
Restoration (immediate attach rollback, crash recovery of an incomplete attach during detach, and offline detach) runs every write, rename, and removal through CodexHomeGuard::restore_step, which re-verifies the captured directory identities of both homes immediately before and immediately after the system call, under the CODEX_HOME lock. On a mismatch restoration stops at once, keeps the rollback snapshot, journal, key, and ownership record, and reports a recovery error; the rollback snapshot and journal are removed only by a step whose own checks passed. The window between an identity check and the system call it guards is not closed: anyone who can replace the home or a parent directory can still swap the home in that instant. Doing so gives them nothing they lack already, because the same access lets them replace those files directly; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against a writer who controls that directory tree. Coverage: native/ocx-child/tests/lifecycle_guards.rs (a test barrier between verification and the write while the home is replaced by a directory of sentinel files).
Recover (lane O5 of the disposable-machines design; check_expected_link in native/ocx-child/src/cli.rs, src/link/tombstones.ts, src/link/link-recover.ts, src/server/management/link-recover-route.ts, src/cli/link-recover.ts): ocx-child detach --expect-link <id> takes the lifecycle lock and the CODEX_HOME lock in the usual order, then restores only when state.json and the ownership record both name <id> (or when no state, record, journal, rollback snapshot, key or catalog backup is left: outcome NotAttached, which the Home trusts as the link's own restore whether or not the planted binary stays, the binary then being judged by the purge rules below), and holds both locks from that check through the restore, so no attach or sync runs in between; its JSON report then carries linkId. The check also reads the link an unresolved attach rollback would put back: when state, owner record and rollback name more than one link, an interrupted attach (phase attaching, or a rollback left) answers residual for the links involved and conflict for any other, so recovery never restores and detaches a link it was not asked for. --purge removes the planted binary before the lifecycle lock is released (plain detach --purge too), so an attach waiting on that lock starts only after the binary is gone. A /1 record, a record naming the link without state.json outside an interrupted attach or detach of that link (both are finished instead, as a plain detach would: an attach of that link interrupted before it wrote the state, record in phase attaching with no rollback or one naming no link or this link, since a rollback naming another link answers residual as below; and a detach of that link that stopped after it marked the record, record in phase detaching with no attach rollback or one naming no link or this link, as a detach leaves that stopped while it restored that rollback), an interrupted attach between two links, state.json without a record, or a missing CODEX_HOME with records left answers residual (exit 6); state.json or a record naming another link, an unreadable record or state, or a record owned by another OPENCODEX_HOME answers conflict (exit 5); with --json both print {restored:false,outcome,linkId,record,path,reason,purged:false}, and neither changes or purges anything. On the Home, DELETE /api/link/:id {force:true} of a Home-initiated link with childKind: "ephemeral" first writes a tombstone to <configDir>/link/tombstones.json (or keeps the one already there for that link, which another attempt wrote, a forced revoke in flight in another runtime or one that died, and then owns none it could drop: replacing it would let a failed attempt drop the only tombstone of a revoke that then commits; and since the attempt that wrote the kept one may itself fail and drop it, the commit step checks, under the lock that removes the record and before the key is revoked, that a tombstone for the link is there, writes this attempt's own (which it then owns) when it is not, and when it cannot answers 503 tombstone_write_failed, or 409 tombstone_limit when the cap refuses it, before this attempt revoked a key or removed a record) (0600, strict schema, under the config mutation lock: link id, alias, pinned fingerprint, platform, route list or explicit route, revokedAt; no key), before its first journal intent (key_revoked) and before the tunnel stops, and refuses the revoke with 503 tombstone_write_failed when it cannot, including when that file is damaged, which is never read as empty. POST /api/link/recover {linkId} (ocx link recover --link-id) refuses a link that still exists (409 link_present), refuses under the alias lock while any Home-initiated link names the tombstone's alias as its alias or one of its routes (effectiveLinkRoutes), without regard to case (409 alias_linked, before any SSH: that machine may belong to a re-enrollment, or to a revoke whose disconnect ran without committing, journalled or not), checks that the link known_hosts still pins the tombstone's fingerprint for its alias (409 host_key_changed, before any SSH), runs the managed planted binary's disconnect --purge --json --expect-link <id> over that alias alone, serialized per alias with enrollment, and removes the tombstone only on exit 0 with a report naming that link with restored: true and purged: true. Before its purge, detach --expect-link writes a link-bound restore receipt to $OPENCODEX_HOME/link-receipts/<id>.json (ocx.child.detach-receipt/1: link id, restored, outcome; 0600, no credential), which outlives the removed child directory; a plain detach writes none, and an attach of the same link removes it under the lifecycle lock before it changes anything, so an old receipt never vouches for a newer attachment. When the managed binary is already gone (an answer lost after the purge, a crash after it, or a tombstone removal that failed), recover reads that receipt read-only (remoteRecoverReceiptArgv; the link id travels as data, a positional sh argument on POSIX and the $d.args data line on Windows, as does --expect-link in remoteManagedPurgeDisconnectArgv, never spliced into script source) and settles only on one naming the link with restored true while the planted binary is confirmed absent (the same read exits non-zero when a file of that name exists, executable or not, or when any directory from $HOME down to it cannot be searched, since a test for a path cannot tell absence from an unreadable ancestor; on Windows only ItemNotFoundException from Get-Item counts as absent); the missing binary is the purge's own evidence, and absence of files alone proves nothing. The check also refuses, before it changes anything, a rollback snapshot that recovery itself would refuse (parsed and home-checked as AttachRollbackSnapshot::recover does). The planted binary is shared by every ocx-child home of one OS user, so every relay serving from it holds .ocx-child-in-use.lock beside it shared for its lifetime (hold_managed_plant_in_use, and serve refuses while a purge holds it), and purge_managed_executable takes that lock exclusively without waiting and keeps the binary (purged: false, outcome InUse) while another home's relay runs from it; this applies to every --purge. The lock file is never unlinked (a relay could otherwise lock a fresh file of the same name while the binary goes), so the plant directory keeps that empty file. The enrollment rollback purge (remotePurgePlantedChildArgv) leaves a binary whose detach --purge --json answered purged: false (POSIX) or that still exists after a successful purge (Windows). An attach of another home that has not started its relay yet is not covered. Likewise, in another runtime an apply or ordinary revoke that started before a forced revoke wrote the alias's first tombstone runs without the alias lock and can overlap recover (whether apply takes the lock is decided before it exists, and its early duplicate check is case-sensitive); --expect-link and the Child's lifecycle lock still prevent a wrong-link restore, so the cost is a failed enrollment or recover. A Child that restored the link but kept the planted binary because another home's relay runs from it (purged: false with ocx-purge-kept:binary_in_use) settles the tombstone: the link's own state is clean and no repeat would remove the binary. Other failures answer 502 remote_recover_failed with a fixed hint (ssh_failed, child_failed, platform_unknown, managed_child_missing, receipt_unbound, purge_incomplete), never the machine's stderr. Residual and conflict answer 409 child_residual / 409 child_conflict with the record name as hint and never echo the Child's free text or paths; an ocx-child that predates the flag (exit 2) answers 409 child_recover_unsupported. {operationId} resolves through src/link/operation-recovery-slot.ts, which the link operation journal (lane O1) registers; until it does, the answer is 409 operation_journal_unavailable. The tombstone also keeps the link's explicit route and exclusive flag ("Explicit route" above; an explicit route requires exclusive, as on the record), and recover passes that route's ProxyCommand, user and port on the ssh command line; without one it uses the alias. Either way the pinned key is required and no sibling route is tried. ocx capabilities --contracts lists link.recover/1. Coverage: native/ocx-child/tests/owner_link.rs, tests/server/link-recover.test.ts, tests/cli/cli-link-recover.test.ts.
Recover's limits and the operator's way out: at most MAX_TOMBSTONES (256) tombstones wait, and a forced revoke beyond that answers 409 tombstone_limit and keeps the link; a tombstone is never dropped to make room. GET /api/link/recover (ocx link recover --list) lists link id, alias, revokedAt and platform, never the fingerprint or route. ocx link recover --forget --link-id <id> --yes drops a tombstone without contacting the machine, for one that is gone for good (a destroyed disposable VM). It has no management API, so an admin-token caller cannot reach it: the CLI removes the tombstone on the Home under the config mutation lock, refuses while the link still exists (a forced revoke that wrote its tombstone but has not removed the link yet cannot lose it, since that removal takes the same lock), and refuses without --yes and whenever isAgentDriven() (src/cli/agent-driven.ts) sees an agent or CI runner. The tombstone schema stays strict, so a later Home adds fields under ext, a JSON object of at most 4 KiB that this Home keeps verbatim on rewrite and never reads; a change older Homes must not ignore bumps the store version instead. A forced revoke whose tombstone write throws after writing the file (its chmod, or the lock's COMMIT), or whose first journal intent, key revoke, record-removal intent or record removal fails, or that throws before its commit (the tunnel hold, stopLink), removes the tombstone it wrote, only that exact tombstone and only while the link still exists. That removal is best effort: it takes the config mutation lock with busy_timeout = 0, so when another process holds the lock it fails silently and the tombstone stays beside the live link. So does one whose owner died before it could drop it. Such a tombstone stays until the link is removed (a later forced revoke keeps it, recordTombstone never replaces one), recover refuses it as link_present meanwhile, it counts toward MAX_TOMBSTONES, and its routes may be stale, which is harmless since recover reads only its alias, explicit route and pinned key. A tombstone another process's committed forced revoke wrote stays (the link stays, and recover refuses it as link_present), and no revoke removes a tombstone it did not write, bound or not: beside a listed link a tombstone belongs to a forced revoke still in flight in another process (or one that died), whose Child an enrollment may still attach, so removing it could lose the only recovery evidence. Recover settles it once the link is gone (the cleaned Child answers NotAttached, or a purged one leaves its receipt). After an unbound cleanup of an older Child it cannot: an older ocx-child purges without a link-bound receipt (recover answers managed_child_missing) and one that stays answers child_recover_unsupported, so a person checks the machine and runs ocx link recover --forget; until then the tombstone counts toward MAX_TOMBSTONES. Recover runs under O1's cross-process alias lock (underAliasLock, src/link/alias-lock.ts, always required for recover) and the per-alias operation set within one runtime (serialized), both keyed without case as OpenSSH and apply's duplicate check read the alias, so it never overlaps an enrollment or revoke of that machine: apply and revoke take that lock not only with an operation id or a journal but also whenever a tombstone names the alias (tombstoneNamesAlias, compared without case; a damaged tombstone file counts as naming it), so a Home without a journal gets no new lock until it has a tombstone. A --purge that keeps the binary because another home's relay runs from it prints ocx-purge-kept:binary_in_use on stderr, and for a --purge revoke the Home answers remote_disconnect_failed with hint binary_in_use and keeps the link; an older Child without that line still answers purge_incomplete. Recover never answers binary_in_use: a report naming the link with restored: true and purged: false plus that line settles the tombstone. hold_managed_plant_in_use decides "managed" from the path, takes the shared lock, then requires the binary to still exist (on Linux an unlinked executable's /proc/self/exe name ends in (deleted)), so a relay spawned just before a purge refuses instead of serving without the lock. An attach rollback snapshot that holds earlier files (state bytes, journal, key or catalog backup) without a readable prior state answers conflict, because attach reads an unreadable state.json as no prior state. An enrollment whose attach was killed before it wrote state.json leaves the ownership record naming its link in phase attaching, with no attach rollback or one that names no link or that link; the bound ocx-child detach --expect-link <id> for that link finishes it like a plain detach (own_interrupted), and so does a later bound detach when an earlier one stopped after marking the record detaching, even while it restored that rollback; ocx link recover of such a link settles clean. child_residual remains for an ownership record that names no link (schema /1); state.json missing while the record names this link in phase attached or cleanup complete; state.json naming the link with no ownership record; a child recovery record left without either; and an interrupted attach that replaced another link's attachment, where a plain detach would restore and then remove that other link. Compatibility: an ocx-child older than the /2 record parses the ownership record with deny_unknown_fields and refuses it (ownership record ... is unreadable (unknown field linkId)) for every lifecycle command, so a downgraded Home planting an older build, or an older second Home enrolling the same machine, strands the attachment until a current binary is planted. Recover, like DELETE, probe and apply, is not refused on a Child runtime.
Daemon start commit (native/ocx-child/src/daemon.rs, relay.rs): attach, restart, sync rollover, and rollback's restart of the prior relay all start serve --foreground --lifecycle-start through start_or_rollover_daemon. It records a fresh random daemonGeneration in state.json before spawning and passes ocx-child-start/1 <generation> <shutdown secret> on the child's stdin, which stays open. Inside the daemon start gate the child requires state.daemonGeneration to equal its generation, and until it reads commit <generation> on stdin it answers only /healthz (reporting generationCommitted: false), returning 503 child_starting for every other route and posting no parity reports. EOF, a read error, or any other line before the commit makes it exit without serving, so a lifecycle parent that dies after handing over the secret leaves no relay behind and no recovery record is touched. The parent writes the commit line only after the state naming the generation and PID is durable and, for attach, after the ownership phase is attached and the rollback snapshot is removed, and for rollback's restart of the prior relay, after the rollback snapshot is removed (a removal that fails leaves that relay uncommitted, and it exits); it then waits for /healthz to report generationCommitted: true. An operator-run ocx-child serve --foreground (no --lifecycle-start) serves immediately and is refused while an attach rollback is pending. Coverage: native/ocx-child/tests/lifecycle_guards.rs (parent killed at a barrier before the commit; stale and superseded generations).
Authenticated shutdown is bounded by stop_owned_daemon's 3-second wait; open link WebSocket sockets take at most 1 s of it (each 1001 close gets min(close bound, 1 s) before the reaper shuts the socket down, however full its peer's buffer). One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a CancelSlot; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. serve exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only /healthz and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: native/ocx-child/tests/lifecycle_guards.rs (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). An ocx-child never reads /v1/link-management/route-intent and sends only the base parity report, so route intent does not apply to it: enrollment seeds no route intent for it (seedRouteDeviceClients is skipped), homeHasLinks does not count it, a route write naming it and a single-device POST /api/route-to/sync answer 409 route_intent_unsupported (reason: ephemeral-child-no-route-intent) before any state change, --device all reports its sync row unsupported without running anything, and its status row is unsupportedRouteToDevice (no client rows, routeIntent: "unsupported") in src/routing/route-to-status.ts (tests/server/route-to-ephemeral.test.ts). Coverage: tests/clients/link-ocx-child-conformance.test.ts, tests/clients/link-ephemeral-child-discovery.test.ts, tests/clients/link-child-plant-foundations.test.ts, tests/server/link-enrollment-ephemeral-child.test.ts, tests/clients/link-ocx-child-e2e.test.ts, gui/tests/remote-link.test.tsx.
Foreground startup requires an attached state, matching recovery journal, verified link key and matching listener port. Sync, restart and detach refuse a journal bound to different canonical homes; detach checks this before changing state or stopping the relay. Shutdown authenticates both ends on one socket without sending the private secret and never signals a process based only on a reported PID. Attach keeps a private rollback snapshot until success or completed recovery; detach can retry an incomplete rollback. Failed restore or cleanup keeps recovery records for retry. On Windows, temporary replacements are created with a protected owner-only DACL before bytes are written.
Link WebSocket in ocx-child. native/ocx-child/src/link_ws.rs, link_ws_advert.rs and link_ws_frame.rs carry Codex's Responses WebSocket over the link the way the TypeScript Child does, behind ocx-child's own release gate: link_ws_advert::gate_open is false in every build without the link-ws-test-gate Cargo feature, whatever its profile, and only a build with it reads OCX_CHILD_TEST_LINK_WS_READY; build:ocx-child (cargo build --release --locked) passes no features, and test:ocx-child runs the crate once without the feature (a unit test proves the gate stays closed with the variable set) and once with it (the link_websocket integration target, required-features), checked by tests/clients/link-ocx-child-ws-gate.test.ts, which also makes the run without the feature in CI, and tests/clients/link-ocx-child-ws-feature.test.ts, which makes the run with it; each runs alone (SERIAL_FULL_SUITE_FILES), and both Cargo runs are skipped on Windows (ocxChildLinkWsCargoSkipped in tests/helpers/ocx-child-cargo.ts), where the link_websocket target did not finish inside its bound on the hosted runner; the gate is a compile-time cfg, closed in every build without the feature, and the conformance Cargo run still builds and runs ocx-child on Windows. While the gate is closed, or the Home's verified on has not been applied for the current link, the /v1/responses upgrade keeps its 426 body byte for byte and nothing reaches the tunnel. Every relay-auth preflight of the serving process (relayed HTTP requests, the parity report, the /readyz probe) takes a number before it is written, and its answer's x-opencodex-link-ws-state/-proof (exactly one of each, the proof an HMAC under the 32 fingerprint bytes, equal to the Home's linkWebsocketStateProof, pinned for both by the vectors in tests/fixtures/link/link-ws-state-proof.json) applies only to its own link id's entry and only above the last number applied for it; at most 8 links with per-link watermarks; lifecycle commands run in their own process and teach nothing. A tried upgrade needs no Origin, a valid Codex handshake (GET /v1/responses without a query, Upgrade, Connection, version 13, a 16-byte key, no body framing), a slot (8 open or setting up, 4 setting up, 32 MiB of 256 MiB), then one preflight and the upgrade on one connection inside one 8 s deadline, cancellable by stop, proceeding only on that answer's own verified on; the Home leg's headers are Codex's minus its handshake, the hop-by-hop and Connection-nominated set, Origin, Host, framing, caller credentials and the relay's internal headers, with the link key added through the wiped request-head pattern; a 101 counts only with Upgrade, Connection, the right accept value (SHA-1 written in the module for this one use) and no extension or subprotocol. Every failure is the same 426; ocx-child has no tunnel supervisor, so it has no 503 of its own (the existing child_busy and child_starting 503s apply to upgrades as to HTTP). ocx-child writes the local 101 itself, with x-codex-websocket-max-message-bytes: 8388608, under Codex's write lock inside a control operation deadline; no frame is ever written to Codex before it, and a stop that claimed the socket first leaves the 101 unwritten; the worker then answers today's 426 as best effort, since the stop also shuts Codex's connection down and Codex may see it close with nothing written instead. Every setup write (preflight and upgrade head) gives each write call only the time left before the 8 s deadline (relay::write_all_before), so a Home that drains slowly cannot stretch setup. One frame reader serves both roles with the TypeScript Child's bounds (masked frames from Codex, unmasked from the Home, 8 MiB and 4 MiB + 64 KiB, at most the limit plus a 14-byte header held). Each socket has two threads, the worker reading Codex and one spawned thread (256 KiB stack) reading the Home, each writing whole messages to the other leg under that leg's write lock; every frame write is one operation with an absolute deadline (data 600 s, control 30 s) registered before the lock, plus a 300 s no-progress write timeout, and a single reaper thread enforces every deadline by shutting both sockets down, never taking a write lock. One idempotent terminal transition closes both legs with a 5 s bound (close frames best effort); Codex's close without a status reaches the Home as an empty close frame, Codex's drop ends the Home connection without one, a Home close is relayed (1005, 1006 and 1015 as 1011), a binary message closes both legs with 1003. A stop (serve shutdown) aborts setups, closes every socket with 1001 (best effort, at most 1 s per socket, all at once) and waits until every slot is free; registration is refused once stop began, and the worker's start step checks stopping under the registry lock and the socket's state lock. A socket's slot and reservation are a lease released only when nothing can use its descriptors. A panic in either of a socket's threads still ends both connections, unregisters the socket and frees its slot, and a relay worker or control thread that panics still returns its admission (drop guards; the crate builds with unwinding). ocx-child has no Child-local opt-out, counters, diagnostics or byte book for the link WebSocket, as it has none for HTTP. Two limits hold today: a frame write registers its absolute deadline before it takes the leg's write lock, so a pong (30 s) that waits behind a long data write on the same leg can reach its deadline and have the socket cut while the data write is healthy (the Home's link socket sends no pings, idleTimeout: 0; a ping from Codex is answered on Codex's leg and meets the same limit); and every frame write registers and clears a reaper deadline, which wakes the reaper twice per frame. Coverage: unit tests in link_ws_frame.rs, link_ws_advert.rs and link_ws.rs (an in-process mock Home and Codex on loopback sockets), and native/ocx-child/tests/link_websocket.rs (the real binary: today's 426 without the gate and no connection to the Home; frames both ways with it; a failed preflight teaching nothing and a later one teaching on with the parity loop stopped; a Home that switches off answered by the upgrade's own preflight with 426, the turn then completing on HTTP; the /readyz probe teaching on; the authenticated shutdown closing both legs with 1001).