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. 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.
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.
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.
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.
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, and no upgrade is ever relayed. 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.
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.
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.
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.
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 1 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.
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 empty OPENCODEX_HOME counts as unset). No package ships native-bin/ sidecars and nothing populates the cache, so a packaged npm or desktop Home answers plantable: false with plantUnavailableReason: "artifact_not_found" until an operator provides a build; 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.
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). 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/1: random owner id, canonical OPENCODEX_HOME, directory identities of both homes, phase attaching/attached/detaching/cleanup-complete; 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).
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. 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). 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.