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. 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, a quoted POSIX sh -c script. 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. Windows/MSYS/Cygwin detection exits 125 (remote_platform_unsupported); exhaustion exits 127 (remote_ocx_missing). A Windows SSH shell without POSIX sh is unsupported as well, though it may fail before this detection runs. Coverage: tests/clients/link-ssh-argv-discovery.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. 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/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 retried after 60 seconds and auth failures after five minutes, so no more than 12 auth attempts reach the peer's sshd in an hour; a host-key failure is never retried. The Child's own tunnel runs with it, and src/link/supervisor.ts runs the Home's -R tunnels with HOME_TUNNEL_RETRY_POLICY, the same policy. 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 retryAt, so a long outage keeps the one-a-minute cadence instead of restarting fast backoff. 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/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 skips Hub-state requests and cached Hub facts on link transport; 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/candidates, POST /api/link/probe, POST /api/link/confirm-host, POST /api/link/apply, POST /api/link/{id}/reconnect and DELETE /api/link/{id}) 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.
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). 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. 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.
The Child relay requires a positive connected() verdict from src/client/link-tunnel.ts before every fetch. 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. 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. 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.
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-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.
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. 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.
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); 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.
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.
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.
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/stderr cap at 64 KiB; only the catalog command permits 4 MiB stdout. Coverage: tests/clients/link-ssh-runner.test.ts.
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 preserves Hub state when Home is a Hub 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. A bounded 404 body with error.code: hub_state_not_a_hub is role absence, not 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, 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.
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/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).