ADR 0292: SSH bootstrap for remote hosts
- Status: Accepted for implementation
- Date: 2026-09-19
- Decision: D453
- Related: ADR 0205 (D373 / D374 / D375), ADR 0285 (D448), ADR 0286 (D449),
02-architecture/05-remote-agent-control.md§5.2,05-security/02-remote-control-security.md§3.4,06-delivery/07-remote-control-rollout.md§2 R2b
Context
ADR 0286 delivered the desktop-side R2a kernel and left the bootstrap explicitly out of scope: "Every paired host today assumes the loopback URL already exists." Reaching another machine meant the user installed pi-host there, started it, opened ssh -L themselves, and pasted the resulting URL and a pairing token into Settings.
R2's exit criteria do not accept that (rollout §2 R2b, security §3.4). The desktop has to install and pair a host on a machine the user already reaches over SSH, under two boundaries the security spec fixes: the desktop never uploads executable bytes, and it never holds an SSH secret.
Three properties of the system as it stands shaped the design:
release.ymlpublishespi-host-<version>-<platform>-<arch>.tar.gzand its.sha256to thev<version>GitHub Release, and the bundle matrix is Linux x64 only.- A paired host is a URL in
remote-hosts.json, but a bootstrapped host has no stable URL: the local port of a forward is chosen per launch. bootstrap/remote-hosts.tsalready owns every host's lifecycle, so the bootstrap plugs into that layer instead of beside it.
Decision
The bootstrap is five modules under apps/desktop/electron/main/remote/, one extended boot hook, and one new IPC channel.
The system
sshclient is the transport. Shelling out tosshbeats linking an SSH library because the user's~/.ssh/config, agent, jump hosts, andknown_hoststhen apply unchanged, and the app never holds a secret to leak.BatchMode=yesis what keeps that honest: a host needing an interactive password or passphrase fails immediately with a typed error instead of hanging a modal behind an invisible prompt. The module always setsStrictHostKeyChecking=accept-new,ConnectTimeout=15, andExitOnForwardFailure=yes.Pure release coordinates (
pi-host-release.ts). The artifact is resolved for the remote platform at the desktop's version, and the SHA-256 the release publishes is the trust anchor.PUBLISHED_TARGETSis the release matrix (linux-x64today), so any other target is refused withHOST_BOOTSTRAP_FAILEDbefore a download starts rather than as a 404 halfway through one. URL derivation,unameparsing, checksum-file parsing, constant-work digest comparison, and the version-parity rule are all pure functions with no network and no SSH.One script runs remotely (
pi-host-bootstrap-script.ts). It downloads the pinned URL, verifies the digest, installs under$HOME/.pi-desktop/pi-host, (re)starts the host on loopback with--pair, and echoesPI_HOST_READY/PI_HOST_PAIRING_TOKEN. It runs underumask 077, so the single-use pairing token only ever exists in a work file inside the bootstrap directory and on the SSH channel's stdout — never a world-readable path, never a URL (security §3.4). Every failure exits non-zero after aPI_HOST_BOOTSTRAP_FAILED <step>line, so the desktop reports the step that failed instead of a bare exit code. Generating it is pure and every input is shell-quoted byshellQuote, which is what lets the tests execute the generated text for real.ssh-transport.tsis a port, not a dependency.exec/execWithInput/forward/disposelet the orchestrator be tested end to end against a fake with no process spawned;createSystemSshTransportis the only implementation that spawns.execWithInputpipes the script intosh -s, so the script never survives an argv round trip and never lands in a remote file to clean up. Values that reach thesshargv are refused byassertSshArgumentwhen they are empty or start with-: passed as its own argv entry, a leading dash becomes an option (-oProxyCommand=…), not a destination.Forwards are durable and owned, not incidental (
ssh-tunnel.ts). The persisted record stores the SSH descriptor, not a URL; the manager reserves a loopback port, runsssh -N -L 127.0.0.1:<local>:127.0.0.1:<remotePort>, and derivesws://127.0.0.1:<port>/v1/racp/wsper launch, so a restart re-establishes the tunnel before connecting instead of dialling a stale port. The bootstrap's own forward is adopted, not closed and reopened, so pairing pays for exactly one tunnel. A forward counts as ready only once its local port accepts a connection, because a healthyssh -N -Lprints nothing.Version parity is checked before a forward exists. The desktop's version is what gets downloaded — there is no version negotiation — and
PI_HOST_READY.versionmust equal it (vprefix tolerated either way). A mismatch raisesHOST_VERSION_MISMATCH(D375) before any forward exists, and the desktop tears down the SSH processes it spawned on the way out instead of leaving a half-connected host behind.The registry keeps a descriptor, not a URL.
metadata.transport = "ssh"plusmetadata.ssh(host, optional port / user / identity file,remotePort,version).remote-hosts.jsonis user-editable, so the descriptor is re-validated on every read: a missing host or aremotePortoutside 1..65535 degrades the record to "not an SSH host" instead of producing ansshspawn with garbage arguments. Records written before this change carry notransportand read asdirect.One IPC channel and one pairing exchange.
pi-desktop/remoteHost/bootstrapjoinslist/pair/removeand carries a host, never a credential. Theconnection/pairexchange was factored intoexchangePairingToken, used by both the pasted-URL path and the bootstrap path, and it closes its throwaway connection on every path.bootstrapHostadopts the forward before persisting the record, because the device token only works over that forward.
Invariants
- No SSH secret is stored, prompted for, or transmitted by the app. The only credentials the bootstrap touches are the ones the system
sshclient resolves itself. - No executable bytes travel over the SSH channel. The remote script downloads the bundle; the bytes the desktop sends are the script text on stdin.
pi-hostkeeps binding loopback only. The desktop reaches it through a forward and never opens a listener of its own.- The renderer stays transport-agnostic.
RemoteHostSummary.transportis optional and rendered as a label; no renderer call branches on it. - An empty registry stays a full no-op: nothing connects and no tunnel opens.
- A failed bootstrap leaves nothing running: its transport is disposed unless the caller adopts the forward, and an SSH host whose connect fails has its tunnel closed rather than left idle.
Out of scope
Still open for R2b and later:
- Terminal work-panel client (Stage 5) and reverse tool relay (Stage 6) from ADR 0286. Both remain unbuilt.
- Provider configuration over the SSH channel. Spec §3.4 and §5.2 name it as part of the bootstrap step, but it needs host-side provider schema work and is not in this change, so a freshly bootstrapped host still fails
turn/startclosed withMODEL_NOT_CONFIGUREDuntil a provider is configured on it. - The resync watchdog:
resync.requiredevents are still dropped. - Non-Linux remote targets, and Windows as a remote target. Only
linux-x64is published, so both are refused before a download starts.
Alternatives considered
- An
ssh2-style library instead of the system client. Rejected: it re-implements config, agent, and jump-host semantics the user already has, and asks them to re-enter credentials the app should never hold. - Download the artifact on the desktop and upload it. Rejected by security §3.4: the desktop never uploads executable bytes.
- Let the script read the published
.sha256itself. Rejected: the desktop should hold the trust anchor, and this keeps the script's only input a URL plus a digest. - Store the forwarded URL in the registry. Simpler read path, but the local port is not stable across launches, so a restart would dial a port nothing listens on. Rejected in favour of the descriptor.
- Install the host into a user-level service manager. Rejected: the script installs under
$HOMEand restarts the process its own pidfile tracks, which keeps the bootstrap privilege-free.
Testing
The release rules and the script generator are pure and covered by node --test fixtures; the generator's tests run sh -n over the produced text and execute it against a stubbed PATH. The orchestrator runs end to end against a fake transport, a fake checksum fetch, reserved ports, and a fake pairing exchange, so step order, the digest failure path, the version-mismatch path with no forward, and the adopt-don't-reopen rule are asserted without spawning a process.
Consequences
remote-hosts.jsongains a second record shape and stays backward compatible: existing records have notransportand are read asdirect.- A machine without outbound GitHub access still cannot be bootstrapped (D375).
- The desktop now spawns
sshprocesses, which is new and visible to endpoint-security tooling; until now the remote stack only opened outbound WebSocket connections. - The 16 new
settings.remoteHostsi18n keys must be translated in every shipped locale, because the catalog type is exhaustive. - No new runtime dependency: the bootstrap adds no SSH library, no HTTP client, and no archive tool to
apps/desktop.