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.
This commit is contained in:
Neil
2026-09-01 08:18:10 -07:00
parent 5aa02ead59
commit 05992422d2
2 changed files with 286 additions and 0 deletions
+2
View File
@@ -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
+284
View File
@@ -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-<sha256(targetId)[0:16]>.sock the endpoint
relay-<…>.sock.credential
agent-hooks/<sockname>/
orcad-<version>/ 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-<fullVersion>` —
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/<commit>/`.
- **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-<v>` and `orcad-<v>` 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-<sha256(targetId)[0:16]>.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 (`<sockname>.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-<sha256(remoteDir\0sockName)[0:20]>` (`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
`<dir>/relay-*.sock` and `<dir>/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-<sockname>` 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+<hash>`.
**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 … </dev/null &`, `ssh-relay-deploy.ts:1464-1471`) precisely so remote
work survives the client going away, and its PTYs are its children. The shipped default grace
period is `0` = keep alive until reset (`src/shared/ssh-types.ts:9`), so no timer reclaims it
either.
The result is work that does not stop and can never be addressed again:
- The new client never computes the old path (point 1), so it never even attempts a reconnect.
- If it somehow did, points 2–4 would refuse it.
- GC will not remove the old tree, because `hasLiveRelaySocket` correctly sees the live socket
and returns `ALIVE` (`remote-install-gc.ts:256-285`).
Per [`reference/ssh-execution-boundary.md`](./reference/ssh-execution-boundary.md) the verdict
for that work is **`live`**, not `exited`. Losing the ability to address a process is not
evidence of its death, and the doc's rule against collapsing `unverifiable` into `exited`
applies here directly. The user sees terminals come back as fresh shells; the old ones are still
running.
This is tracked as issues #8585, #13614, and #13852. The real fix named in PR #17821 —
replacing the build-content-hash `launchVersion` with a semantic protocol version plus a stable,
version-independent socket path — is a wire change requiring capability negotiation and has not
been attempted.
## GC, and the socket-unlink hazard
`gcOldRelayVersions` (`remote-install-gc.ts:240-254`) runs fire-and-forget after a successful
launch, with errors swallowed so GC can never block a connect (`ssh-relay-deploy.ts:578-590`).
It is conservative by construction:
- The remote listing is prefix-scoped and capped, and `remoteInstallGcPermits` re-checks every
candidate client-side (`remote-install-gc.ts:104-113`).
- The current directory is pinned and never a candidate (`remote-install-gc.ts:89`, `:109`).
- A locked directory is skipped unless the lock is provably stale, and a directory without
`.install-complete` is left for the next deploy to recover (`remote-install-gc.ts:200-232`).
- An inconclusive liveness probe returns `true` — never evidence a tree is idle
(`remote-install-gc.ts:282-284`, and the `isDirLive` contract at `:60-63`).
**The hazard is on the relaunch path, not in GC.** Before launching a fresh POSIX relay, deploy
probes `test -S <sock>`; 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 <sock>` 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-<hash>.sock` | `<runtimeDir>/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-<hash>`, `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.