From 05992422d289bf04fad3328b5083f229a820bf94 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:17:54 -0700 Subject: [PATCH] docs(ssh): add the versioned-install-dirs reference cited by six relay files docs/ssh-relay-versioned-install-dirs.md is cited as the design reference by ssh-relay-versioned-install.ts, ssh-relay-version-mismatch-error.ts, ssh-relay-deploy.ts and three tests, but never existed. docs/** is gitignored behind a per-file allowlist, so tracking it also needs a .gitignore entry. Covers the content-hashed install dir, why the endpoint lives inside it (GC reads liveness from in there), the four points that make mixed client/relay versions impossible, the orphaned-PTY cost of that, the socket-unlink hazard on the relaunch path (PR #17821 pending), the contrast with the terminal daemon's semantic protocol version, and why parseHandshakeMessage forbids new handshake types. --- .gitignore | 2 + docs/ssh-relay-versioned-install-dirs.md | 284 +++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 docs/ssh-relay-versioned-install-dirs.md diff --git a/.gitignore b/.gitignore index e3fd07e45d1..7e2feb2fea5 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,8 @@ docs/** !docs/STYLEGUIDE.md !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md +# Cited as the design reference by six files under src/main/ssh and src/relay. +!docs/ssh-relay-versioned-install-dirs.md !docs/reference/ !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md diff --git a/docs/ssh-relay-versioned-install-dirs.md b/docs/ssh-relay-versioned-install-dirs.md new file mode 100644 index 00000000000..cdc54ec8e25 --- /dev/null +++ b/docs/ssh-relay-versioned-install-dirs.md @@ -0,0 +1,284 @@ +# SSH relay versioned install directories + +Every relay build installs into its own immutable directory on the remote host, named after a +hash of the bundle's own bytes. That one decision determines where the relay's socket lives, +what garbage collection is allowed to delete, whether a client may talk to a relay it did not +deploy, and what happens to remote work when Orca updates. This page is the reference for all +of it. Six source files cite it; keep them in sync. + +Companion pages: [`reference/ssh-execution-boundary.md`](./reference/ssh-execution-boundary.md) +(who owns execution state, and the `live` / `unverifiable` / `exited` vocabulary) and +[`reference/remote-wire-compatibility.md`](./reference/remote-wire-compatibility.md) (rules for +changing anything two peers exchange). + +## The scheme + +The remote layout is: + +``` +~/.orca-remote/ RELAY_REMOTE_DIR (relay-protocol.ts:31) + relay-0.1.0+0a5fe134d020/ one immutable install per bundle + relay.js, relay-watcher.js, … RELAY_ARTIFACTS (relay-artifacts.ts:45-58) + .version "0.1.0+0a5fe134d020" + .install-complete written last; absence = torn install + relay-.sock the endpoint + relay-<…>.sock.credential + agent-hooks// + orcad-/ permanent sibling, different owner +``` + +`computeRemoteRelayDir` (`src/main/ssh/ssh-relay-versioned-install.ts:75`) builds the path from +two validated segments — `.orca-remote` and `relay-` — +via `remoteInstallDirSegments` (`ssh-relay-install-namespace.ts:47-64`). Both the shell builder +and the SFTP-relative builder go through that one function so the two namespaces cannot drift, +and every segment is checked for path and CR/LF metacharacters because it is interpolated into +remote shell, `awk`, and PowerShell. + +`fullVersion` is `${RELAY_VERSION}+${contentHash}`. `RELAY_VERSION` is the literal `'0.1.0'` +(`src/main/ssh/relay-protocol.ts:28`, mirrored at `src/relay/protocol.ts:27`). `contentHash` is +the first 12 hex characters of a SHA-256 over every declared artifact, concatenated in +`RELAY_ARTIFACTS` order (`config/scripts/build-relay.mjs:199-217`); optional artifacts are +hashed only when the build emitted them, so a relay carrying the Windows process-table addon and +one that falls back to the PowerShell scan never share a directory. The hash inputs are exactly +the names declared in `RELAY_ARTIFACTS`, and `.version` is written afterwards, so it is never an +input to itself (`build-relay.mjs:235`, `src/shared/relay-artifacts.ts:60-61`). + +**Why a content hash and not a semantic version.** The build comment states it directly +(`build-relay.mjs:195-197`): the deploy check must detect code changes even when `RELAY_VERSION` +has not been bumped, and hashing the whole artifact manifest means a companion-only change (a +watcher fix, a hook runtime fix) still selects a fresh directory. In practice `RELAY_VERSION` +has never moved, so the hash is the only thing that distinguishes builds. + +What that buys: + +- **Immutability.** A directory name implies its bytes. The header of + `ssh-relay-versioned-install.ts:1-3` gives the motivating failure: an in-memory daemon serving + new clients off on-disk code that was overwritten underneath it. Modelled on VS Code's + `~/.vscode-server/bin//`. +- **Cheap idempotent deploys.** `isRelayAlreadyInstalled` (`ssh-relay-versioned-install.ts:99`) + probes for the required artifacts plus the `.install-complete` sentinel; a match skips the + upload entirely, and any missing artifact forces a full re-deploy rather than a patch. +- **One identity for three purposes.** The same string is the directory name, the wire handshake + version, and the PTY-grant build id — see [Four enforcement points](#four-enforcement-points). + +Native dependencies (`node-pty`, `@parcel/watcher`) are installed and compiled **into the version +directory** too (`installNativeDeps`, `ssh-relay-deploy.ts:951-1026`), so each install carries +its own addons and a rebuild can never affect another version. One naming consequence lives +there: `npm init -y` rejects the `+` in a content-hashed directory name, so deploy writes a fixed +minimal `package.json` instead (`ssh-relay-deploy.ts:984`). A failed post-install `require()` +probe is deliberately **non-fatal** — deploy logs `[ssh-relay][NPTY-MISSING]` and finalizes the +install anyway (`ssh-relay-deploy.ts:1103-1108`), because the relay still serves fs/git/preflight +and throwing would loop reconnects forever on a host that cannot build the addons. The optional +Windows process-table addon is treated the same way: hashed when present, never probed +(`src/shared/relay-artifacts.ts:54-57`). + +The client refuses to guess it: `readLocalFullVersion` (`ssh-relay-versioned-install.ts:48-67`) +throws when `.version` is missing or empty rather than falling back to a bare `0.1.0`, because +that fallback path may already have a stale-generation daemon running on it +(`ssh-relay-deploy.ts:378`). + +`relay-` and `orcad-` are permanent siblings under one `.orca-remote/` +(`src/main/ssh/remote-install-model.ts:1-13`), which is why the prefix is a model parameter. +Ownership is enforced: `remoteInstallDirOwner` / `remoteInstallGcPermits` +(`remote-install-model.ts:100-135`) mean a relay GC pass can never see or delete an orcad tree. + +## Where the socket lives + +On POSIX the endpoint is a unix socket **inside the version directory** +(`src/main/ssh/ssh-relay-deploy.ts:1385`, `relayEndpointForHost` at +`ssh-relay-endpoints.ts:11-17`). Its name is `relay-.sock`, or +`relay.sock` when no relay instance id is supplied (`ssh-relay-instance-id.ts:3-9`) — one +version directory is shared by every Orca target on that account, and hashing the target id into +the socket name is what stops cross-target attach (`ssh-relay-deploy.ts:1383`). The endpoint +credential (`.credential`, `ssh-relay-deploy.ts:1387`) and the agent-hook endpoint +directory (`relayHookEndpointDirForHost`, `ssh-relay-endpoints.ts:26-37`) sit beside it. + +On Windows there is no filesystem socket: the endpoint is a named pipe +`\\.\pipe\orca-relay-` (`ssh-relay-endpoints.ts:18-24`). The +version directory is still an input to that hash, so pipes are version-scoped too — but a pipe +leaves nothing inside the directory. + +**Why inside the version directory: garbage collection reads liveness from in there.** GC's +`isDirLive` hook for the relay is `hasLiveRelaySocket` (`remote-install-gc.ts:256`), and the +command it runs (`relayLivenessProbeCommand`, `ssh-remote-commands.ts:208-219`) globs +`/relay-*.sock` and `/relay.sock` and answers `ALIVE`/`DEAD` from `test -S` alone — +deliberately not a connect-and-close probe, which would race a daemon about to idle out. So +"which install directory is in use" is answered purely by looking inside it; nothing has to +enumerate remote processes. + +Windows has to restore that property artificially. Because the pipe is not in the directory, the +running relay writes a `.windows-active-pipe-` marker file into the version directory +(`windowsActivePipeMarkerPath`, `ssh-relay-endpoints.ts:50-61`) and the Windows branch of the +liveness probe reads those markers back out and connect-probes each pipe +(`ssh-remote-commands.ts:220-238`). The marker exists solely so the same "liveness is discoverable +from inside the directory" rule holds there. + +Endpoint placement and GC safety are therefore a single decision. Moving the socket out of the +version directory — the obvious first step toward a stable, build-independent endpoint — +invalidates the liveness probe and needs a replacement designed at the same time. + +## Four enforcement points + +A client and a relay of different builds cannot reach each other. This is not enforced once; it +is enforced four independent times, so the structural guarantee survives any one of them being +bypassed. + +| # | Point | Mechanism | +| --- | --- | --- | +| 1 | The path itself | The client only ever computes `remoteDir` from its own `fullVersion` (`ssh-relay-deploy.ts:378-379`). A different build addresses a different directory, hence a different socket or pipe. There is nothing to refuse because there is nothing to reach. | +| 2 | Wire handshake | The daemon compares the client's handshake `version` against its own `launchVersion` and closes on inequality (`src/relay/relay-handshake.ts:126`). | +| 3 | PTY consumer grant | The client re-checks `protocolVersion` and `serverBuildId` on every `pty.openClient` grant and throws on mismatch (`src/main/ssh/ssh-pty-consumer-session.ts:58-65`). | +| 4 | Owner-claim recovery | A persisted PTY owner claim is reusable only when its recorded `serverBuildId` equals the current one (`src/main/ssh/ssh-relay-session.ts:1136-1147`). | + +**Point 2 in detail.** `launchVersion` is `readLaunchVersion()` — `.version` read beside the +*resolved* script path, not the launch cwd (`relay-handshake.ts:19-46`, wired in at +`relay-daemon.ts:43-47`). On inequality the daemon writes an `orca-relay-handshake-mismatch` +frame and ends the socket; the `--connect` bridge prints the detail and exits with +`EXIT_CODE_VERSION_MISMATCH = 42` (`relay-handshake.ts:203-213`). The client maps exit 42 into a +typed `RelayVersionMismatchError` (`ssh-relay-deploy-helpers.ts:139-149`, +`ssh-relay-handshake-mismatch.ts:6-14`), which the reconnect loop treats as terminal rather than +retrying with backoff (`ssh-relay-version-mismatch-error.ts:1-8`; handled at +`ssh-relay-session.ts:632` and `:786`). + +Given point 1, a mismatch here should be unreachable — the same directory implies the same +`.version` — and the source calls this check defense-in-depth (`relay-handshake.ts:164`). The +one reachable route is a `.version` that could not be read when the daemon launched: +`readLaunchVersion` falls back to bare `RELAY_VERSION` (`relay-handshake.ts:45`), which never +equals a client's `0.1.0+`. + +**Point 3 in detail.** The relay stamps its `launchVersion` into every grant as `serverBuildId` +(`relay-runtime-services.ts:36-43`, `src/shared/pty-consumer-session.ts:85-86`); the client's +expected value is the `fullVersion` returned by deploy (`ssh-relay-deploy.ts:594`). The comment +at `ssh-pty-consumer-session.ts:78-79` states the invariant this whole scheme creates — +*"client and relay ship in one build"* — and uses it to conclude that a missing field in a grant +is corruption rather than an older peer. That reasoning is only valid while the scheme holds. + +## What that costs + +After an Orca update the content hash changes, so the client computes a new directory, a new +socket path, and launches a fresh daemon. **Nothing above stops the previous daemon.** It was +launched detached (`nohup … `; if the socket is alive it runs `relay.js --connect` to re-attach and +preserve PTY state. Any failure of that reconnect is caught by an **untyped** catch, which logs +and then runs `rm -f ` before falling through to a fresh launch +(`ssh-relay-deploy.ts:1421-1455`, unlink at `:1445-1446`). The catch does not distinguish: + +- a genuinely stale inode left by a SIGKILLed relay — the case the comment at + `ssh-relay-deploy.ts:1445` describes; +- **exit 42**, which is positive proof that a healthy daemon is listening on that socket and + refused the handshake. `waitForSentinel` builds the typed `RelayVersionMismatchError`, but this + catch swallows it, so neither typed handler in `ssh-relay-session.ts` is reached; +- a rotated endpoint credential — `writeRelayEndpointCredential` runs on the fresh-launch path + only (`ssh-relay-deploy.ts:1467`), so an incumbent holding the previous credential fails every + subsequent handshake with no version change involved. + +Unlinking a unix socket does not close the listener the incumbent already holds. Worse, it +defeats the daemon's own guard: on `EADDRINUSE` the relay connect-probes the blocked path and +unlinks only when the inode identity is unchanged and the probe was refused +(`src/relay/relay-socket-ownership.ts:88-112`, `:146-200`). With the inode already removed by the +client, the replacement's `listen()` simply succeeds and the incumbent is never contended with — +it keeps running with its PTYs, now unreachable. + +Windows is not affected: `launchRelay` returns from its Windows branch +(`ssh-relay-deploy.ts:1389-1417`) before this block, and a named pipe has no inode to unlink. + +**Status.** PR #17821 ("fix(ssh): stop orphaning live relays when an endpoint is taken over") is +**open and unmerged** as of this writing, and its own description flags one behavior change as +still needing a product decision. The behavior described above is what ships today. +The PR removes the client-side unlink entirely and replaces it with a read-only incumbent probe +producing a strict `live` / `unverifiable` / `exited` verdict, letting `RelaySocketOwnership` +remain the authority. It also converts the live-incumbent-holding-work case from a silent leak +into a visible connect failure — an explicit product decision still under review. Do not write +code or docs that assume it has landed. + +## Contrast: the terminal daemon + +The local terminal daemon solves the same problem — a long-lived process holding PTYs across app +updates — and reaches the opposite conclusion at every step. It is the consolidation target. + +| | SSH relay | Terminal daemon | +| --- | --- | --- | +| Endpoint | `~/.orca-remote/relay-<0.1.0+hash>/relay-.sock` | `/daemon-v36.sock` (`daemon-spawner.ts:122-134`) | +| Namespaced by | build content hash — changes on any byte change | semantic `PROTOCOL_VERSION` — bumped deliberately (`daemon-protocol-version.ts:3`) | +| Path stability across builds | none | stable until the protocol version moves | +| Older peers | refused (exit 42) | kept attachable; `PREVIOUS_DAEMON_PROTOCOL_VERSIONS` lists 1–35 (`daemon-protocol-version.ts:31-34`) | +| Feature skew | not expressible — one build on both ends | per-version predicates, e.g. `supportsPtyStartupIngress` (`daemon-protocol-version.ts:36-38`) | +| On app update with live work | old daemon orphaned with its PTYs | replacement refused: `shouldPreserveDaemonWithLiveSessions` (`daemon-replacement-preflight.ts:247-266`) | + +The daemon's preservation rule is the sharpest contrast. It replaces only at *exactly* zero live +sessions; a non-zero count preserves, and a `null` count — live state that could not be verified +— also preserves (`daemon-replacement-preflight.ts:247-266`). The relay has no equivalent check +on the update path. The daemon's own header states the premise the relay does not adopt: +*"daemons survive app updates, so wire behavior must be version-gated"* +(`daemon-protocol-version.ts:1-2`). + +Windows named pipes on the daemon side carry the protocol version in the name for the same +reason (`\\?\pipe\orca-terminal-host-v36-`, `daemon-spawner.ts:129-132`) — the version, not +the build. + +## Constraints on changing any of this + +Read [`reference/remote-wire-compatibility.md`](./reference/remote-wire-compatibility.md) first. +Two constraints are specific to the relay handshake. + +**A new handshake message type is not backward compatible.** `parseHandshakeMessage` +(`src/relay/protocol.ts:50-61`) throws on any `type` outside the three it knows, and both sides +destroy the socket on a parse failure (`relay-handshake.ts:113-120`, `:185-194`). Unlike the +terminal stream — where an unknown opcode is dropped silently (Rule 2 of the wire-compatibility +page) — an unknown handshake type is a hard close. The failure is loud but total, and it happens +before any capability could have been advertised, so there is no negotiation step to hang a new +type off. + +**Evolve with optional fields on the existing types.** `endpointCredential` is the precedent: it +is an optional field on `orca-relay-handshake` (`src/relay/protocol.ts:41`), sent only when the +client has one (`relay-handshake.ts:231-237`), and checked by the daemon only when the daemon +itself has one, through an `in` guard that tolerates its absence +(`relay-handshake.ts:144-151`). An older peer on either side is unaffected. + +Two more things that move together with the version string: + +- It is load-bearing in three places simultaneously — directory name, wire handshake version, + and PTY grant `serverBuildId`. Changing the format changes all three at once, plus the + persisted owner-claim records that key on it (`ssh-relay-session.ts:1136-1147`). +- Its shape is pinned by remote regex dialects. `VERSION_PATTERN` + (`remote-install-model.ts:66-72`) is embedded verbatim into a remote `awk` ERE and a PowerShell + `-match`, which is why it spells `[0-9]` rather than `\d`. A format change must stay + expressible in all three dialects, or GC and the install listing stop recognizing their own + directories.