From d9faea34a9b96034b7d041ce9d2d9c57e84ac666 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 00:34:36 -0700 Subject: [PATCH 001/170] docs(ssh-anywhere): design for SSH to any machine, from anywhere Captures the new line of work: a plain SSH terminal pane to any sshd-only host as the primary product, with two connection modes (stock ProxyJump "Jump", and a plane-blind sealed agent relay "Relay" that authenticates the connecting machine using a key that never leaves its home). The agent-destination boot from the #330 approach becomes an optional upgrade on top. Phased M1 (terminal + jump, no relay) / M2 (the sealed relay, own spec). Design doc only; no implementation. Supersedes the approach on feat/ssh-support (PR #330). Co-Authored-By: Claude --- .../specs/2026-10-07-ssh-anywhere-design.md | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md new file mode 100644 index 000000000..b536a1f8e --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -0,0 +1,292 @@ +# SSH to Any Machine, from Anywhere + +Status: design, for review. Dated 2026-10-07. +Branch: `feat/ssh-anywhere`, cut from `origin/main`. This supersedes the approach +on `feat/ssh-support` (PR #330), which is left open and unmerged as a reference +for the brokered-session transport, not as a base. + +## 1. Problem and goal + +From Subshell I want to open an interactive session against an arbitrary host +over SSH. The host may be an enrolled Subshell node or it may be nothing more +than a box running sshd; it must never be required to run Subshell in order to +be reachable. The SSH identity that authenticates the connection may live on a +different machine than the one that actually dials the host, and in that case +the private key must never leave its home and the control plane must never see +key material or plaintext authentication challenges. + +The plain SSH terminal is the primary product. Turning the host into a full +Subshell agent destination is a secondary act layered on the same connection. + +## 2. Non-goals + +- Not a general-purpose SSH client for terminal use outside Subshell panes. +- Not a secret store: Subshell never custodies, imports, or persists an SSH + private key. +- Not multi-tenant hardening. Subshell is a single-operator, trusted-network + service (`docs/security.md` section 0); grants name one operator's machines. +- Not replacing node enrollment. Reachable-by-enrollment and reachable-by-SSH + are two doors to the same pane machinery. + +## 3. Terminology and topology + +- **D** the destination: any host with an sshd. Holds no Subshell secret. May or + may not be an enrolled node. +- **B** the connecting machine: the node or Subshell Client broker that runs the + `ssh` process and hosts the pane. +- **A** the key home: the machine whose `ssh-agent` holds the key that + authenticates D. A laptop broker or another node. +- **The plane** the Subshell control plane. Nodes and brokers only ever meet + each other through it; it is the sole transport between A and B. + +A pane is the unit of value: a tmux-backed PTY on the connecting machine, +streamed, shareable, and logged exactly like any other Subshell pane. + +## 4. Connection modes + +One launcher, two modes, chosen per launch by a key grant (section 5.1). + +### 4.1 Jump mode (A is the connecting machine) + +A runs `ssh -J B D`. The key and the agent stay on A; B is a network-only jump +that never sees the key; the pane lives on A. This is stock OpenSSH +`ProxyJump`, needs no new crypto, and is plane-blind because no key material or +challenge crosses the plane at all. Use it when the reason to involve B is purely +that B can reach D's network while A cannot, and it is acceptable for the pane to +run on A. + +### 4.2 Relay mode (B is the connecting machine) + +B runs the pane and the `ssh D` process, and authenticates to D through A's agent +over a sealed agent relay (section 8). The key never leaves A; the plane relays +only sealed sign requests it cannot read; the pane lives on B, on server-grade +compute, and persists after A disconnects. Use it when the session must run and +survive on B (or on D as a Subshell destination) rather than on the laptop. + +Relay mode requires A to be online and connected for the duration of the +handshake. That is the accepted price of the key never moving; an offline A is a +clear error, never a silent fallback to copying the key. + +## 5. Architecture components + +### 5.1 Key grant + +An owner-scoped, plane-stored record that says: to reach these destinations, sign +with the agent on machine A. It carries a name, the key-home node id, a +destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy, unrelated to the wildcard patterns discovery drops), the pinned host key +for D (section 9), an optional "ask me each time" approval flag, and audit +metadata. It stores no secret and no key bytes. Grants are cheap because the +operator is the only party. Writes are audited in the ssh/nodes family, naming ids +and host only, never any credential. + +### 5.2 SSH launcher + +A single plane service that composes the `ssh` invocation toward the chosen +connecting machine and mode. It reuses the OpenSSH-based resolution already +specified here: read concrete aliases from `~/.ssh/config`, resolve an alias with +`ssh -G`, and render a per-connection config from the resulting snapshot (see +section 15 for which of these are ported). Every token in the built command is +passed through `shellQuote`; the connection is one shell string through tmux, so +quoting is the load-bearing defense and is never relaxed. + +### 5.3 Sealed agent relay (Milestone 2) + +The one genuinely new subsystem, and it is encrypted channels carrying the SSH +agent protocol. On B, a local Unix socket speaks the `ssh-agent` wire and is +exported as `SSH_AUTH_SOCK` into the pane's `ssh`; requests arriving on it are +sealed end-to-end to A and shipped through the plane. On A, a responder accepts +only requests the plane brokered for a live grant, checks policy, forwards them to +A's real agent, and seals the signature back. The plane routes by the recipient id +in the envelope header without opening anything. Detail in section 8. + +### 5.4 Terminal pane + +A launch of the `terminal` harness whose command is the launcher's `ssh` +invocation, on the connecting machine, under the tmux PTY. Streaming, input, +capture, logging, and sharing are the ordinary pane path and need no new code. The +far side needs only sshd. + +### 5.5 Agent-destination upgrade + +A secondary act on an open session: "Set up Subshell here" boots the Subshell +runtime on D over an authenticated connection using the same key grant and mode. +This is where the brokered-session machinery from #330 becomes relevant, and it is +a consumer of the shared launcher and relay rather than a separate product. It is +out of Milestone 1 scope and revisited in the Milestone 2 spec. + +## 6. Phasing + +### 6.1 Milestone 1: terminal and jump (the mergeable first slice) + +Port the neutral primitives, then ship: SSH-terminal panes to any D regardless of +node status, alias discovery and `ssh -G` resolve, the "connect from" and host +pickers, host-key pinning, Jump mode end to end, and audits. No session-frame +codec, supervisor, or runtime-serve in this milestone. Delivers the whole "SSH +into any machine" goal for every case that does not require the session to run on +B with a key that lives only on A. + +### 6.2 Milestone 2: the sealed agent relay (own spec, own review) + +Relay mode, the grant/authorization machinery that drives it, and the optional +agent-destination upgrade. A focused security review pass is expected here; it is +the risky core and is deliberately isolated behind a clean seam the M1 launcher +exposes. + +## 7. Data model + +- `ssh_key_grants`: id, owner, name, key-home node id, destination selector, + pinned D host key (public), approval flag, created/updated. No secrets. +- Relay session state (M2, in-memory on the plane with a durable audit row): a + brokered pairing of grant, A, B, the pane/subshell id, an opaque routing ref, + and a lifetime. The ssh-agent payload (challenge, signature, target host, + identity) lives only inside sealed envelopes, never in this state. +- Panes reuse the existing `subshells` rows; a connecting-machine-only SSH pane + is an ordinary row on B (or A in jump mode). Nothing about the key is written + there. + +## 8. Relay protocol and security (M2) + +- Sealing and pinning reuse `packages/mcp-core/src/crypto.ts` (`seal`/`open`, + ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing) + and `packages/mcp-core/src/pin-store.ts` (TOFU, byte-equality, `peers.json`). + The envelope the plane stores and routes is the same opaque blob the encrypted + channels already relay. +- The plane brokers a relay pairing when a grant is used: it tells A to expect + sealed requests for this pane/host, and B that its agent socket backs onto A. A + routing id rides in the envelope for plane-side pairing, quota, and lifetime; + the sensitive agent payload rides inside the ciphertext. +- Origin: to be accepted by A, a request must seal under B's identity key, which + A has pinned TOFU. The plane holds no machine's private ECDH key, so it cannot + mint a request A will accept as coming from B. Sealing therefore supplies origin + authentication, not just confidentiality. +- Scope of signing: the grant pins one key identity so only that key can be used. + SSH agent signing requests carry no destination, so a grant cannot bind the + agent to a host at the protocol layer; the residual risk (a compromised B using + the granted key against other hosts that key can reach) is accepted under the + single-operator posture and bounded by key-scoping plus instant revoke. +- No forwarding onward: B authenticates to D using A's agent but does not run + `ssh -A` toward D. A's agent is used for the handshake and is never exposed on + D, which is strictly safer than stock agent forwarding. +- Handshake-window lifetime: an SSH agent is needed only at connection + establishment. The relay proxy socket on B is created for the handshake, sealed + traffic flows for seconds, then the socket is unbound and the relay pairing is + torn down while the established pane keeps running on its own transport. This is + the "discard immediately after connecting" intent, achieved without the key ever + moving. A fresh re-authentication that needs the agent again re-opens the relay, + requiring A online at that moment. + +## 9. Host-key trust + +Trust for D follows the key-holder, not the connecting box. When a grant is +created from A, A's trust in D's host key is captured (trust-on-first-use from +A's `~/.ssh/known_hosts`, or an explicit pin) and stored as the grant's pinned +public host key. In relay mode, B's ssh validates D against a rendered +`UserKnownHostsFile` containing that pin with `StrictHostKeyChecking=yes`; B's own +`known_hosts` is not the authority. A changed D host key is a hard block, matching +the pin doctrine elsewhere in the threat model. + +## 10. Trust model delta versus section 0 + +Gained: the plane never holds, sees, or can be shown an SSH private key or a +plaintext sign challenge; compromising the plane cannot exfiltrate a key or forge +a request A will honor; D never receives a forwarded agent. + +Unchanged or accepted: the OS-user boundary on A and B is still the operator +(`docs/security.md` section 0); a machine you run the agent on is a machine you +trust. A already-compromised B can drive its granted relay during the handshake. +The plane retains what it always had: denial of service, and metadata (which +machines are talking, timing, sizes). Replacing the plane's identity material +remains the manual operation it is today. + +## 11. UI + +Primary-first, consistent with the launch pickers already in the codebase +(`SearchableSelect`). One SSH tab whose flow is: pick the connecting machine +("Connect from"), pick the destination host (discovered aliases, or a typed +target), pick the key source when it differs (a grant, defaulting to the +connecting machine's own keys), then open a terminal. "Set up Subshell here" sits +below as the secondary act on the resulting session. Saved destinations and recent +hosts are one-click shortcuts, not the primary read. Shipped copy follows the +design system: role tokens only, at most two sentences, no em dashes. + +## 12. Failure modes and UX + +- A offline in relay mode: refuse before the handshake, name the remedy (bring + the key machine online); never copy the key to B. +- No matching key in A's agent: say so and point at loading it; never fall back to + reading a passphrase-protected file. +- D host key changed since the grant pinned it: hard block, name D. +- Grant names a connecting machine that cannot reach D: a distinct error from "no + key," so the two causes are not confused. +- Relay expired or A dropped mid-handshake: the connection either completed (pane + fine) or fails cleanly with a retry that re-opens the relay. +- Quota or policy refusal on A: surfaced as a refusal with the reason, matching + the equality-mapped refusal convention used elsewhere. + +## 13. Security rules honored + +- No key material, challenge, or signature content in any log, argv, audit row, or + notification. Audit rows name ids, hosts, and field names only. +- The agent proxy socket on B is 0600 in a per-session directory; the rendered + connection config is 0600; transient files are removed with the pane. +- `shellQuote` on every token of the built command; the connection is one tmux + shell string, and that is never reasoned away. +- Node and broker command transport is the existing authenticated, replay-fenced + path (`sendCommand`, signed commands, the desktop broker's monotonic fence). +- Static imports only; no dynamic import outside the one sanctioned plugin site. +- Machine-to-machine sealed payloads reuse the channel crypto and TOFU store, not + a new bespoke cipher. + +## 14. Testing + +- Unit: alias discovery budgets and wildcard exclusion; `ssh -G` snapshot parsing + and the forbidden-settings refusal; connection-config render with the pinned + `UserKnownHostsFile`; the agent-proxy wire framing against a stub agent; grant + validation; relay envelope seal/open (right recipient opens, wrong id and + tampering fail); ProxyJump argv construction and quoting. +- Integration: two in-process identities as A and B with a fake plane relay; + assert a sign request reaches A's stub agent, the response returns sealed, the + relayed blob is opaque at the plane, an ungranted or wrong-origin request is + refused, and revoke stops signing mid-session. +- End to end: the sshd fixture reused as D. Jump mode drives a real two-hop + `ProxyJump` (bastion plus target) with the key on the origin only. Relay mode is + the crown-jewel test: a second brokered machine as A with the key in its agent, + a B with no key, and an assertion that B authenticates to D through the plane + relay while the plane-side capture of the wire shows only ciphertext. +- CI regime per the repo rules: focused files while iterating, the full trio plus + `lint:prose` at the boundary, and the e2e specs under the existing serial + container regime. + +## 15. Branching and reuse from main + +`origin/main` does not contain the ssh-runtime subsystem (discovery, resolve, the +connection-snapshot grammar, the brokered-session supervisor, the ssh-broker, or +the sshd fixture); those live only on `feat/ssh-support`. The E2EE primitives +(`mcp-core` seal/open and the TOFU pin store) are already on main and are used as +is. `feat/ssh-anywhere` therefore re-introduces only the neutral primitives +OpenSSH config discovery, `ssh -G` resolve, and the connection-snapshot grammar, +plus the sshd test fixture, as a small first commit, and deliberately does not +carry the session-frame codec, supervisor, or runtime-serve. `feat/ssh-support` +stays open and unmerged as the reference for the brokered-session transport that +Milestone 2 may revisit. + +## 16. Deferred / open items for Milestone 2 + +- Whether the agent-destination upgrade reuses #330's runtime-session protocol or + a lighter boot now that the terminal path exists. +- Per-use approval UX on A (a notification or prompt to confirm a handshake), + versus the default of a standing grant. +- Whether to support A's identity by file (with an on-A passphrase prompt) at all, + versus requiring the key to be loaded in A's agent. +- Enrolling a relay-only destination as a first-class node automatically after a + successful "set up Subshell here." + +## 17. Success criteria + +Milestone 1 is done when, from the web app and the Client, an operator opens an +interactive pane against an arbitrary sshd-only host, using either the connecting +machine's own keys or a jump through a second machine, sees it stream and accept +input like any pane, and no private key or plaintext challenge ever appears on the +plane or the destination. Milestone 2 is done when the same pane can run on a +machine that has no key at all, authenticating through a machine that does, with a +CI test proving the plane sees only ciphertext and the key never leaves its home. From 3dca25fe2a0ee90977c5151ab5779886c46d71fa Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 00:47:03 -0700 Subject: [PATCH 002/170] docs(ssh-anywhere): destination-first UI; #330 UX not ported Rewrites the UI section around the real job (pick a destination; origin/key source only as a progressive follow-up; the agent upgrade is a secondary act in the pane). Records that reuse from #330 is code/ideas only and its connect UX is a cautionary example, not a base. Co-Authored-By: Claude --- .../specs/2026-10-07-ssh-anywhere-design.md | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index b536a1f8e..a91d6415a 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -200,14 +200,30 @@ remains the manual operation it is today. ## 11. UI -Primary-first, consistent with the launch pickers already in the codebase -(`SearchableSelect`). One SSH tab whose flow is: pick the connecting machine -("Connect from"), pick the destination host (discovered aliases, or a typed -target), pick the key source when it differs (a grant, defaulting to the -connecting machine's own keys), then open a terminal. "Set up Subshell here" sits -below as the secondary act on the resulting session. Saved destinations and recent -hosts are one-click shortcuts, not the primary read. Shipped copy follows the -design system: role tokens only, at most two sentences, no em dashes. +The surface is designed fresh; #330's connect UX is intentionally not carried +over. That flow led with "where do you connect from," offered several overlapping +ways to pick a target at once (connect-from, connected hosts, saved locations, +recent, a manage link), and implied a Subshell runtime on the far side (folder, +agent, and preset pickers) even for what should be a plain shell. It read as +confusing, so we design around its failure modes rather than patch it. + +The job is one question, "where do you want to work?", so the flow leads with the +destination: + +1. A single searchable destination field, fed by the connecting box's + `~/.ssh/config` plus saved and recent hosts (the discovery idea, reused). Pick + or type a host. +2. Origin and key source are asked only when they are genuinely ambiguous, as one + progressive follow-up ("connect via [machine]" or "sign with [machine]'s + keys"), never a mandatory first step. The common case just opens. +3. Opening yields the interactive terminal pane. "Set up Subshell here" is a + secondary act inside the pane, not a competing mode on the landing screen. + +Saved hosts and the this-computer nicety survive as shortcuts, not the spine. +Terminology is trimmed to three nouns: destination, connecting machine, and key +source. The destination field is a `SearchableSelect` like the other launch +pickers. Shipped copy follows the design system: role tokens only, at most two +sentences, no em dashes. ## 12. Failure modes and UX @@ -268,7 +284,9 @@ OpenSSH config discovery, `ssh -G` resolve, and the connection-snapshot grammar, plus the sshd test fixture, as a small first commit, and deliberately does not carry the session-frame codec, supervisor, or runtime-serve. `feat/ssh-support` stays open and unmerged as the reference for the brokered-session transport that -Milestone 2 may revisit. +Milestone 2 may revisit. The reuse is of code and ideas only: the user-facing UX +is redesigned (section 11) rather than ported, and #330's connect flow is a +cautionary example, not a base. ## 16. Deferred / open items for Milestone 2 From b3748a7ceb32c5812c6c1c897128b422f4d48c84 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 00:48:54 -0700 Subject: [PATCH 003/170] docs(ssh-anywhere): default-off per-node SSH capability gate (owner-enabled, fail-closed, audited) Co-Authored-By: Claude --- .../specs/2026-10-07-ssh-anywhere-design.md | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index a91d6415a..e6e9af4ff 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -67,6 +67,24 @@ Relay mode requires A to be online and connected for the duration of the handshake. That is the accepted price of the key never moving; an offline A is a clear error, never a silent fallback to copying the key. +### 4.3 Node SSH capability gate + +A machine's participation in Subshell SSH at all (dialing out over SSH, or +serving its key/agent) is opt-in per node and **off by default, including the +`local` server node**. The node carries one admin-controlled flag, `ssh_enabled`; +a node with it off refuses every SSH role. Turning it on is a real widening of +that machine's egress and key exposure, so the gate fails **closed**: a node that +cannot read its own setting refuses, the opposite of the directory allowlist's +deliberate fail-open, because here the risk is what enabling lets Subshell do. + +Enabling is an owner action (admin for `local`), audited, and mirrors the +maintenance flag (plane and node agree, newer stamp wins). When a user needs a +machine whose flag is off, the launch is refused with the remedy named in the +copy: ask the owner of that machine to enable SSH on it. The gate is per role: in +relay mode both the connecting machine (which dials out) and the key home (whose +agent signs) must be enabled; in jump mode the origin must be enabled; the +destination `D` needs no flag, it is merely reached. + ## 5. Architecture components ### 5.1 Key grant @@ -119,11 +137,13 @@ out of Milestone 1 scope and revisited in the Milestone 2 spec. ### 6.1 Milestone 1: terminal and jump (the mergeable first slice) Port the neutral primitives, then ship: SSH-terminal panes to any D regardless of -node status, alias discovery and `ssh -G` resolve, the "connect from" and host -pickers, host-key pinning, Jump mode end to end, and audits. No session-frame -codec, supervisor, or runtime-serve in this milestone. Delivers the whole "SSH -into any machine" goal for every case that does not require the session to run on -B with a key that lives only on A. +node status, alias discovery and `ssh -G` resolve, the destination-first launch +flow (section 11), host-key pinning, Jump mode end to end, the per-node SSH +capability gate (section 4.3, off by default including `local`, owner-enabled, +fail-closed, audited), and audits. No session-frame codec, supervisor, or +runtime-serve in this milestone. Delivers the whole "SSH into any machine" goal +for every case that does not require the session to run on B with a key that +lives only on A. ### 6.2 Milestone 2: the sealed agent relay (own spec, own review) @@ -134,6 +154,10 @@ exposes. ## 7. Data model +- `ssh_enabled` (default `false`, including `local`): the per-node capability gate + of section 4.3, stored in node config and mirrored to the plane like + maintenance (newer stamp wins), owner/admin to change, audited as + `node.ssh_enabled`. It is a node setting, not a new table. - `ssh_key_grants`: id, owner, name, key-home node id, destination selector, pinned D host key (public), approval flag, created/updated. No secrets. - Relay session state (M2, in-memory on the plane with a durable audit row): a @@ -220,6 +244,8 @@ destination: secondary act inside the pane, not a competing mode on the landing screen. Saved hosts and the this-computer nicety survive as shortcuts, not the spine. +A machine whose SSH gate is off (section 4.3) is never silently offered: when one +is needed it is named with the remedy to ask its owner to enable SSH there. Terminology is trimmed to three nouns: destination, connecting machine, and key source. The destination field is a `SearchableSelect` like the other launch pickers. Shipped copy follows the design system: role tokens only, at most two @@ -249,6 +275,9 @@ sentences, no em dashes. shell string, and that is never reasoned away. - Node and broker command transport is the existing authenticated, replay-fenced path (`sendCommand`, signed commands, the desktop broker's monotonic fence). +- Outbound SSH is gated per node, off by default including `local`, and fails + closed when the setting is unreadable (section 4.3); enabling is owner/admin + only and audited (`node.ssh_enabled`). - Static imports only; no dynamic import outside the one sanctioned plugin site. - Machine-to-machine sealed payloads reuse the channel crypto and TOFU store, not a new bespoke cipher. @@ -302,9 +331,9 @@ cautionary example, not a base. ## 17. Success criteria Milestone 1 is done when, from the web app and the Client, an operator opens an -interactive pane against an arbitrary sshd-only host, using either the connecting -machine's own keys or a jump through a second machine, sees it stream and accept -input like any pane, and no private key or plaintext challenge ever appears on the -plane or the destination. Milestone 2 is done when the same pane can run on a +interactive pane against an arbitrary sshd-only host on a machine whose owner has +enabled the (default-off) SSH gate, using either that machine's own keys or a jump +through a second machine, sees it stream and accept input like any pane, and no +private key or plaintext challenge ever appears on the plane or the destination. Milestone 2 is done when the same pane can run on a machine that has no key at all, authenticating through a machine that does, with a CI test proving the plane sees only ciphertext and the key never leaves its home. From 894070e1fbd3ccf6894bcba5bfeb2c7e503036ca Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 00:59:09 -0700 Subject: [PATCH 004/170] docs(ssh-anywhere): Plan 1 foundations (ssh_enabled gate + ported primitives); spec supersedes SSH-SUPPORT.md Co-Authored-By: Claude --- .../2026-10-07-ssh-anywhere-1-foundations.md | 497 ++++++++++++++++++ .../specs/2026-10-07-ssh-anywhere-design.md | 6 +- 2 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md new file mode 100644 index 000000000..b70a01d9a --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -0,0 +1,497 @@ +# SSH Anywhere — Plan 1: Foundations (gate + ported primitives) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On `feat/ssh-anywhere`, land the two foundations every later slice needs: a default-off, owner-controlled, fail-closed per-node `ssh_enabled` capability gate, and the neutral OpenSSH primitives (config discovery, `ssh -G` resolve, connection-snapshot grammar) plus the sshd e2e fixture, ported off `feat/ssh-support`. + +**Architecture:** The `ssh_enabled` gate mirrors the existing `maintenance` flag (plane column + node mirror file + a push command + `ready` reporting) but with the plane as the sole writer and no pane-killing side effects; a `nodeCanSsh` predicate refuses use of a disabled node. The ssh grammar lives in `@internal/subshell-protocol` (barrel-safe: no `node:` builtins), the ssh engines in `@internal/pane-runtime` (its barrel is never imported by mobile), and the sshd fixture in `e2e/fixtures`. + +**Tech Stack:** Bun, TypeScript, Elysia (routes), Kysely (migrations/types), node:test-style `bun:test` unit tests, Playwright (e2e), `@base-ui`, React (only read by later plans). + +## Global Constraints + +Every task obeys these (copied from the spec `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`): + +- New migration number is **`0047`** (`main`'s latest is `0046-backup-recovery.ts`); do NOT reuse the branch's 0047-0051 names. +- `ssh_enabled` defaults **off/false on every node including `local`**; the DB column is `notNull().defaultTo(0)` (the maintenance default-0 "upgrade keeps the fleet as it was" doctrine). +- The node-side read is **fail-closed**: a node that cannot read its own setting refuses SSH; an absent file reads as **off** (default-off). +- Changing the flag is **owner-only, admin on `local`** (`gate.canManage`), cookie-authenticated, audited as `node.ssh_enabled`; the flag is plane-written (no node self-toggle CLI). +- No SSH private key material, challenge, signature, or config value enters any log, argv, or audit row; audits name ids/hosts/field-names only. +- Node-builtin-importing modules must NOT be re-exported from `packages/subshell-protocol/src/index.ts` (Metro imports that barrel and cannot resolve `node:*`). Grammar modules that import no `node:` builtin MAY be barrel-exported there. `@internal/pane-runtime` is not a mobile dependency, so its engines may be exported from its own index barrel. +- `shellQuote` every token of any built command; static top-level imports only (no `await import` outside the sanctioned `plugin-runtime.ts`). +- Run package tests with `env -u SHELLOPTS -u BASHOPTS`; a `bun test ` silently passes on a wrong path, so check the file count. +- Commit as author `theo@suteki.nu`. +- Design system (only where copy is shipped, which this plan does not): role tokens, two-sentence max, no em dashes. + +--- + +## File Structure + +Ports (verbatim copies off `origin/feat/ssh-support`, then export wiring): +- Create: `packages/subshell-protocol/src/ssh-config.ts` (grammar; no `node:` builtins → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-limits.ts` (budgets; no `node:` builtins → barrel-safe) +- Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` +- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` (`node:*` → pane-runtime barrel only) +- Create: `packages/pane-runtime/src/ssh/ssh-discover.ts` (`node:fs/os/path` → pane-runtime barrel only) +- Create: `packages/pane-runtime/src/ssh/ssh-resolve.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts` +- Create: `e2e/fixtures/sshd.ts` + +Gate (new, mirroring the maintenance template): +- Create: `apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts` +- Modify: `apps/server/api/src/db/migrate.ts` (register `0047`) +- Modify: `apps/server/api/src/db/types/nodes.db-types.ts` (`sshEnabled`, `sshEnabledAt`) +- Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` (`setSshEnabled`) +- Modify: `apps/server/api/src/lib/node-access.ts` (`nodeCanSsh`) +- Modify: `apps/server/api/src/api/nodes/node-view.ts` (`sshEnabled` in `NodeViewSchema` + `nodeViewBase`) +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` +- Modify: `apps/server/api/src/api/nodes/index.ts` (mount the route) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NodeSshEnabledWire`, `parseNodeSshEnabled`, `set_ssh_enabled` command arm, `ssh_enabled` event arm, `ready` arm, `NODE_RESULT_SSH_DISABLED`) +- Modify: `packages/subshell-protocol/src/index.ts` (export grammar + the new wire symbols) +- Create: `apps/node/agent/src/ssh-enabled.ts` (`node:*` file read/write, fail-closed) +- Create: `apps/node/agent/src/commands/set-ssh-enabled.ts` (executor: write mirror file + memoize) +- Modify: `apps/node/agent/src/commands/context.ts` (`lastReportedSshEnabled?` memo) +- Modify: `apps/node/agent/src/commands/report.ts` (`reportSshEnabled`, `maybeReportSshEnabled`, `seedSshEnabledMemo`) +- Modify: `apps/node/agent/src/commands/index.ts` (dispatch `set_ssh_enabled`) +- Modify: `apps/node/agent/src/daemon.ts` (`readyEvent` includes `sshEnabled`) +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (`setNodeSshEnabled`, `pushSetSshEnabled`, `reconcileSshEnabled`) +- Modify: `apps/server/api/src/services/nodes/node-events.ts` + `apps/server/api/src/index.ts` (onReady reconcile hook) +- Test files alongside each new service/route/module in `__tests__/`. + +--- + +## Task 1: Port the ssh grammar into `subshell-protocol` (barrel-safe) + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-config.ts` +- Create: `packages/subshell-protocol/src/ssh-limits.ts` +- Modify: `packages/subshell-protocol/src/index.ts` +- Test: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (from `@internal/subshell-protocol`): `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS`, `SSH_REQ_ID_MAX_CHARS`, `SSH_MAX_IDENTITY_REFS` (limits); `type SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS` (grammar). + +- [ ] **Step 1: Copy the two grammar files verbatim off the branch** + +```bash +cd /home/theo/projects/subshell # the shared checkout has feat/ssh-support pushed +B=origin/feat/ssh-support +git show "$B:packages/subshell-protocol/src/ssh-limits.ts" > packages/subshell-protocol/src/ssh-limits.ts +git show "$B:packages/subshell-protocol/src/ssh-config.ts" > packages/subshell-protocol/src/ssh-config.ts +git show "$B:packages/subshell-protocol/src/__tests__/ssh-config.test.ts" > packages/subshell-protocol/src/__tests__/ssh-config.test.ts +grep -nE "from \"node:" packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-limits.ts # MUST print nothing +``` + +Expected: the `grep` prints nothing (these two are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead — stop and re-scope. + +- [ ] **Step 2: Run the ported grammar test to confirm the copy is complete** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-config.test.ts` +Expected: the test imports from `../ssh-config.js`; it FAILS with "Cannot find module `../ssh-limits.js`" only if a symbol is missing — otherwise it may already pass because the two files are self-contained. If it references symbols that live in files NOT yet ported (e.g. `ssh-frames`), trim the test to the symbols actually in these two files for now, or port those symbols too. Record what the test needs. + +- [ ] **Step 3: Barrel-export the grammar** + +Append to `packages/subshell-protocol/src/index.ts` (do NOT export any `node:`-importing module here): + +```ts +export { + SSH_MAX_DISCOVERED_ALIASES, + SSH_NAME_MAX_CHARS, + SSH_REQ_ID_MAX_CHARS, + SSH_MAX_IDENTITY_REFS, +} from "./ssh-limits.js"; +export { + type SshConnectionSnapshotWire, + parseSshConnectionSnapshot, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, +} from "./ssh-config.js"; +``` + +- [ ] **Step 4: Build the package and run its tests** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test` +Expected: build succeeds; `ssh-config` test PASS; no other protocol test regresses. + +- [ ] **Step 5: Confirm the mobile build is unaffected (the Metro trap)** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol` then `grep -c "ssh-config\|ssh-limits" dist/index.js` +Expected: the built barrel references the grammar; because both files import no `node:`, Metro can still resolve the barrel. (The real mobile guard runs in CI; this is the local sanity check the rules call for.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts +git commit -m "feat(ssh): port OpenSSH connection grammar + budgets into subshell-protocol" +``` + +--- + +## Task 2: Port the ssh engines into `pane-runtime` (barrel-safe because mobile never imports it) + +**Files:** +- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` +- Create: `packages/pane-runtime/src/ssh/ssh-discover.ts` +- Create: `packages/pane-runtime/src/ssh/ssh-resolve.ts` +- Modify: `packages/pane-runtime/src/index.ts` +- Test: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts`, `.../ssh-resolve.test.ts` + +**Interfaces:** +- Consumes (Task 1): `SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS`, `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS` from `@internal/subshell-protocol`. +- Produces (from `@internal/pane-runtime`): `discoverSshAliases(...)`, `defaultSshConfigPath`, `isDiscoverableAlias`, `resolveSshAliasConfig(...)`, `runSshProcess`, `sshChildPath` (verify exact export names against the copied files and mirror them). + +- [ ] **Step 1: Copy the three engine files + their tests** + +```bash +B=origin/feat/ssh-support +cd /home/theo/projects/subshell +mkdir -p /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime/src/ssh/__tests__ +for f in ssh-spawn ssh-discover ssh-resolve; do + git show "$B:packages/pane-runtime/src/ssh/$f.ts" > /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime/src/ssh/$f.ts +done +``` + +Then open each copied file and confirm the ONLY external imports are `node:*`, `../…` within pane-runtime, or `@internal/subshell-protocol`. If a file imports anything from `ssh-session-*`, `ssh-render`, or the codec (the abandoned runtime path), those are NOT needed for discovery/resolve: remove the unused import and the symbols that use it, or note it for a follow-up. Record any such coupling before deleting. + +- [ ] **Step 2: Write the discovery failing test (real behavior, temp HOME)** + +Create `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts`: + +```ts +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverSshAliases } from "../ssh-discover.js"; + +let home: string; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), "ssh-discover-")); + mkdirSync(join(home, ".ssh"), { recursive: true, mode: 0o700 }); + writeFileSync(join(home, ".ssh", "config"), "Host alpha\n HostName a.example\nHost beta\n HostName b.example\nHost *\n User nobody\n"); +}); +afterAll(() => rmSync(home, { recursive: true, force: true })); + +describe("discoverSshAliases", () => { + it("lists concrete aliases and excludes the wildcard-only Host", () => { + const { aliases } = discoverSshAliases({ homeDir: home }); + expect(aliases).toEqual(["alpha", "beta"]); + expect(aliases).not.toContain("*"); + }); +}); +``` + +(Adapt the call signature to what `ssh-discover.ts` actually exports — read its top-level function before finalizing the assertion.) + +- [ ] **Step 3: Run the test to see it fail before wiring the barrel** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh/__tests__/ssh-discover.test.ts` +Expected: FAIL importing `../ssh-discover.js` if it needs a symbol not yet in the barrel, or PASS if self-contained. Either way the file runs. + +- [ ] **Step 4: Export the engines from the pane-runtime barrel** + +Append to `packages/pane-runtime/src/index.ts`: + +```ts +export * from "./ssh/ssh-spawn.js"; +export * from "./ssh/ssh-discover.js"; +export * from "./ssh/ssh-resolve.js"; +``` + +- [ ] **Step 5: Build + run the engines' tests** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/pane-runtime && cd packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh` +Expected: PASS. Resolve's test invokes `ssh -G`; keep it behind the sshd/binary guard used elsewhere (`missingSshBins`) or mark it `it.skip` if `ssh` is absent, matching the repo's existing conditional-test idiom. + +- [ ] **Step 6: Commit** + +```bash +git add packages/pane-runtime/src/ssh packages/pane-runtime/src/index.ts +git commit -m "feat(ssh): port OpenSSH config discovery and ssh -G resolve into pane-runtime" +``` + +--- + +## Task 3: Port the sshd e2e fixture + +**Files:** +- Create: `e2e/fixtures/sshd.ts` +- Test: nothing (fixture; exercised by later plans) + +**Interfaces:** +- Produces (from `e2e/fixtures/sshd`): `missingSshBins`, `SSH_FIXTURE_ALIAS`, `startSshFixture`, `type SshFixture`. + +- [ ] **Step 1: Copy the fixture and check its imports** + +```bash +B=origin/feat/ssh-support +cd /home/theo/projects/subshell +mkdir -p /home/theo/projects/wt-ssh-anywhere/e2e/fixtures +git show "$B:e2e/fixtures/sshd.ts" > /home/theo/projects/wt-ssh-anywhere/e2e/fixtures/sshd.ts +``` + +Open it: it must depend only on `node:*` + repo-internal e2e helpers (`../ports`, `../stack`) that already exist on `main`. Fix any import that pointed at a #330-only helper. + +- [ ] **Step 2: Typecheck the e2e package** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo verify-types --filter=@internal/e2e` +Expected: no new errors from `sshd.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/fixtures/sshd.ts +git commit -m "test(e2e): port the isolated sshd fixture" +``` + +--- + +## Task 4: Add the `nodes.ssh_enabled` column (migration + row type) + +**Files:** +- Create: `apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts` +- Modify: `apps/server/api/src/db/migrate.ts` +- Modify: `apps/server/api/src/db/types/nodes.db-types.ts` +- Test: `apps/server/api/src/db/__tests__/migration-0047.test.ts` + +**Interfaces:** +- Produces: `NodeTable.sshEnabled: number` (0/1), `NodeTable.sshEnabledAt: string | null`; `NewNode.sshEnabled?: number`. + +- [ ] **Step 1: Write the failing migration test** + +```ts +import { describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { testDatabase } from "@/__tests__/helpers/test-database.js"; + +describe("migration 0047 nodes.ssh_enabled", () => { + it("adds a not-null default-0 column and a nullable stamp", async () => { + const { db } = await testDatabase(); + const cols = await sql<{ name: string }>`select name from pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("ssh_enabled"); + expect(names).toContain("ssh_enabled_at"); + const row = await db.selectFrom("nodes").select(["ssh_enabled"]).where("id", "=", "local").executeTakeFirstOrThrow(); + expect(row.ssh_enabled).toBe(0); // default off, including local + }); +}); +``` + +(Confirm the helper module name/path `@/__tests__/helpers/test-database.js` against the existing `0031-node-maintenance` migration test and mirror it exactly.) + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/__tests__/migration-0047.test.ts` +Expected: FAIL (no such column / migration not registered). + +- [ ] **Step 3: Write the migration (copy the 0031 shape)** + +```ts +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +// Adds the per-node SSH capability gate. Default 0 keeps the whole fleet (and +// the control-plane host) off after an upgrade: SSH egress and key use are opt-in. +export async function up(db: Kysely): Promise { + await db.schema.alterTable("nodes").addColumn("ssh_enabled", (col) => col.integer().notNull().defaultTo(0)).execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", (col) => col.text()).execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("ssh_enabled").execute(); +} +``` + +- [ ] **Step 4: Register it in `migrate.ts`** + +Add the import and map entry beside the maintenance ones, keyed `"0047-node-ssh-enabled"`. + +- [ ] **Step 5: Add the row-type fields** + +In `nodes.db-types.ts`, next to `maintenance`/`maintenanceAt`, add `sshEnabled: number;` and `sshEnabledAt: string | null;` (with the property JSDoc the file uses) and `sshEnabled?: number;` on `NewNode`. + +- [ ] **Step 6: Run the test to confirm it passes** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/__tests__/migration-0047.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts apps/server/api/src/db/migrate.ts apps/server/api/src/db/types/nodes.db-types.ts apps/server/api/src/db/__tests__/migration-0047.test.ts +git commit -m "feat(nodes): add ssh_enabled capability column (default off) + migration" +``` + +--- + +## Task 5: `nodeCanSsh` predicate + +**Files:** +- Modify: `apps/server/api/src/lib/node-access.ts` +- Test: `apps/server/api/src/lib/__tests__/node-access.test.ts` (add a describe) + +**Interfaces:** +- Produces: `nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; granted: NodeAccess; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean`. + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("nodeCanSsh", () => { + it("refuses when the node's SSH gate is off, whoever the actor is", () => { + expect(nodeCanSsh({ kind: "agent", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: false })).toBe(false); + }); + it("refuses a non-owner even when enabled and shared", () => { + expect(nodeCanSsh({ kind: "agent", access: "edit", granted: "edit", serverAccountEnabled: true, sshEnabled: true })).toBe(false); + }); + it("allows an owner of an enabled agent node", () => { + expect(nodeCanSsh({ kind: "agent", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: true })).toBe(true); + }); + it("allows admin on an enabled local server only when server-as-node is on", () => { + expect(nodeCanSsh({ kind: "local", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: true })).toBe(true); + expect(nodeCanSsh({ kind: "local", access: "owner", granted: "owner", serverAccountEnabled: false, sshEnabled: true })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/lib/__tests__/node-access.test.ts` +Expected: FAIL (`nodeCanSsh` not exported). + +- [ ] **Step 3: Implement it beside `nodeCanLaunchOn`** + +```ts +/** Whether this node may be used for Subshell SSH at all (dial out or serve keys). + * Owner-only (matching who may broker an OS account's credentials), gated by the + * node's own ssh_enabled flag; local is the server account and also honors + * serverAccountEnabled. A distinct refusal from maintenance/no-launch, so the UI + * can say why. */ +export function nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; granted: NodeAccess; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean { + if (!opts.sshEnabled) return false; + if (opts.kind === "local") return opts.access === "owner" && opts.serverAccountEnabled; + return opts.access === "owner"; +} +``` + +- [ ] **Step 4: Run to confirm pass; commit** + +Run then: +```bash +git add apps/server/api/src/lib/node-access.ts apps/server/api/src/lib/__tests__/node-access.test.ts +git commit -m "feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabled)" +``` + +--- + +## Task 6: Plane write + view + `PUT /:id/ssh-enabled` route + +**Files:** +- Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` +- Modify: `apps/server/api/src/api/nodes/node-view.ts` +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` +- Modify: `apps/server/api/src/api/nodes/index.ts` +- Test: `apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.test.ts` + +**Interfaces:** +- Consumes (Task 4): row fields; (Task 5): nothing directly (view reports the flag, not the predicate). +- Produces: `repos.nodes.setSshEnabled(id, { on, changedAt }): Promise`; `NodeView.sshEnabled: boolean`; route `PUT /api/nodes/:id/ssh-enabled` body `{ on: boolean }` returning the node view. + +- [ ] **Step 1: Failing route test (owner on, non-owner 403/404, audit written, local admin-only)** + +Mirror the structure of the existing `set-node-maintenance` route test: create a member + a node they own; PUT `{on:true}` as owner → 200 and `sshEnabled:true`; PUT as a non-owner member → 404/403; PUT `local` as non-admin → refused; assert an audit row `node.ssh_enabled` with `{ on }` metadata and no secret. + +- [ ] **Step 2: Run to confirm it fails**, then **Step 3: implement `setSshEnabled` in the repository** (copy `setMaintenance`'s shape, only `ssh_enabled`/`ssh_enabled_at`, `ssh_enabled: on ? 1 : 0`). + +- [ ] **Step 4: add `sshEnabled` to the view** — in `NodeViewSchema` a `sshEnabled: t.Boolean({ description: "…" })`; in `nodeViewBase`, `sshEnabled: row.sshEnabled === 1`. Both list and detail flow through `nodeViewBase`, so one add lands both. + +- [ ] **Step 5: write the route** (copy `set-node-maintenance.route.ts`): + +```ts +const SshEnabledBodySchema = t.Object({ + on: t.Boolean({ description: "Allow this machine to be used for Subshell SSH (outbound ssh and serving its keys). Off by default. Owner only; admin on the control-plane host." }), +}); +// requireCookieActor -> loadNodeGate -> 404 if missing/invisible -> if (!gate.canManage) ForbiddenError +// -> await repos.nodes.setSshEnabled(id, { on, changedAt: new Date().toISOString() }) +// -> audit({ actorUserId: user.id, action: "node.ssh_enabled", targetType: "node", targetId: id, metadataJson: { on } }) +// -> void pushSetSshEnabled(id, { on, changedAt }) (Task 7; may be a no-op stub until then) +// -> respond toNodeView(row, access, isAdmin, granted) +``` + +Mount it in `api/nodes/index.ts` beside the maintenance route. + +- [ ] **Step 6: Run the route test to confirm pass**, then **rebuild backend so the Eden client sees the route**: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/server --filter=@internal/backend-client`. + +- [ ] **Step 7: Commit** (all touched files + the test). + +--- + +## Task 7: Node-side mirror file + `set_ssh_enabled` push + `ready` reporting (fail-closed) + +**Files:** +- Create: `apps/node/agent/src/ssh-enabled.ts` +- Create: `apps/node/agent/src/commands/set-ssh-enabled.ts` +- Modify: `apps/node/agent/src/commands/context.ts`, `.../report.ts`, `.../index.ts`, `apps/node/agent/src/daemon.ts` +- Modify: `packages/subshell-protocol/src/node-frames.ts`, `packages/subshell-protocol/src/index.ts` +- Modify: `apps/server/api/src/services/nodes/node-events.ts`, `apps/server/api/src/index.ts` +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` +- Test: `apps/node/agent/src/__tests__/ssh-enabled.test.ts` + +**Interfaces:** +- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`. + +- [ ] **Step 1: Failing node-side test** — `readSshEnabled` on an empty dir → `{kind:"absent"}`; after `writeSshEnabled({on:true})` → `{kind:"on"}`; a chmod-000 (or unreadable) file → `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". + +- [ ] **Step 2: Run to confirm fail.** + +- [ ] **Step 3: Implement `apps/node/agent/src/ssh-enabled.ts`** mirroring `maintenance.ts` exactly (same file-permission doctrine 0600 temp+rename, same `MaintenanceRead`-shaped result with `kind`, the `unreadable` fail-closed arm), but: default/absent means **off**, and there is **no self-toggle** (plane is the only writer). + +- [ ] **Step 4: Protocol arms** in `node-frames.ts`: `interface NodeSshEnabledWire { on: boolean; changedAt: string }`, `parseNodeSshEnabled`, a `set_ssh_enabled` command arm, an `ssh_enabled` event arm, and `ready` gains `sshEnabled?: NodeSshEnabledWire` (drop-not-fatal when malformed, like `maintenance`). Re-export from `index.ts`. + +- [ ] **Step 5: Agent executor** `commands/set-ssh-enabled.ts` mirroring `set-maintenance.ts`: write the mirror file, set `ctx.lastReportedSshEnabled`, return `{ ok: true }`. Dispatch it in `commands/index.ts`. + +- [ ] **Step 6: Reporting** — in `report.ts` add `reportSshEnabled(ctx, on)`, `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. + +- [ ] **Step 7: Plane reconcile** — `services/nodes/ssh-enabled.ts`: `setNodeSshEnabled` (row write + audit + best-effort push), `pushSetSshEnabled` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. + +- [ ] **Step 8: Run all touched tests + build**: +`cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd apps/node/agent && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-enabled.test.ts && cd ../../server/api && env -u SHELLOPTS -u BASHOPTS bun test src/services/nodes` +Expected: PASS; node defaults off/unreadable-refuse; local push is a no-op. + +- [ ] **Step 9: Commit.** + +--- + +## Task 8: Tier-level verification + changelog + +- [ ] **Step 1: Full boundary suite** (per `verification.md`): + +```bash +cd /home/theo/projects/wt-ssh-anywhere +bun run verify-types +bun run lint:check +bun run lint:prose +bunx turbo build +``` + +Expected: all green. `verify-types` is what proves the port engines' barrel edits and the new protocol arms typecheck across dependents. + +- [ ] **Step 2: Focused suite counts** — run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). + +- [ ] **Step 3: Changeset** — add a minor changeset noting the new `nodes.ssh_enabled` gate (migration 0047) and the ported ssh primitives, so release tooling sees the schema change. + +- [ ] **Step 4: Commit** and open the stacked PR: + +```bash +git push --no-verify -u origin HEAD +gh pr create --base feat/ssh-anywhere --title "SSH anywhere 1/3: foundations (ssh_enabled gate + ported primitives)" --body "..." +``` + +--- + +## Self-Review (author, after writing) + +- **Spec coverage:** Plan 1 covers spec §4.3 (the gate), §7 (`ssh_enabled` column), §15 (port grammar + engines + fixture), and the `nodeCanSsh` predicate from §5/§13. It does NOT cover the destination-first UI (§11), the terminal launch, or host-key pinning — those are Plans 2 and 3, by design. No M2 (relay) items leaked in. +- **Type consistency:** the wire type name is `NodeSshEnabledWire` everywhere; the repo method is `setSshEnabled`; the predicate is `nodeCanSsh`; the audit action is `node.ssh_enabled`; the migration is `0047`. Confirm each before building on it in Plan 2. +- **Placeholders:** every "copy from `maintenance.ts`"/`git show` step names the exact source; new code is shown in full. Where a step says "adapt to the actual export name/`test-database` helper path," that is a deliberate verify-against-source instruction, not a content gap — resolve it during the task, do not leave a stub. diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index e6e9af4ff..db6fbdba2 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -3,7 +3,11 @@ Status: design, for review. Dated 2026-10-07. Branch: `feat/ssh-anywhere`, cut from `origin/main`. This supersedes the approach on `feat/ssh-support` (PR #330), which is left open and unmerged as a reference -for the brokered-session transport, not as a base. +for the brokered-session transport, not as a base. It also supersedes the prior +`SSH-SUPPORT.md` contract (runtime-on-destination, keys staying only local), which +has been removed and no longer applies: this session's direction (terminal to any +host, runtime as an optional upgrade, cross-machine keys via a sealed relay) is the +contract. ## 1. Problem and goal From 94af16f27ed9bfbb7e0be34630413e4c4824aa10 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 00:59:25 -0700 Subject: [PATCH 005/170] docs(ssh-anywhere): strip em dashes from Plan 1 (lint:prose) Co-Authored-By: Claude --- .../2026-10-07-ssh-anywhere-1-foundations.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md index b70a01d9a..598eebcb3 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -1,4 +1,4 @@ -# SSH Anywhere — Plan 1: Foundations (gate + ported primitives) Implementation Plan +# SSH Anywhere - Plan 1: Foundations (gate + ported primitives) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. @@ -84,12 +84,12 @@ git show "$B:packages/subshell-protocol/src/__tests__/ssh-config.test.ts" > pack grep -nE "from \"node:" packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-limits.ts # MUST print nothing ``` -Expected: the `grep` prints nothing (these two are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead — stop and re-scope. +Expected: the `grep` prints nothing (these two are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. - [ ] **Step 2: Run the ported grammar test to confirm the copy is complete** Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-config.test.ts` -Expected: the test imports from `../ssh-config.js`; it FAILS with "Cannot find module `../ssh-limits.js`" only if a symbol is missing — otherwise it may already pass because the two files are self-contained. If it references symbols that live in files NOT yet ported (e.g. `ssh-frames`), trim the test to the symbols actually in these two files for now, or port those symbols too. Record what the test needs. +Expected: the test imports from `../ssh-config.js`; it FAILS with "Cannot find module `../ssh-limits.js`" only if a symbol is missing - otherwise it may already pass because the two files are self-contained. If it references symbols that live in files NOT yet ported (e.g. `ssh-frames`), trim the test to the symbols actually in these two files for now, or port those symbols too. Record what the test needs. - [ ] **Step 3: Barrel-export the grammar** @@ -182,7 +182,7 @@ describe("discoverSshAliases", () => { }); ``` -(Adapt the call signature to what `ssh-discover.ts` actually exports — read its top-level function before finalizing the assertion.) +(Adapt the call signature to what `ssh-discover.ts` actually exports - read its top-level function before finalizing the assertion.) - [ ] **Step 3: Run the test to see it fail before wiring the barrel** @@ -404,7 +404,7 @@ Mirror the structure of the existing `set-node-maintenance` route test: create a - [ ] **Step 2: Run to confirm it fails**, then **Step 3: implement `setSshEnabled` in the repository** (copy `setMaintenance`'s shape, only `ssh_enabled`/`ssh_enabled_at`, `ssh_enabled: on ? 1 : 0`). -- [ ] **Step 4: add `sshEnabled` to the view** — in `NodeViewSchema` a `sshEnabled: t.Boolean({ description: "…" })`; in `nodeViewBase`, `sshEnabled: row.sshEnabled === 1`. Both list and detail flow through `nodeViewBase`, so one add lands both. +- [ ] **Step 4: add `sshEnabled` to the view** - in `NodeViewSchema` a `sshEnabled: t.Boolean({ description: "…" })`; in `nodeViewBase`, `sshEnabled: row.sshEnabled === 1`. Both list and detail flow through `nodeViewBase`, so one add lands both. - [ ] **Step 5: write the route** (copy `set-node-maintenance.route.ts`): @@ -441,7 +441,7 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. **Interfaces:** - Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`. -- [ ] **Step 1: Failing node-side test** — `readSshEnabled` on an empty dir → `{kind:"absent"}`; after `writeSshEnabled({on:true})` → `{kind:"on"}`; a chmod-000 (or unreadable) file → `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". +- [ ] **Step 1: Failing node-side test** - `readSshEnabled` on an empty dir → `{kind:"absent"}`; after `writeSshEnabled({on:true})` → `{kind:"on"}`; a chmod-000 (or unreadable) file → `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". - [ ] **Step 2: Run to confirm fail.** @@ -451,9 +451,9 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. - [ ] **Step 5: Agent executor** `commands/set-ssh-enabled.ts` mirroring `set-maintenance.ts`: write the mirror file, set `ctx.lastReportedSshEnabled`, return `{ ok: true }`. Dispatch it in `commands/index.ts`. -- [ ] **Step 6: Reporting** — in `report.ts` add `reportSshEnabled(ctx, on)`, `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. +- [ ] **Step 6: Reporting** - in `report.ts` add `reportSshEnabled(ctx, on)`, `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. -- [ ] **Step 7: Plane reconcile** — `services/nodes/ssh-enabled.ts`: `setNodeSshEnabled` (row write + audit + best-effort push), `pushSetSshEnabled` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. +- [ ] **Step 7: Plane reconcile** - `services/nodes/ssh-enabled.ts`: `setNodeSshEnabled` (row write + audit + best-effort push), `pushSetSshEnabled` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. - [ ] **Step 8: Run all touched tests + build**: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd apps/node/agent && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-enabled.test.ts && cd ../../server/api && env -u SHELLOPTS -u BASHOPTS bun test src/services/nodes` @@ -477,9 +477,9 @@ bunx turbo build Expected: all green. `verify-types` is what proves the port engines' barrel edits and the new protocol arms typecheck across dependents. -- [ ] **Step 2: Focused suite counts** — run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). +- [ ] **Step 2: Focused suite counts** - run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). -- [ ] **Step 3: Changeset** — add a minor changeset noting the new `nodes.ssh_enabled` gate (migration 0047) and the ported ssh primitives, so release tooling sees the schema change. +- [ ] **Step 3: Changeset** - add a minor changeset noting the new `nodes.ssh_enabled` gate (migration 0047) and the ported ssh primitives, so release tooling sees the schema change. - [ ] **Step 4: Commit** and open the stacked PR: @@ -492,6 +492,6 @@ gh pr create --base feat/ssh-anywhere --title "SSH anywhere 1/3: foundations (ss ## Self-Review (author, after writing) -- **Spec coverage:** Plan 1 covers spec §4.3 (the gate), §7 (`ssh_enabled` column), §15 (port grammar + engines + fixture), and the `nodeCanSsh` predicate from §5/§13. It does NOT cover the destination-first UI (§11), the terminal launch, or host-key pinning — those are Plans 2 and 3, by design. No M2 (relay) items leaked in. +- **Spec coverage:** Plan 1 covers spec §4.3 (the gate), §7 (`ssh_enabled` column), §15 (port grammar + engines + fixture), and the `nodeCanSsh` predicate from §5/§13. It does NOT cover the destination-first UI (§11), the terminal launch, or host-key pinning - those are Plans 2 and 3, by design. No M2 (relay) items leaked in. - **Type consistency:** the wire type name is `NodeSshEnabledWire` everywhere; the repo method is `setSshEnabled`; the predicate is `nodeCanSsh`; the audit action is `node.ssh_enabled`; the migration is `0047`. Confirm each before building on it in Plan 2. -- **Placeholders:** every "copy from `maintenance.ts`"/`git show` step names the exact source; new code is shown in full. Where a step says "adapt to the actual export name/`test-database` helper path," that is a deliberate verify-against-source instruction, not a content gap — resolve it during the task, do not leave a stub. +- **Placeholders:** every "copy from `maintenance.ts`"/`git show` step names the exact source; new code is shown in full. Where a step says "adapt to the actual export name/`test-database` helper path," that is a deliberate verify-against-source instruction, not a content gap - resolve it during the task, do not leave a stub. From 7e98924c4c75ca8a0484052c12584f5ec1dd765d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:04:01 -0700 Subject: [PATCH 006/170] docs(ssh-anywhere): spec review round 1 - fix anti-plane-compromise claim, machine-identity gap, grant/pin phasing, SSH_AUTH_SOCK + sharing, gate semantics Co-Authored-By: Claude --- .../specs/2026-10-07-ssh-anywhere-design.md | 515 ++++++++++-------- 1 file changed, 302 insertions(+), 213 deletions(-) diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index db6fbdba2..12dfebfcb 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -2,25 +2,23 @@ Status: design, for review. Dated 2026-10-07. Branch: `feat/ssh-anywhere`, cut from `origin/main`. This supersedes the approach -on `feat/ssh-support` (PR #330), which is left open and unmerged as a reference -for the brokered-session transport, not as a base. It also supersedes the prior -`SSH-SUPPORT.md` contract (runtime-on-destination, keys staying only local), which -has been removed and no longer applies: this session's direction (terminal to any -host, runtime as an optional upgrade, cross-machine keys via a sealed relay) is the -contract. +on `feat/ssh-support` (PR #330), left open and unmerged as a reference for the +brokered-session transport, not as a base. It also supersedes the prior +`SSH-SUPPORT.md` contract (runtime-on-destination, keys only ever local), now +removed: terminal to any host, runtime as an optional upgrade, cross-machine keys +via a sealed relay is the direction. ## 1. Problem and goal -From Subshell I want to open an interactive session against an arbitrary host -over SSH. The host may be an enrolled Subshell node or it may be nothing more -than a box running sshd; it must never be required to run Subshell in order to -be reachable. The SSH identity that authenticates the connection may live on a -different machine than the one that actually dials the host, and in that case -the private key must never leave its home and the control plane must never see -key material or plaintext authentication challenges. +From Subshell I want to open an interactive session against an arbitrary host over +SSH. The host may be an enrolled node or just a box running sshd; it must never be +required to run Subshell to be reachable. The SSH identity may live on a different +machine than the one dialing the host; in that case the private key must never +leave its home and the control plane must never see key material or plaintext +authentication challenges. The plain SSH terminal is the primary product. Turning the host into a full -Subshell agent destination is a secondary act layered on the same connection. +Subshell agent destination is a secondary act on the same connection. ## 2. Non-goals @@ -28,246 +26,322 @@ Subshell agent destination is a secondary act layered on the same connection. - Not a secret store: Subshell never custodies, imports, or persists an SSH private key. - Not multi-tenant hardening. Subshell is a single-operator, trusted-network - service (`docs/security.md` section 0); grants name one operator's machines. -- Not replacing node enrollment. Reachable-by-enrollment and reachable-by-SSH - are two doors to the same pane machinery. + service (`docs/security.md` section 0); every machine involved is the operator's. +- Not replacing node enrollment. Reachable-by-enrollment and reachable-by-SSH are + two doors to the same pane machinery. ## 3. Terminology and topology - **D** the destination: any host with an sshd. Holds no Subshell secret. May or may not be an enrolled node. -- **B** the connecting machine: the node or Subshell Client broker that runs the - `ssh` process and hosts the pane. -- **A** the key home: the machine whose `ssh-agent` holds the key that - authenticates D. A laptop broker or another node. -- **The plane** the Subshell control plane. Nodes and brokers only ever meet - each other through it; it is the sole transport between A and B. - -A pane is the unit of value: a tmux-backed PTY on the connecting machine, -streamed, shareable, and logged exactly like any other Subshell pane. +- **B** the connecting machine: a node (a machine running the Subshell node agent, + which is how a laptop participates) that runs the `ssh` process and hosts the + pane. +- **A** the key home: the machine whose `ssh-agent` holds the key authenticating + D. Often B itself; sometimes a different node. +- **J** a jump hop: an intermediate host that `ssh -J` tunnels through. +- **The plane** the Subshell control plane. Nodes only ever meet each other through + it for Subshell traffic; raw ssh between a node and a host is direct and does not + route through the plane. + +A pane is the unit of value: a tmux-backed PTY on the connecting machine, streamed, +shareable, and logged like any other Subshell pane (section 5.4 states the sharing +caveat when that pane's ssh can drive an agent). ## 4. Connection modes -One launcher, two modes, chosen per launch by a key grant (section 5.1). +One launcher, two modes, chosen by where the signing key lives. If the connecting +machine B holds the key, ssh runs there directly. If the key lives on another +machine A, the choice is jump mode (origin A dials out; pane on A) or relay mode +(pane on B; signed by A's agent over a sealed shuttle; section 8; an M2 +capability). In Milestone 1 the key source is always the connecting machine itself, +so relay and the grants that drive it (section 5.1) are out of scope. ### 4.1 Jump mode (A is the connecting machine) -A runs `ssh -J B D`. The key and the agent stay on A; B is a network-only jump -that never sees the key; the pane lives on A. This is stock OpenSSH +A runs `ssh -J D`. The key and agent stay on A; the hop is a network-only +relay that never sees the key; the pane lives on A. This is stock OpenSSH `ProxyJump`, needs no new crypto, and is plane-blind because no key material or -challenge crosses the plane at all. Use it when the reason to involve B is purely -that B can reach D's network while A cannot, and it is acceptable for the pane to -run on A. +challenge crosses the plane. Use it when the only reason to involve the hop is that +it can reach D's network while A cannot, and it is acceptable for the pane to run +on A. ### 4.2 Relay mode (B is the connecting machine) -B runs the pane and the `ssh D` process, and authenticates to D through A's agent -over a sealed agent relay (section 8). The key never leaves A; the plane relays -only sealed sign requests it cannot read; the pane lives on B, on server-grade -compute, and persists after A disconnects. Use it when the session must run and -survive on B (or on D as a Subshell destination) rather than on the laptop. +B runs the pane and the `ssh D` process and authenticates to D through A's agent +over a sealed agent relay (section 8). The key never leaves A; the plane relays only +sealed requests it cannot read; the pane lives on B, on server-grade compute, and +persists after A disconnects. Use it when the session must run and survive on B, or +on D as a Subshell destination, rather than on the laptop. -Relay mode requires A to be online and connected for the duration of the -handshake. That is the accepted price of the key never moving; an offline A is a -clear error, never a silent fallback to copying the key. +Relay mode requires A online and connected for the duration of the handshake. That +is the accepted price of the key never moving; an offline A is a clear error, never +a silent fallback to copying the key. ### 4.3 Node SSH capability gate -A machine's participation in Subshell SSH at all (dialing out over SSH, or -serving its key/agent) is opt-in per node and **off by default, including the -`local` server node**. The node carries one admin-controlled flag, `ssh_enabled`; -a node with it off refuses every SSH role. Turning it on is a real widening of -that machine's egress and key exposure, so the gate fails **closed**: a node that -cannot read its own setting refuses, the opposite of the directory allowlist's -deliberate fail-open, because here the risk is what enabling lets Subshell do. - -Enabling is an owner action (admin for `local`), audited, and mirrors the -maintenance flag (plane and node agree, newer stamp wins). When a user needs a -machine whose flag is off, the launch is refused with the remedy named in the -copy: ask the owner of that machine to enable SSH on it. The gate is per role: in -relay mode both the connecting machine (which dials out) and the key home (whose -agent signs) must be enabled; in jump mode the origin must be enabled; the -destination `D` needs no flag, it is merely reached. +A machine's participation in Subshell SSH at all (dialing out over SSH, or serving +its key/agent) is opt-in per node and **off by default, including the `local` +server node**. The node carries one owner-controlled flag (admin on `local`), +`ssh_enabled`; a node with it off refuses every SSH role. Turning it on is a real +widening of that machine's egress and key exposure, so the gate fails **closed**: a +node that cannot read its own setting refuses, the opposite of the directory +allowlist's deliberate fail-open, because here the risk is what enabling lets +Subshell do. + +The flag is plane-authored; there is no node self-toggle CLI, so the node caches +the plane's value only to fail closed (no two-writer reconcile, unlike +maintenance, which the node can also set). Enabling is owner-only (admin on +`local`), audited. When a user needs a machine whose flag is off, the launch is +refused with the remedy named in copy: ask the owner of that machine to enable SSH +on it. The gate is per role: relay mode needs both the connecting machine (dials +out) and the key home (whose agent signs) enabled; jump mode needs only the origin +A (which dials out and serves its key) enabled; a jump hop `J` and the destination +`D` are dialed-to, not gated. ## 5. Architecture components -### 5.1 Key grant - -An owner-scoped, plane-stored record that says: to reach these destinations, sign -with the agent on machine A. It carries a name, the key-home node id, a -destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy, unrelated to the wildcard patterns discovery drops), the pinned host key -for D (section 9), an optional "ask me each time" approval flag, and audit -metadata. It stores no secret and no key bytes. Grants are cheap because the -operator is the only party. Writes are audited in the ssh/nodes family, naming ids -and host only, never any credential. +### 5.1 Key grant (M2) + +An owner-scoped, plane-stored record (an M2 concept; M1 needs none because the key +source is always the connecting machine): to reach destinations matching a +selector, sign with the agent on machine A. It carries a name, the key-home node +id, and a destination selector (an alias, a concrete host, or a host glob matched +against the resolved destination hostname, as policy only, unrelated to the +wildcard patterns discovery drops). It stores no secret and no key bytes. Host-key +pins are NOT part of a grant: they are stored per resolved destination +`user@host:port` (section 9), so a selector naming many hosts has many pins, never +one. An "ask me each time" approval flag is deferred with the section 16 approval UX +and stored only once that design lands. Writes are audited as +`node.ssh_grant.create|update|delete` (new names to add to `docs/security.md` +section 10), naming ids and host only, never any credential. ### 5.2 SSH launcher A single plane service that composes the `ssh` invocation toward the chosen connecting machine and mode. It reuses the OpenSSH-based resolution already -specified here: read concrete aliases from `~/.ssh/config`, resolve an alias with -`ssh -G`, and render a per-connection config from the resulting snapshot (see -section 15 for which of these are ported). Every token in the built command is -passed through `shellQuote`; the connection is one shell string through tmux, so -quoting is the load-bearing defense and is never relaxed. +specified: read concrete aliases from `~/.ssh/config`, resolve an alias with +`ssh -G`, and render a per-connection config from the resulting snapshot (section +15 states which are ported). Discovery and resolve run ON the connecting machine +via `sendCommand` (the signed, replay-fenced node command transport, present on +`main`); alias resolution is a user-initiated action, never run during discovery. +Every token in the built command is passed through `shellQuote`; the connection is +one shell string through tmux, so quoting is the load-bearing defense and is never +relaxed. ### 5.3 Sealed agent relay (Milestone 2) -The one genuinely new subsystem, and it is encrypted channels carrying the SSH -agent protocol. On B, a local Unix socket speaks the `ssh-agent` wire and is -exported as `SSH_AUTH_SOCK` into the pane's `ssh`; requests arriving on it are -sealed end-to-end to A and shipped through the plane. On A, a responder accepts -only requests the plane brokered for a live grant, checks policy, forwards them to -A's real agent, and seals the signature back. The plane routes by the recipient id -in the envelope header without opening anything. Detail in section 8. +The one genuinely new subsystem: encrypted channels carrying the SSH agent +protocol. On B, a local Unix socket speaks the `ssh-agent` wire and is exported as +`SSH_AUTH_SOCK` into the pane's `ssh`; requests arriving on it are sealed +end-to-end to A and shipped through the plane. On A, a responder accepts only +requests the plane brokered for a live grant, enforces the agent method allow-list +(section 8), forwards permitted requests to A's real agent, and seals the response +back. The plane routes by the recipient id in the envelope header without opening +anything. Detail in section 8. + +This is not "the primitives used as is." It needs a machine-to-machine identity +roster and a TOFU pin flow that do not exist today: nodes already hold a +registration ECDH-ES keypair (`apps/node/agent/src/identity.ts`, private half never +leaves the machine), but `identities.route` serves only per-principal (`sess:` / +`user:`) keys, so there is no way today for A to learn and pin B's machine key. +Building that roster and first-use pinning, and deciding whether the relay obeys +`SUBSHELL_CHANNEL_PIN` or is always strict, is part of the M2 work. ### 5.4 Terminal pane A launch of the `terminal` harness whose command is the launcher's `ssh` invocation, on the connecting machine, under the tmux PTY. Streaming, input, -capture, logging, and sharing are the ordinary pane path and need no new code. The -far side needs only sshd. +capture, logging, and sharing are the ordinary pane path. The far side needs only +sshd. + +To use the connecting machine's own agent, the launcher must pass `SSH_AUTH_SOCK` +into the ssh process: the pane env allowlist (`pane-runtime/src/launch.ts` +`curatedEnv`: `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`, `LANG`, +`LC_ALL`, plus `CLAUDE_PATH`) deliberately omits it, so this is a scoped exception +applied to the `ssh` invocation only, not the whole pane env. In relay mode the +socket is B's proxy, not A's real agent. + +Sharing caveat: a pane whose ssh can drive an agent must not be handed to a viewer +who can type into it, since typing `ssh ` from that shell would use the +agent. So an SSH-terminal pane is **view-only to sharees** (input requires the +owner); an explicit "this pane can sign" affordance is a possible M2 refinement. ### 5.5 Agent-destination upgrade A secondary act on an open session: "Set up Subshell here" boots the Subshell -runtime on D over an authenticated connection using the same key grant and mode. -This is where the brokered-session machinery from #330 becomes relevant, and it is -a consumer of the shared launcher and relay rather than a separate product. It is -out of Milestone 1 scope and revisited in the Milestone 2 spec. +runtime on D over an authenticated connection using the same launcher and key +source. This is where the brokered-session machinery from #330 becomes relevant, as +a consumer of the shared launcher rather than a separate product. Out of Milestone 1 +scope; revisited in the Milestone 2 spec. ## 6. Phasing ### 6.1 Milestone 1: terminal and jump (the mergeable first slice) Port the neutral primitives, then ship: SSH-terminal panes to any D regardless of -node status, alias discovery and `ssh -G` resolve, the destination-first launch -flow (section 11), host-key pinning, Jump mode end to end, the per-node SSH +node status; alias discovery and `ssh -G` resolve; the destination-first launch +flow (section 11); destination host-key trust via the connecting machine's own +`~/.ssh/known_hosts` (stock OpenSSH `StrictHostKeyChecking`; the grant-pinned, +cross-box pinning of section 9 is M2); Jump mode end to end; the per-node SSH capability gate (section 4.3, off by default including `local`, owner-enabled, -fail-closed, audited), and audits. No session-frame codec, supervisor, or -runtime-serve in this milestone. Delivers the whole "SSH into any machine" goal -for every case that does not require the session to run on B with a key that -lives only on A. +fail-closed, audited); and audits. No session-frame codec, supervisor, or +runtime-serve in this milestone. Delivers "SSH into any machine" for every case +that does not require the session to run on B with a key that lives only on A. ### 6.2 Milestone 2: the sealed agent relay (own spec, own review) -Relay mode, the grant/authorization machinery that drives it, and the optional -agent-destination upgrade. A focused security review pass is expected here; it is -the risky core and is deliberately isolated behind a clean seam the M1 launcher -exposes. +Relay mode, the grant/authorization machinery and machine-identity roster that +drive it, and the optional agent-destination upgrade. A focused security review +pass is expected here; it is the risky core and is deliberately isolated behind a +clean seam the M1 launcher exposes. ## 7. Data model - `ssh_enabled` (default `false`, including `local`): the per-node capability gate - of section 4.3, stored in node config and mirrored to the plane like - maintenance (newer stamp wins), owner/admin to change, audited as - `node.ssh_enabled`. It is a node setting, not a new table. -- `ssh_key_grants`: id, owner, name, key-home node id, destination selector, - pinned D host key (public), approval flag, created/updated. No secrets. + of section 4.3. Plane-authored row value, cached in a node-side mirror file the + node reads only to fail closed; owner/admin to change; audited as + `node.ssh_enabled`. Not a new table (a column on `nodes`). +- `ssh_key_grants` (M2): id, owner, name, key-home node id, destination selector. + No secrets, no keys. +- `ssh_host_pins` (M2): keyed by resolved destination `user@host:port`, the pinned + public host key. One pin per resolved destination, independent of grants. - Relay session state (M2, in-memory on the plane with a durable audit row): a - brokered pairing of grant, A, B, the pane/subshell id, an opaque routing ref, - and a lifetime. The ssh-agent payload (challenge, signature, target host, - identity) lives only inside sealed envelopes, never in this state. -- Panes reuse the existing `subshells` rows; a connecting-machine-only SSH pane - is an ordinary row on B (or A in jump mode). Nothing about the key is written - there. + brokered pairing of grant, A, B, the pane/subshell id, an opaque routing ref, and + a lifetime. The agent payload (challenge, signature, and which of A's keys within + the filtered identity list; there is no destination field in an agent sign + request) lives only inside sealed envelopes, never in this state. +- Panes reuse the existing `subshells` rows; a connecting-machine-only SSH pane is + an ordinary row on B (or A in jump mode). Nothing about the key is written there. ## 8. Relay protocol and security (M2) - Sealing and pinning reuse `packages/mcp-core/src/crypto.ts` (`seal`/`open`, - ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing) - and `packages/mcp-core/src/pin-store.ts` (TOFU, byte-equality, `peers.json`). - The envelope the plane stores and routes is the same opaque blob the encrypted + ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing) and + `packages/mcp-core/src/pin-store.ts` (TOFU, byte-equality, `peers.json`). The + envelope the plane stores and routes is the same opaque blob the encrypted channels already relay. - The plane brokers a relay pairing when a grant is used: it tells A to expect - sealed requests for this pane/host, and B that its agent socket backs onto A. A - routing id rides in the envelope for plane-side pairing, quota, and lifetime; - the sensitive agent payload rides inside the ciphertext. -- Origin: to be accepted by A, a request must seal under B's identity key, which - A has pinned TOFU. The plane holds no machine's private ECDH key, so it cannot - mint a request A will accept as coming from B. Sealing therefore supplies origin - authentication, not just confidentiality. -- Scope of signing: the grant pins one key identity so only that key can be used. - SSH agent signing requests carry no destination, so a grant cannot bind the - agent to a host at the protocol layer; the residual risk (a compromised B using - the granted key against other hosts that key can reach) is accepted under the - single-operator posture and bounded by key-scoping plus instant revoke. + sealed requests for this pane, and B that its agent socket backs onto A. A routing + id rides in the envelope header for plane-side pairing, quota, and lifetime; the + agent payload rides inside the ciphertext. +- Agent method allow-list: the responder forwards only `REQUEST_IDENTITIES` and the + sign/authenticate methods OpenSSH needs. It refuses `ADD_IDENTITY`, + `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted handshake + window cannot mutate or lock A's agent against its other panes. + `REQUEST_IDENTITIES` is answered with A's keys filtered to the grant's pinned + identity, not the whole list. +- Replay posture: because the plane stores envelopes, a byte-identical envelope + could be resent within the handshake window; a nonce/freshness inside the + ciphertext, bound to the relay session, makes such replays inert. +- Origin: to be accepted by A, a request must seal under B's machine key, which A + has pinned TOFU. The plane holds no machine's private ECDH key, so after that pin + it cannot mint a request A accepts as B; sealing supplies origin authentication, + not just confidentiality. This rests on the machine-identity roster and pin flow + named in section 5.3, and carries the pin-establishment window described in + section 10. +- Scope of signing: SSH agent sign requests carry no destination, so a grant cannot + bind the agent to a host at the protocol layer; the residual risk (a compromised B + using the granted key against other hosts that key can reach) is accepted under + the single-operator posture and bounded by key-scoping plus instant revoke. - No forwarding onward: B authenticates to D using A's agent but does not run - `ssh -A` toward D. A's agent is used for the handshake and is never exposed on - D, which is strictly safer than stock agent forwarding. + `ssh -A` toward D. A's agent is used for the handshake and is never exposed on D, + strictly safer than stock agent forwarding. - Handshake-window lifetime: an SSH agent is needed only at connection - establishment. The relay proxy socket on B is created for the handshake, sealed - traffic flows for seconds, then the socket is unbound and the relay pairing is - torn down while the established pane keeps running on its own transport. This is - the "discard immediately after connecting" intent, achieved without the key ever - moving. A fresh re-authentication that needs the agent again re-opens the relay, - requiring A online at that moment. + establishment (rekeying re-verifies the host key, not the client agent). The relay + proxy socket on B is created for the handshake, sealed traffic flows for seconds, + then the socket is unbound and the pairing torn down while the established pane + keeps running on its own transport. This is the "discard immediately after + connecting" intent, achieved without the key ever moving. A fresh re-authentication + re-opens the relay, requiring A online at that moment. ## 9. Host-key trust -Trust for D follows the key-holder, not the connecting box. When a grant is -created from A, A's trust in D's host key is captured (trust-on-first-use from -A's `~/.ssh/known_hosts`, or an explicit pin) and stored as the grant's pinned -public host key. In relay mode, B's ssh validates D against a rendered -`UserKnownHostsFile` containing that pin with `StrictHostKeyChecking=yes`; B's own -`known_hosts` is not the authority. A changed D host key is a hard block, matching -the pin doctrine elsewhere in the threat model. +Trust for D follows the key-holder, not the connecting box, and pins are stored per +resolved destination `user@host:port` (never one per grant). -## 10. Trust model delta versus section 0 +- M1: the connecting machine is the key home, so this is plain OpenSSH. D is + validated against the connecting machine's own `~/.ssh/known_hosts` with + `StrictHostKeyChecking`. +- M2 (relay): when a grant is created, A's trust in a destination's host key is + captured (TOFU from A's `~/.ssh/known_hosts`, or an explicit pin) and stored as + `ssh_host_pins` against that resolved destination. B's ssh then validates D + against a rendered `UserKnownHostsFile` containing the pin with + `StrictHostKeyChecking=yes`; B's own `known_hosts` is not the authority. + +A changed host key is a byte-equality hard block, matching the pin doctrine elsewhere +in the threat model. -Gained: the plane never holds, sees, or can be shown an SSH private key or a -plaintext sign challenge; compromising the plane cannot exfiltrate a key or forge -a request A will honor; D never receives a forwarded agent. +## 10. Trust model delta versus section 0 -Unchanged or accepted: the OS-user boundary on A and B is still the operator -(`docs/security.md` section 0); a machine you run the agent on is a machine you -trust. A already-compromised B can drive its granted relay during the handshake. -The plane retains what it always had: denial of service, and metadata (which -machines are talking, timing, sizes). Replacing the plane's identity material -remains the manual operation it is today. +Gained: the plane never holds or sees an SSH private key or a plaintext sign +challenge; a plane compromised AFTER A first pinned B cannot forge a request A will +honor (TOFU origin); D never receives a forwarded agent. + +The word "after" is load-bearing. The TOFU pin between two machines is established +through the plane, so a plane compromised BEFORE A's first pin of B can substitute +its own key. That is exactly the first-use window `pin-store.ts` documents for +channels (a compromised server swapping a key into a roster slot, undetectable to +the sender) and the reason that module exists. Closing it needs an out-of-band pin or +a first-secure-contact story, which is M2 design, and `docs/security.md` section 0 +already lists control-plane compromise as not-defended. The relay inherits +`pin-store`'s strictness and its `SUBSHELL_CHANNEL_PIN` escape unless M2 says +otherwise. + +Unchanged or accepted: the OS-user boundary on A and B is still the operator; a +machine you run the agent on is a machine you trust. A already-compromised B can +drive its granted relay during the handshake (bounded by the method allow-list and +key-scoping, section 8). The plane retains what it always had: denial of service, +and metadata (which machines talk, timing, sizes, and the plaintext routing ids on +sealed envelopes, which reveal no key material). ## 11. UI -The surface is designed fresh; #330's connect UX is intentionally not carried -over. That flow led with "where do you connect from," offered several overlapping -ways to pick a target at once (connect-from, connected hosts, saved locations, -recent, a manage link), and implied a Subshell runtime on the far side (folder, -agent, and preset pickers) even for what should be a plain shell. It read as -confusing, so we design around its failure modes rather than patch it. +The surface is designed fresh; #330's connect UX is intentionally not carried over. +That flow led with "where do you connect from," offered several overlapping ways to +pick a target at once (connect-from, connected hosts, saved locations, recent, a +manage link), and implied a Subshell runtime on the far side (folder, agent, and +preset pickers) even for what should be a plain shell. It read as confusing, so we +design around its failure modes rather than patch it. The job is one question, "where do you want to work?", so the flow leads with the destination: -1. A single searchable destination field, fed by the connecting box's - `~/.ssh/config` plus saved and recent hosts (the discovery idea, reused). Pick - or type a host. -2. Origin and key source are asked only when they are genuinely ambiguous, as one - progressive follow-up ("connect via [machine]" or "sign with [machine]'s - keys"), never a mandatory first step. The common case just opens. +1. A single searchable destination field. The connecting box defaults to the user's + chosen default connecting machine (the one used unless step 2 changes it), and + the field is fed by that box's `~/.ssh/config` (via `sendCommand`, section 5.2) + plus saved and recent hosts (the discovery idea, reused). Pick or type a host. +2. Choosing a different connecting machine, or a key source on another machine + ("sign with [machine]'s keys"), is a progressive follow-up shown only when the + default cannot do it, never a mandatory first step. The cross-machine key source + is the M2 relay; in M1 the key is always on the connecting box. The common case + just opens. 3. Opening yields the interactive terminal pane. "Set up Subshell here" is a secondary act inside the pane, not a competing mode on the landing screen. -Saved hosts and the this-computer nicety survive as shortcuts, not the spine. -A machine whose SSH gate is off (section 4.3) is never silently offered: when one -is needed it is named with the remedy to ask its owner to enable SSH there. -Terminology is trimmed to three nouns: destination, connecting machine, and key -source. The destination field is a `SearchableSelect` like the other launch -pickers. Shipped copy follows the design system: role tokens only, at most two -sentences, no em dashes. +Saved and recent hosts live on the plane, keyed by the resolved canonical +destination `user@host:port` (the alias is display metadata only), so an edited +alias can never silently re-point a saved destination or a pin. A machine whose SSH +gate is off (section 4.3) is never silently offered: when one is needed it is named +with the remedy to ask its owner to enable SSH there. Terminology stays to three +nouns: destination, connecting machine, key source. The destination field is a +`SearchableSelect` like the other launch pickers. Shipped copy follows the design +system: role tokens only, at most two sentences, no em dashes. ## 12. Failure modes and UX -- A offline in relay mode: refuse before the handshake, name the remedy (bring - the key machine online); never copy the key to B. +- A offline in relay mode: refuse before the handshake, name the remedy (bring the + key machine online); never copy the key to B. - No matching key in A's agent: say so and point at loading it; never fall back to reading a passphrase-protected file. -- D host key changed since the grant pinned it: hard block, name D. +- D host key changed since it was first recorded (M1: the connecting machine's + `known_hosts`; M2: the `ssh_host_pins` entry): byte-equality hard block, name D. - Grant names a connecting machine that cannot reach D: a distinct error from "no key," so the two causes are not confused. - Relay expired or A dropped mid-handshake: the connection either completed (pane fine) or fails cleanly with a retry that re-opens the relay. -- Quota or policy refusal on A: surfaced as a refusal with the reason, matching - the equality-mapped refusal convention used elsewhere. +- Quota or policy refusal on A: a refusal carrying the reason, using the same + byte-equality hard-block convention (section 9). ## 13. Security rules honored @@ -275,69 +349,84 @@ sentences, no em dashes. notification. Audit rows name ids, hosts, and field names only. - The agent proxy socket on B is 0600 in a per-session directory; the rendered connection config is 0600; transient files are removed with the pane. -- `shellQuote` on every token of the built command; the connection is one tmux - shell string, and that is never reasoned away. -- Node and broker command transport is the existing authenticated, replay-fenced - path (`sendCommand`, signed commands, the desktop broker's monotonic fence). -- Outbound SSH is gated per node, off by default including `local`, and fails - closed when the setting is unreadable (section 4.3); enabling is owner/admin +- `shellQuote` on every token of the built command; the connection is one tmux shell + string, and that is never reasoned away. +- Node command transport is the existing authenticated, signed, replay-fenced + `sendCommand` path (present on `main`). The desktop ssh *broker* and its monotonic + replay fence are #330 code, not on this base; if M2 needs the laptop's own broker + it is re-ported then, not assumed here. +- Outbound SSH is gated per node, off by default including `local`, and fails closed + when the setting is unreadable (section 4.3); enabling is owner (admin on `local`) only and audited (`node.ssh_enabled`). +- `SSH_AUTH_SOCK` is exported only into the ssh invocation, never the whole pane env, + and an agent-capable pane is view-only to sharees (section 5.4). - Static imports only; no dynamic import outside the one sanctioned plugin site. -- Machine-to-machine sealed payloads reuse the channel crypto and TOFU store, not - a new bespoke cipher. +- Machine-to-machine sealed payloads reuse the channel crypto and TOFU store, not a + new bespoke cipher. ## 14. Testing -- Unit: alias discovery budgets and wildcard exclusion; `ssh -G` snapshot parsing - and the forbidden-settings refusal; connection-config render with the pinned - `UserKnownHostsFile`; the agent-proxy wire framing against a stub agent; grant - validation; relay envelope seal/open (right recipient opens, wrong id and - tampering fail); ProxyJump argv construction and quoting. -- Integration: two in-process identities as A and B with a fake plane relay; - assert a sign request reaches A's stub agent, the response returns sealed, the +- Unit, M1: alias discovery budgets and wildcard exclusion; `ssh -G` snapshot + parsing and the forbidden-settings refusal; ProxyJump argv construction and + quoting; the `ssh_enabled` gate default-off, fail-closed-on-unreadable, + owner/admin gating, and `nodeCanSsh` refusal codes; the scoped `SSH_AUTH_SOCK` + export (present on the ssh process, absent from the rest of the pane env). +- Unit, M2: connection-config render with the pinned `UserKnownHostsFile`; the + agent-proxy wire framing against a stub agent, including that non-allow-listed + agent methods are refused; grant validation; relay envelope seal/open (right + recipient opens, wrong id and tampering fail); envelope replay within a window is + inert. +- Integration, M2: two in-process machine identities as A and B with a fake plane + relay; a sign request reaches A's stub agent, the response returns sealed, the relayed blob is opaque at the plane, an ungranted or wrong-origin request is refused, and revoke stops signing mid-session. -- End to end: the sshd fixture reused as D. Jump mode drives a real two-hop - `ProxyJump` (bastion plus target) with the key on the origin only. Relay mode is - the crown-jewel test: a second brokered machine as A with the key in its agent, - a B with no key, and an assertion that B authenticates to D through the plane - relay while the plane-side capture of the wire shows only ciphertext. -- CI regime per the repo rules: focused files while iterating, the full trio plus - `lint:prose` at the boundary, and the e2e specs under the existing serial - container regime. +- End to end: the sshd fixture reused as D. M1 jump drives a real two-hop + `ProxyJump` (bastion plus target) with the key on the origin only, proving the + pane attaches and accepts input. M2 relay is the crown-jewel test: a second + machine as A with the key in its agent, a B with no key, B authenticates to D + through the plane relay while the plane-side wire capture shows only opaque + envelopes plus plaintext routing ids, and the key never appears on B or the plane. +- CI regime per `verification.md`: focused files while iterating, the full trio + plus `lint:prose` at the boundary, e2e under the existing serial container regime, + `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never live `:3080`. ## 15. Branching and reuse from main `origin/main` does not contain the ssh-runtime subsystem (discovery, resolve, the -connection-snapshot grammar, the brokered-session supervisor, the ssh-broker, or -the sshd fixture); those live only on `feat/ssh-support`. The E2EE primitives -(`mcp-core` seal/open and the TOFU pin store) are already on main and are used as -is. `feat/ssh-anywhere` therefore re-introduces only the neutral primitives -OpenSSH config discovery, `ssh -G` resolve, and the connection-snapshot grammar, -plus the sshd test fixture, as a small first commit, and deliberately does not -carry the session-frame codec, supervisor, or runtime-serve. `feat/ssh-support` -stays open and unmerged as the reference for the brokered-session transport that -Milestone 2 may revisit. The reuse is of code and ideas only: the user-facing UX -is redesigned (section 11) rather than ported, and #330's connect flow is a -cautionary example, not a base. +connection-snapshot grammar, the brokered-session supervisor, the ssh-broker, or the +sshd fixture); those live only on `feat/ssh-support`. The E2EE primitives +(`mcp-core` seal/open and the TOFU pin store) are already on main. +`feat/ssh-anywhere` re-introduces only the neutral primitives: OpenSSH config +discovery, `ssh -G` resolve, and the connection-snapshot grammar, plus the sshd test +fixture, as a small first commit, and deliberately does not carry the session-frame +codec, supervisor, or runtime-serve. `feat/ssh-support` stays open and unmerged as a +reference for the brokered-session transport that M2 may revisit. Reuse is code and +ideas only: the UX is redesigned (section 11) rather than ported, and #330's connect +flow is a cautionary example, not a base. ## 16. Deferred / open items for Milestone 2 -- Whether the agent-destination upgrade reuses #330's runtime-session protocol or - a lighter boot now that the terminal path exists. -- Per-use approval UX on A (a notification or prompt to confirm a handshake), - versus the default of a standing grant. +- The machine-identity roster and TOFU pin flow (section 5.3), including whether the + relay obeys `SUBSHELL_CHANNEL_PIN` or is always strict, and how to narrow the + first-use window (section 10). +- Whether the agent-destination upgrade reuses #330's runtime-session protocol or a + lighter boot now that the terminal path exists. +- Per-use approval UX on A (notification or prompt to confirm a handshake), versus a + standing grant; and whether the approval flag on the grant ships with it. - Whether to support A's identity by file (with an on-A passphrase prompt) at all, - versus requiring the key to be loaded in A's agent. + versus requiring the key loaded in A's agent. - Enrolling a relay-only destination as a first-class node automatically after a successful "set up Subshell here." ## 17. Success criteria -Milestone 1 is done when, from the web app and the Client, an operator opens an -interactive pane against an arbitrary sshd-only host on a machine whose owner has -enabled the (default-off) SSH gate, using either that machine's own keys or a jump -through a second machine, sees it stream and accept input like any pane, and no -private key or plaintext challenge ever appears on the plane or the destination. Milestone 2 is done when the same pane can run on a -machine that has no key at all, authenticating through a machine that does, with a -CI test proving the plane sees only ciphertext and the key never leaves its home. +Milestone 1 is done when, from the web app and the enrolled laptop, an operator +opens an interactive pane against an arbitrary sshd-only host on a machine whose +owner has enabled the (default-off) SSH gate, using that machine's own agent (via +the scoped `SSH_AUTH_SOCK` export) or a jump through a hop, sees it stream and +accept input like any pane, and no private key or plaintext challenge ever crosses +the plane: the plane carries only opaque envelopes plus a plaintext routing id that +reveals no key material, and D sees only a normal ssh handshake. Milestone 2 is done +when the same pane can run on a machine with no key at all, authenticating through a +machine that does, with a CI test proving the plane sees only opaque envelopes and +the key never leaves its home. From a049d79087d022efb8c293558697e08e1426b11e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:10:26 -0700 Subject: [PATCH 007/170] docs(ssh-anywhere): spec review round 2 - seal gives confidentiality not origin (add B's signature), identity filter from snapshot, saved-hosts store + view-only/direct tests, host-key + mode-naming + success fixes Co-Authored-By: Claude --- .../specs/2026-10-07-ssh-anywhere-design.md | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index 12dfebfcb..578e042e5 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -50,12 +50,13 @@ caveat when that pane's ssh can drive an agent). ## 4. Connection modes -One launcher, two modes, chosen by where the signing key lives. If the connecting -machine B holds the key, ssh runs there directly. If the key lives on another -machine A, the choice is jump mode (origin A dials out; pane on A) or relay mode -(pane on B; signed by A's agent over a sealed shuttle; section 8; an M2 -capability). In Milestone 1 the key source is always the connecting machine itself, -so relay and the grants that drive it (section 5.1) are out of scope. +One launcher, three arrangements, chosen by where the signing key lives and how it +reaches D. **Direct**: the connecting machine B holds the key and `ssh` runs there. +**Jump** (section 4.1): the origin A holds the key and dials out through a hop, pane +on A. **Relay** (section 4.2, M2): the pane is on B but the key is on a different +machine A, reached over a sealed shuttle. In Milestone 1 the key source is always the +connecting machine itself, so relay and the grants that drive it (section 5.1) are +out of scope; direct and jump are the M1 arrangements. ### 4.1 Jump mode (A is the connecting machine) @@ -97,7 +98,9 @@ refused with the remedy named in copy: ask the owner of that machine to enable S on it. The gate is per role: relay mode needs both the connecting machine (dials out) and the key home (whose agent signs) enabled; jump mode needs only the origin A (which dials out and serves its key) enabled; a jump hop `J` and the destination -`D` are dialed-to, not gated. +`D` are dialed-to, not gated. Only the owner of an enabled machine may route SSH +through it (mirrors `nodeCanSsh`); an `edit` grantee of someone else's enabled node +is not thereby allowed to use that machine's egress or keys. ## 5. Architecture components @@ -127,7 +130,10 @@ via `sendCommand` (the signed, replay-fenced node command transport, present on `main`); alias resolution is a user-initiated action, never run during discovery. Every token in the built command is passed through `shellQuote`; the connection is one shell string through tmux, so quoting is the load-bearing defense and is never -relaxed. +relaxed. The built `ssh` runs against the rendered snapshot config via `-F` only, +never the connecting machine's live `~/.ssh/config`: `~/.ssh/config` is +discovery/resolve input only, so settings the session policy forbids (ForwardAgent, +remote/local forwards, ProxyCommand) cannot reappear at connect time. ### 5.3 Sealed agent relay (Milestone 2) @@ -141,12 +147,13 @@ back. The plane routes by the recipient id in the envelope header without openin anything. Detail in section 8. This is not "the primitives used as is." It needs a machine-to-machine identity -roster and a TOFU pin flow that do not exist today: nodes already hold a +roster and a TOFU pin flow that do not fully exist today: nodes already hold a registration ECDH-ES keypair (`apps/node/agent/src/identity.ts`, private half never -leaves the machine), but `identities.route` serves only per-principal (`sess:` / -`user:`) keys, so there is no way today for A to learn and pin B's machine key. -Building that roster and first-use pinning, and deciding whether the relay obeys -`SUBSHELL_CHANNEL_PIN` or is always strict, is part of the M2 work. +leaves the machine) and an identities store keyed by principal exists, but a node is +not registered there as a readable principal and there is no client-visible read path +or machine-to-machine TOFU pin flow. Building that (a `node:` identity, a read +path, and first-use pinning, and deciding whether the relay obeys +`SUBSHELL_CHANNEL_PIN` or is always strict) is part of the M2 work. ### 5.4 Terminal pane @@ -202,6 +209,10 @@ clean seam the M1 launcher exposes. of section 4.3. Plane-authored row value, cached in a node-side mirror file the node reads only to fail closed; owner/admin to change; audited as `node.ssh_enabled`. Not a new table (a column on `nodes`). +- `ssh_saved_hosts` (M1): per owner, keyed by the resolved canonical destination + `user@host:port` (display alias separate), with the connecting box used and + last-connect; recent hosts derive from connects. Also stores the user's default + connecting machine; with none set the UI prompts rather than silently choosing. - `ssh_key_grants` (M2): id, owner, name, key-home node id, destination selector. No secrets, no keys. - `ssh_host_pins` (M2): keyed by resolved destination `user@host:port`, the pinned @@ -229,21 +240,28 @@ clean seam the M1 launcher exposes. sign/authenticate methods OpenSSH needs. It refuses `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted handshake window cannot mutate or lock A's agent against its other panes. - `REQUEST_IDENTITIES` is answered with A's keys filtered to the grant's pinned - identity, not the whole list. + `REQUEST_IDENTITIES` is answered with A's keys filtered to the identity files the + resolved connection snapshot names for that destination (bounded by + `SSH_MAX_IDENTITY_REFS`), not A's whole list. - Replay posture: because the plane stores envelopes, a byte-identical envelope could be resent within the handshake window; a nonce/freshness inside the ciphertext, bound to the relay session, makes such replays inert. -- Origin: to be accepted by A, a request must seal under B's machine key, which A - has pinned TOFU. The plane holds no machine's private ECDH key, so after that pin - it cannot mint a request A accepts as B; sealing supplies origin authentication, - not just confidentiality. This rests on the machine-identity roster and pin flow - named in section 5.3, and carries the pin-establishment window described in - section 10. +- Origin: sealing supplies confidentiality only. The reused primitive (`crypto.ts`) + is ephemeral-static ECDH-ES+A256KW: the sender contributes a per-message throwaway + key and only the recipient's static key is used, so a seal to A authenticates no + one. Anyone holding A's public key, including the plane that registered it, could + craft an envelope A opens. Origin therefore comes from a SIGNATURE: B signs the + inner payload with its machine key and A verifies against B's pinned key (the ES256 + JWS precedent from node command signing); the signature rides inside the sealed + envelope, which hides the payload from the plane. A's own TOFU pin of B's signing + key is the authorizer, not the plane that brokers the pairing. This rests on the + machine-identity roster and pin flow of section 5.3 and the pin-establishment + window of section 10. - Scope of signing: SSH agent sign requests carry no destination, so a grant cannot bind the agent to a host at the protocol layer; the residual risk (a compromised B using the granted key against other hosts that key can reach) is accepted under - the single-operator posture and bounded by key-scoping plus instant revoke. + the single-operator posture and bounded by serving only the snapshot-named + identities plus instant revoke. - No forwarding onward: B authenticates to D using A's agent but does not run `ssh -A` toward D. A's agent is used for the handshake and is never exposed on D, strictly safer than stock agent forwarding. @@ -260,9 +278,10 @@ clean seam the M1 launcher exposes. Trust for D follows the key-holder, not the connecting box, and pins are stored per resolved destination `user@host:port` (never one per grant). -- M1: the connecting machine is the key home, so this is plain OpenSSH. D is - validated against the connecting machine's own `~/.ssh/known_hosts` with - `StrictHostKeyChecking`. +- M1: the connecting machine is the key home, so this is plain OpenSSH. The launcher + connects with `StrictHostKeyChecking=accept-new`: a first connect to D records its + key into the connecting machine's `~/.ssh/known_hosts`, and OpenSSH itself refuses a + later changed key (we run no separate byte-check in M1). - M2 (relay): when a grant is created, A's trust in a destination's host key is captured (TOFU from A's `~/.ssh/known_hosts`, or an explicit pin) and stored as `ssh_host_pins` against that resolved destination. B's ssh then validates D @@ -275,8 +294,9 @@ in the threat model. ## 10. Trust model delta versus section 0 Gained: the plane never holds or sees an SSH private key or a plaintext sign -challenge; a plane compromised AFTER A first pinned B cannot forge a request A will -honor (TOFU origin); D never receives a forwarded agent. +challenge; a plane compromised AFTER A first pinned B's signing key cannot forge a +request A will honor (origin is B's signature over the payload, verified against that +pin, not the seal); D never receives a forwarded agent. The word "after" is load-bearing. The TOFU pin between two machines is established through the plane, so a plane compromised BEFORE A's first pin of B can substitute @@ -334,14 +354,15 @@ system: role tokens only, at most two sentences, no em dashes. key machine online); never copy the key to B. - No matching key in A's agent: say so and point at loading it; never fall back to reading a passphrase-protected file. -- D host key changed since it was first recorded (M1: the connecting machine's - `known_hosts`; M2: the `ssh_host_pins` entry): byte-equality hard block, name D. +- D host key changed since first recorded: the connect is refused (M1: OpenSSH's own + changed-key refusal against the connecting machine's `known_hosts`; M2: our pin's + byte-equality block), naming D. - Grant names a connecting machine that cannot reach D: a distinct error from "no key," so the two causes are not confused. - Relay expired or A dropped mid-handshake: the connection either completed (pane fine) or fails cleanly with a retry that re-opens the relay. -- Quota or policy refusal on A: a refusal carrying the reason, using the same - byte-equality hard-block convention (section 9). +- Quota or policy refusal on A: a loud refusal naming the reason, never a silent + fallback (the convention this section follows). ## 13. Security rules honored @@ -370,7 +391,9 @@ system: role tokens only, at most two sentences, no em dashes. parsing and the forbidden-settings refusal; ProxyJump argv construction and quoting; the `ssh_enabled` gate default-off, fail-closed-on-unreadable, owner/admin gating, and `nodeCanSsh` refusal codes; the scoped `SSH_AUTH_SOCK` - export (present on the ssh process, absent from the rest of the pane env). + export (present on the ssh process, absent from the rest of the pane env); and the + view-only rule (a sharee typed into an SSH-terminal pane is refused input, + section 5.4). - Unit, M2: connection-config render with the pinned `UserKnownHostsFile`; the agent-proxy wire framing against a stub agent, including that non-allow-listed agent methods are refused; grant validation; relay envelope seal/open (right @@ -380,9 +403,11 @@ system: role tokens only, at most two sentences, no em dashes. relay; a sign request reaches A's stub agent, the response returns sealed, the relayed blob is opaque at the plane, an ungranted or wrong-origin request is refused, and revoke stops signing mid-session. -- End to end: the sshd fixture reused as D. M1 jump drives a real two-hop - `ProxyJump` (bastion plus target) with the key on the origin only, proving the - pane attaches and accepts input. M2 relay is the crown-jewel test: a second +- End to end: the sshd fixture reused as D. M1 direct drives a single-hop pane to D + on the connecting machine's own agent (the section 1 primary and section 11 common + case): it opens, streams, accepts owner input, and refuses a sharee's input. M1 + jump drives a real two-hop `ProxyJump` (bastion plus target) with the key on the + origin only. M2 relay is the crown-jewel test: a second machine as A with the key in its agent, a B with no key, B authenticates to D through the plane relay while the plane-side wire capture shows only opaque envelopes plus plaintext routing ids, and the key never appears on B or the plane. @@ -423,10 +448,11 @@ flow is a cautionary example, not a base. Milestone 1 is done when, from the web app and the enrolled laptop, an operator opens an interactive pane against an arbitrary sshd-only host on a machine whose owner has enabled the (default-off) SSH gate, using that machine's own agent (via -the scoped `SSH_AUTH_SOCK` export) or a jump through a hop, sees it stream and -accept input like any pane, and no private key or plaintext challenge ever crosses -the plane: the plane carries only opaque envelopes plus a plaintext routing id that -reveals no key material, and D sees only a normal ssh handshake. Milestone 2 is done +the scoped `SSH_AUTH_SOCK` export) or a jump through a hop; it streams and accepts +the owner's input (sharees view-only, section 5.4), and no private key or plaintext +challenge ever crosses the plane. In M1 the plane carries no SSH session content at +all: ssh runs directly between the node and D, so the plane is not in that path, and +D sees only a normal ssh handshake. Milestone 2 is done when the same pane can run on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. From 95968e56914e179257309bcfead0a1a35fbc51b9 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:16:10 -0700 Subject: [PATCH 008/170] docs(ssh-anywhere): spec review round 3 - local-admin gate exception (spec 4.3 + plan nodeCanSsh mirrors nodeCanManageFor, no granted param), relay needs a per-machine ES256 signing key, signatures run both ways with the nonce bound, plane-off-transport-path precision, quota/quota-issuer and pin-phrasing tidy-ups --- .../2026-10-07-ssh-anywhere-1-foundations.md | 49 +++++++++++++------ .../specs/2026-10-07-ssh-anywhere-design.md | 33 ++++++++----- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md index 598eebcb3..21545305c 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -333,24 +333,33 @@ git commit -m "feat(nodes): add ssh_enabled capability column (default off) + mi - Test: `apps/server/api/src/lib/__tests__/node-access.test.ts` (add a describe) **Interfaces:** -- Produces: `nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; granted: NodeAccess; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean`. +- Produces: `nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; isAdmin: boolean; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean`. Note the shape mirrors `nodeCanManageFor`, not `nodeCanLaunchOn`: it takes `isAdmin` (the resolver ranks admins at `edit`, never `owner`, so the local-admin exception is invisible in `access` alone) and deliberately has NO `granted` parameter (on `local`, granted access is Everyone-`edit` by the seeded share, and that grant must not confer SSH use). - [ ] **Step 1: Write the failing tests** ```ts describe("nodeCanSsh", () => { it("refuses when the node's SSH gate is off, whoever the actor is", () => { - expect(nodeCanSsh({ kind: "agent", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: false })).toBe(false); + expect(nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: false })).toBe(false); }); - it("refuses a non-owner even when enabled and shared", () => { - expect(nodeCanSsh({ kind: "agent", access: "edit", granted: "edit", serverAccountEnabled: true, sshEnabled: true })).toBe(false); + it("refuses a non-owner even when enabled and shared edit", () => { + expect(nodeCanSsh({ kind: "agent", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true })).toBe(false); + }); + it("refuses an admin on someone else's node: the boost confers management, never SSH use", () => { + expect(nodeCanSsh({ kind: "agent", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true })).toBe(false); }); it("allows an owner of an enabled agent node", () => { - expect(nodeCanSsh({ kind: "agent", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: true })).toBe(true); + expect(nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true })).toBe(true); + }); + it("allows an admin on the enabled local server (admins resolve to edit, never owner)", () => { + expect(nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true })).toBe(true); + }); + it("the seeded Everyone edit grant on local does not confer SSH use", () => { + expect(nodeCanSsh({ kind: "local", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true })).toBe(false); }); - it("allows admin on an enabled local server only when server-as-node is on", () => { - expect(nodeCanSsh({ kind: "local", access: "owner", granted: "owner", serverAccountEnabled: true, sshEnabled: true })).toBe(true); - expect(nodeCanSsh({ kind: "local", access: "owner", granted: "owner", serverAccountEnabled: false, sshEnabled: true })).toBe(false); + it("local honors the server-as-node switch", () => { + expect(nodeCanSsh({ kind: "local", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true })).toBe(true); + expect(nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: false, sshEnabled: true })).toBe(false); }); }); ``` @@ -364,13 +373,25 @@ Expected: FAIL (`nodeCanSsh` not exported). ```ts /** Whether this node may be used for Subshell SSH at all (dial out or serve keys). - * Owner-only (matching who may broker an OS account's credentials), gated by the - * node's own ssh_enabled flag; local is the server account and also honors - * serverAccountEnabled. A distinct refusal from maintenance/no-launch, so the UI - * can say why. */ -export function nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; granted: NodeAccess; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean { + * Owner-only, matching who may broker an OS account's credentials, with the same + * local-node admin exception `nodeCanManageFor` applies (the resolver ranks admins + * at `edit`, never `owner`, so without `isAdmin` an admin could never USE the + * server gate they are allowed to enable). Deliberately NOT shaped like + * `nodeCanLaunchOn`: local's seeded Everyone grant confers launch, never SSH use, + * so there is no `granted` parameter here to widen it. Gated by the row's + * ssh_enabled (fail-closed) and, for `local`, the server-as-node switch. A + * distinct refusal from maintenance/no-launch, so the UI can say why. */ +export function nodeCanSsh(opts: { + kind: NodeKind; + access: NodeAccess; + isAdmin: boolean; + serverAccountEnabled: boolean; + sshEnabled: boolean; +}): boolean { if (!opts.sshEnabled) return false; - if (opts.kind === "local") return opts.access === "owner" && opts.serverAccountEnabled; + if (opts.kind === "local") { + return (opts.access === "owner" || opts.isAdmin) && opts.serverAccountEnabled; + } return opts.access === "owner"; } ``` diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index 578e042e5..ad12f8910 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -51,7 +51,7 @@ caveat when that pane's ssh can drive an agent). ## 4. Connection modes One launcher, three arrangements, chosen by where the signing key lives and how it -reaches D. **Direct**: the connecting machine B holds the key and `ssh` runs there. +reaches D. **Direct**: the connecting machine B holds the key and `ssh` runs there, with no hop. **Jump** (section 4.1): the origin A holds the key and dials out through a hop, pane on A. **Relay** (section 4.2, M2): the pane is on B but the key is on a different machine A, reached over a sealed shuttle. In Milestone 1 the key source is always the @@ -98,8 +98,9 @@ refused with the remedy named in copy: ask the owner of that machine to enable S on it. The gate is per role: relay mode needs both the connecting machine (dials out) and the key home (whose agent signs) enabled; jump mode needs only the origin A (which dials out and serves its key) enabled; a jump hop `J` and the destination -`D` are dialed-to, not gated. Only the owner of an enabled machine may route SSH -through it (mirrors `nodeCanSsh`); an `edit` grantee of someone else's enabled node +`D` are dialed-to, not gated. Only the owner (admin on `local`) of an enabled machine may route SSH +through it (mirrors `nodeCanSsh`, which carries the same local-admin exception +`nodeCanManageFor` applies); an `edit` grantee of someone else's enabled node is not thereby allowed to use that machine's egress or keys. ## 5. Architecture components @@ -153,7 +154,11 @@ leaves the machine) and an identities store keyed by principal exists, but a nod not registered there as a readable principal and there is no client-visible read path or machine-to-machine TOFU pin flow. Building that (a `node:` identity, a read path, and first-use pinning, and deciding whether the relay obeys -`SUBSHELL_CHANNEL_PIN` or is always strict) is part of the M2 work. +`SUBSHELL_CHANNEL_PIN` or is always strict) is part of the M2 work. It must also +mint and hold a per-machine ES256 signing keypair: `identity.ts` today holds only +the ECDH-ES encryption half, and the ES256 command keypair (`control-keys.ts`) is +plane-held, so the signing key the relay's origin proof (section 8) needs does not +exist per-machine yet. ### 5.4 Terminal pane @@ -189,8 +194,8 @@ scope; revisited in the Milestone 2 spec. Port the neutral primitives, then ship: SSH-terminal panes to any D regardless of node status; alias discovery and `ssh -G` resolve; the destination-first launch flow (section 11); destination host-key trust via the connecting machine's own -`~/.ssh/known_hosts` (stock OpenSSH `StrictHostKeyChecking`; the grant-pinned, -cross-box pinning of section 9 is M2); Jump mode end to end; the per-node SSH +`~/.ssh/known_hosts` (stock OpenSSH `StrictHostKeyChecking`; the cross-box pinning +captured at grant creation (section 9) is M2); Jump mode end to end; the per-node SSH capability gate (section 4.3, off by default including `local`, owner-enabled, fail-closed, audited); and audits. No session-frame codec, supervisor, or runtime-serve in this milestone. Delivers "SSH into any machine" for every case @@ -254,7 +259,10 @@ clean seam the M1 launcher exposes. inner payload with its machine key and A verifies against B's pinned key (the ES256 JWS precedent from node command signing); the signature rides inside the sealed envelope, which hides the payload from the plane. A's own TOFU pin of B's signing - key is the authorizer, not the plane that brokers the pairing. This rests on the + key is the authorizer, not the plane that brokers the pairing. Signatures run both + ways (A signs its responses back, B verifies against its pin of A), and the + relay-session nonce is bound into both directions, so a captured envelope is inert + on replay. This rests on the machine-identity roster and pin flow of section 5.3 and the pin-establishment window of section 10. - Scope of signing: SSH agent sign requests carry no destination, so a grant cannot @@ -361,8 +369,8 @@ system: role tokens only, at most two sentences, no em dashes. key," so the two causes are not confused. - Relay expired or A dropped mid-handshake: the connection either completed (pane fine) or fails cleanly with a retry that re-opens the relay. -- Quota or policy refusal on A: a loud refusal naming the reason, never a silent - fallback (the convention this section follows). +- Quota or policy refusal (by the plane or by A): a loud refusal naming the reason, + never a silent fallback (the convention this section follows). ## 13. Security rules honored @@ -450,9 +458,10 @@ opens an interactive pane against an arbitrary sshd-only host on a machine whose owner has enabled the (default-off) SSH gate, using that machine's own agent (via the scoped `SSH_AUTH_SOCK` export) or a jump through a hop; it streams and accepts the owner's input (sharees view-only, section 5.4), and no private key or plaintext -challenge ever crosses the plane. In M1 the plane carries no SSH session content at -all: ssh runs directly between the node and D, so the plane is not in that path, and -D sees only a normal ssh handshake. Milestone 2 is done +challenge ever crosses the plane. In M1 the plane is not on the ssh transport path: +ssh runs directly between the connecting machine and D, so no SSH protocol bytes, +keys, or challenges cross the plane; pane streaming and logging stay the ordinary +pane path (section 5.4), and D sees only a normal ssh handshake. Milestone 2 is done when the same pane can run on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. From 01b208369d5271bb9099ea50362f6fe3c449580a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:27:15 -0700 Subject: [PATCH 009/170] docs(ssh-anywhere): spec+plan review round 4 - enroll already registers node: (the M2 gap is the node end), plan runs wholly in the worktree, grammar port carries the engines' real import set (errors/results/fixtures included, tests ported not hand-written), kysely three-arg addColumn + 0031-pattern migration test, one ssh-enabled service as the single change site, protocol bump 15->16, audit name node.ssh_enabled.update + docs list, relay pins always strict --- .../2026-10-07-ssh-anywhere-1-foundations.md | 398 ++++++++++++------ .../specs/2026-10-07-ssh-anywhere-design.md | 61 ++- 2 files changed, 307 insertions(+), 152 deletions(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md index 21545305c..10c507f43 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** On `feat/ssh-anywhere`, land the two foundations every later slice needs: a default-off, owner-controlled, fail-closed per-node `ssh_enabled` capability gate, and the neutral OpenSSH primitives (config discovery, `ssh -G` resolve, connection-snapshot grammar) plus the sshd e2e fixture, ported off `feat/ssh-support`. +**Goal:** On a branch `feat/ssh-anywhere-1` cut from `feat/ssh-anywhere` (stacked PR onto the spec PR #333), land the two foundations every later slice needs: a default-off, owner-controlled, fail-closed per-node `ssh_enabled` capability gate, and the neutral OpenSSH primitives (config discovery, `ssh -G` resolve, connection-snapshot grammar) plus the sshd e2e fixture, ported off `feat/ssh-support`. **Architecture:** The `ssh_enabled` gate mirrors the existing `maintenance` flag (plane column + node mirror file + a push command + `ready` reporting) but with the plane as the sole writer and no pane-killing side effects; a `nodeCanSsh` predicate refuses use of a disabled node. The ssh grammar lives in `@internal/subshell-protocol` (barrel-safe: no `node:` builtins), the ssh engines in `@internal/pane-runtime` (its barrel is never imported by mobile), and the sshd fixture in `e2e/fixtures`. @@ -15,7 +15,9 @@ Every task obeys these (copied from the spec `docs/superpowers/specs/2026-10-07- - New migration number is **`0047`** (`main`'s latest is `0046-backup-recovery.ts`); do NOT reuse the branch's 0047-0051 names. - `ssh_enabled` defaults **off/false on every node including `local`**; the DB column is `notNull().defaultTo(0)` (the maintenance default-0 "upgrade keeps the fleet as it was" doctrine). - The node-side read is **fail-closed**: a node that cannot read its own setting refuses SSH; an absent file reads as **off** (default-off). -- Changing the flag is **owner-only, admin on `local`** (`gate.canManage`), cookie-authenticated, audited as `node.ssh_enabled`; the flag is plane-written (no node self-toggle CLI). +- Changing the flag is **owner-only, admin on `local`** (`gate.canManage`), cookie-authenticated, audited as `node.ssh_enabled.update` (the node-family verb pattern; the name is added to the `docs/security.md` section 10 event list in Task 6); the flag is plane-written (no node self-toggle CLI). +- **Every command runs in the `/home/theo/projects/wt-ssh-anywhere` worktree**, never in `/home/theo/projects/subshell` (the shared checkout holds other branches' uncommitted work). The worktree is a linked git worktree, so it shares the main checkout's refs and `git show origin/feat/ssh-support:` works from inside it. +- **`NODE_PROTOCOL_VERSION` bumps 15 -> 16** (Task 7): the gate's new frames ride the bump because the node-link compatibility gate is exact-match. - No SSH private key material, challenge, signature, or config value enters any log, argv, or audit row; audits name ids/hosts/field-names only. - Node-builtin-importing modules must NOT be re-exported from `packages/subshell-protocol/src/index.ts` (Metro imports that barrel and cannot resolve `node:*`). Grammar modules that import no `node:` builtin MAY be barrel-exported there. `@internal/pane-runtime` is not a mobile dependency, so its engines may be exported from its own index barrel. - `shellQuote` every token of any built command; static top-level imports only (no `await import` outside the sanctioned `plugin-runtime.ts`). @@ -25,17 +27,35 @@ Every task obeys these (copied from the spec `docs/superpowers/specs/2026-10-07- --- +## Branch setup (once, before Task 1) + +In the worktree (never in the shared checkout): + +```bash +cd /home/theo/projects/wt-ssh-anywhere +git switch -c feat/ssh-anywhere-1 # from feat/ssh-anywhere, the spec/plan branch +``` + +All tasks commit on `feat/ssh-anywhere-1`; Task 8 opens the PR against +`feat/ssh-anywhere`. + +--- + ## File Structure -Ports (verbatim copies off `origin/feat/ssh-support`, then export wiring): +Ports (verbatim copies off `origin/feat/ssh-support`, then export wiring; the ported files cite `SSH-SUPPORT.md`, which was deleted as superseded, so each port commit rewrites every `SSH-SUPPORT.md §N` pointer to this spec's `2026-10-07-ssh-anywhere-design.md §N`): - Create: `packages/subshell-protocol/src/ssh-config.ts` (grammar; no `node:` builtins → barrel-safe) - Create: `packages/subshell-protocol/src/ssh-limits.ts` (budgets; no `node:` builtins → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-errors.ts` (error codes + shipped descriptions; imports nothing → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-results.ts` (resolve/discover wire outcomes; type-only imports → barrel-safe) - Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` +- Create: `packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts` (imported by the grammar test) - Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` (`node:*` → pane-runtime barrel only) - Create: `packages/pane-runtime/src/ssh/ssh-discover.ts` (`node:fs/os/path` → pane-runtime barrel only) - Create: `packages/pane-runtime/src/ssh/ssh-resolve.ts` -- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts` -- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/helpers.ts` (test fixtures + ssh shim; imported by both engine tests) +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts` (ported verbatim, not hand-written) +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts` (ported verbatim, not hand-written) - Create: `e2e/fixtures/sshd.ts` Gate (new, mirroring the maintenance template): @@ -45,9 +65,11 @@ Gate (new, mirroring the maintenance template): - Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` (`setSshEnabled`) - Modify: `apps/server/api/src/lib/node-access.ts` (`nodeCanSsh`) - Modify: `apps/server/api/src/api/nodes/node-view.ts` (`sshEnabled` in `NodeViewSchema` + `nodeViewBase`) -- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` (calls the service, like the maintenance route calls `setNodeMaintenance`) +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (Task 6: `setNodeSshEnabled` row-write + audit; Task 7 adds `pushSetSshEnabled` + `reconcileSshEnabled`) - Modify: `apps/server/api/src/api/nodes/index.ts` (mount the route) -- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NodeSshEnabledWire`, `parseNodeSshEnabled`, `set_ssh_enabled` command arm, `ssh_enabled` event arm, `ready` arm, `NODE_RESULT_SSH_DISABLED`) +- Modify: `docs/security.md` §10 + `.claude/rules/security-context.md` (add `node.ssh_enabled.update` to the nodes audit family) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NodeSshEnabledWire`, `parseNodeSshEnabled`, `set_ssh_enabled` command arm, `ssh_enabled` event arm, `ready` arm, `NODE_PROTOCOL_VERSION` 15 -> 16 + history comment; the `NODE_RESULT_SSH_DISABLED` result string is deliberately NOT here - it ships with the Plan 2 launcher that produces it) - Modify: `packages/subshell-protocol/src/index.ts` (export grammar + the new wire symbols) - Create: `apps/node/agent/src/ssh-enabled.ts` (`node:*` file read/write, fail-closed) - Create: `apps/node/agent/src/commands/set-ssh-enabled.ts` (executor: write mirror file + memoize) @@ -55,7 +77,6 @@ Gate (new, mirroring the maintenance template): - Modify: `apps/node/agent/src/commands/report.ts` (`reportSshEnabled`, `maybeReportSshEnabled`, `seedSshEnabledMemo`) - Modify: `apps/node/agent/src/commands/index.ts` (dispatch `set_ssh_enabled`) - Modify: `apps/node/agent/src/daemon.ts` (`readyEvent` includes `sshEnabled`) -- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (`setNodeSshEnabled`, `pushSetSshEnabled`, `reconcileSshEnabled`) - Modify: `apps/server/api/src/services/nodes/node-events.ts` + `apps/server/api/src/index.ts` (onReady reconcile hook) - Test files alongside each new service/route/module in `__tests__/`. @@ -64,66 +85,86 @@ Gate (new, mirroring the maintenance template): ## Task 1: Port the ssh grammar into `subshell-protocol` (barrel-safe) **Files:** -- Create: `packages/subshell-protocol/src/ssh-config.ts` -- Create: `packages/subshell-protocol/src/ssh-limits.ts` +- Create: `packages/subshell-protocol/src/ssh-limits.ts`, `.../ssh-config.ts`, `.../ssh-errors.ts`, `.../ssh-results.ts` +- Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts`, `.../__tests__/fixtures/ssh-fixtures.ts` - Modify: `packages/subshell-protocol/src/index.ts` -- Test: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` **Interfaces:** - Consumes: nothing. -- Produces (from `@internal/subshell-protocol`): `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS`, `SSH_REQ_ID_MAX_CHARS`, `SSH_MAX_IDENTITY_REFS` (limits); `type SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS` (grammar). +- Produces (from `@internal/subshell-protocol`): the limits `SSH_MAX_DISCOVERED_ALIASES`, `SSH_MAX_PROXY_HOPS`, `SSH_NAME_MAX_CHARS`, `SSH_PROBE_DEADLINE_MS`; the grammar `type SshHopWire`, `type SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS`; the named-refusal codes and shipped sentences `SSH_ERROR_CODES`, `type SshErrorCode`, `isSshErrorCode`, `SSH_ERROR_DESCRIPTIONS` (the Plan 3 review UI renders the descriptions by equality, never the wire code); the RPC outcomes `type NodeSshAliasListResult`, `type NodeSshResolveOutcomeWire`. This is exactly what Task 2's engines import from the package barrel (`ssh-resolve.ts` pulls seven of these symbols; a missing one fails its test run at Task 2 Step 2, not here). -- [ ] **Step 1: Copy the two grammar files verbatim off the branch** +- [ ] **Step 1: Copy the six files verbatim off the branch, into the worktree** ```bash -cd /home/theo/projects/subshell # the shared checkout has feat/ssh-support pushed +cd /home/theo/projects/wt-ssh-anywhere B=origin/feat/ssh-support -git show "$B:packages/subshell-protocol/src/ssh-limits.ts" > packages/subshell-protocol/src/ssh-limits.ts -git show "$B:packages/subshell-protocol/src/ssh-config.ts" > packages/subshell-protocol/src/ssh-config.ts +mkdir -p packages/subshell-protocol/src/__tests__/fixtures +for f in ssh-limits ssh-config ssh-errors ssh-results; do + git show "$B:packages/subshell-protocol/src/$f.ts" > packages/subshell-protocol/src/$f.ts +done git show "$B:packages/subshell-protocol/src/__tests__/ssh-config.test.ts" > packages/subshell-protocol/src/__tests__/ssh-config.test.ts -grep -nE "from \"node:" packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-limits.ts # MUST print nothing +git show "$B:packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts" > packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts +grep -nE "from \"node:" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts # MUST print nothing ``` -Expected: the `grep` prints nothing (these two are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. +Expected: the `grep` prints nothing (all four modules are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. + +- [ ] **Step 2: Rewrite the deleted-document pointers** -- [ ] **Step 2: Run the ported grammar test to confirm the copy is complete** +The copied files' doc comments cite `SSH-SUPPORT.md`, deleted as superseded. Replace every citation with `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`, keeping the sentence true (drop a `§N` cross-reference when the spec has no matching section rather than pointing at a wrong one). Check: `grep -c "SSH-SUPPORT" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts` prints `0` for all four. + +- [ ] **Step 3: Run the grammar test (it imports the copied files by relative path, so it is green before any barrel wiring)** Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-config.test.ts` -Expected: the test imports from `../ssh-config.js`; it FAILS with "Cannot find module `../ssh-limits.js`" only if a symbol is missing - otherwise it may already pass because the two files are self-contained. If it references symbols that live in files NOT yet ported (e.g. `ssh-frames`), trim the test to the symbols actually in these two files for now, or port those symbols too. Record what the test needs. +Expected: PASS (1 file). A failed import means the copy is incomplete - fix the copy, do not trim the test. -- [ ] **Step 3: Barrel-export the grammar** +- [ ] **Step 4: Barrel-export the grammar** Append to `packages/subshell-protocol/src/index.ts` (do NOT export any `node:`-importing module here): ```ts export { SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, - SSH_REQ_ID_MAX_CHARS, - SSH_MAX_IDENTITY_REFS, + SSH_PROBE_DEADLINE_MS, } from "./ssh-limits.js"; export { + type SshHopWire, type SshConnectionSnapshotWire, parseSshConnectionSnapshot, SSH_FORBIDDEN_SNAPSHOT_FIELDS, } from "./ssh-config.js"; +export { + SSH_ERROR_CODES, + type SshErrorCode, + isSshErrorCode, + SSH_ERROR_DESCRIPTIONS, +} from "./ssh-errors.js"; +export { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; ``` -- [ ] **Step 4: Build the package and run its tests** +(Do not export `NODE_RESULT_SSH_GENERATION_STALE` or the other #330 result-channel strings; they have no consumer on this path.) + +- [ ] **Step 5: Build the package and run its tests** Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test` Expected: build succeeds; `ssh-config` test PASS; no other protocol test regresses. -- [ ] **Step 5: Confirm the mobile build is unaffected (the Metro trap)** +- [ ] **Step 6: Confirm the mobile build is unaffected (the Metro trap)** -Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol` then `grep -c "ssh-config\|ssh-limits" dist/index.js` -Expected: the built barrel references the grammar; because both files import no `node:`, Metro can still resolve the barrel. (The real mobile guard runs in CI; this is the local sanity check the rules call for.) +Run: `grep -c "ssh-config\|ssh-limits" /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol/dist/index.js` +Expected: nonzero - the built barrel references the grammar; because the four files import no `node:`, Metro can still resolve the barrel. (The real mobile guard runs in CI; this is the local sanity check the rules call for.) -- [ ] **Step 6: Commit** +- [ ] **Step 7: Commit** ```bash -git add packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts -git commit -m "feat(ssh): port OpenSSH connection grammar + budgets into subshell-protocol" +cd /home/theo/projects/wt-ssh-anywhere +git add packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts +git commit -m "feat(ssh): port OpenSSH grammar, budgets, and named-refusal codes into subshell-protocol" ``` --- @@ -131,82 +172,66 @@ git commit -m "feat(ssh): port OpenSSH connection grammar + budgets into subshel ## Task 2: Port the ssh engines into `pane-runtime` (barrel-safe because mobile never imports it) **Files:** -- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` -- Create: `packages/pane-runtime/src/ssh/ssh-discover.ts` -- Create: `packages/pane-runtime/src/ssh/ssh-resolve.ts` +- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts`, `.../ssh-discover.ts`, `.../ssh-resolve.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/helpers.ts`, `.../ssh-discover.test.ts`, `.../ssh-resolve.test.ts` (all three ported verbatim; the two tests import `./helpers.js`) - Modify: `packages/pane-runtime/src/index.ts` -- Test: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts`, `.../ssh-resolve.test.ts` **Interfaces:** -- Consumes (Task 1): `SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS`, `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS` from `@internal/subshell-protocol`. -- Produces (from `@internal/pane-runtime`): `discoverSshAliases(...)`, `defaultSshConfigPath`, `isDiscoverableAlias`, `resolveSshAliasConfig(...)`, `runSshProcess`, `sshChildPath` (verify exact export names against the copied files and mirror them). +- Consumes (Task 1): `type NodeSshResolveOutcomeWire`, `parseSshConnectionSnapshot`, `SSH_MAX_PROXY_HOPS`, `SSH_PROBE_DEADLINE_MS`, `type SshConnectionSnapshotWire`, `type SshErrorCode`, `type SshHopWire` (ssh-resolve.ts) plus `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS` (ssh-discover.ts), and `type SshConnectionSnapshotWire` in helpers.ts - all from `@internal/subshell-protocol`. +- Produces (from `@internal/pane-runtime`): `runSshProcess`, `type SshProcessResult`, `sshChildPath` (spawn); `defaultSshConfigPath`, `discoverSshAliases`, `isDiscoverableAlias`, `expandTilde`, `walkSshConfig`, `type SshAliasDiscovery`, `type SshConfigWalk`, `type SshHostBlock`, `type SshWalkBudget` (discover); `parseProxyHop`, `resolveSshAliasConfig`, `type SshResolutionDeps` (resolve). These are the branch's real export names, verified from the files themselves on 2026-10-07. -- [ ] **Step 1: Copy the three engine files + their tests** +- [ ] **Step 1: Copy the three engines and their three test files verbatim** ```bash +cd /home/theo/projects/wt-ssh-anywhere B=origin/feat/ssh-support -cd /home/theo/projects/subshell -mkdir -p /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime/src/ssh/__tests__ +mkdir -p packages/pane-runtime/src/ssh/__tests__ for f in ssh-spawn ssh-discover ssh-resolve; do - git show "$B:packages/pane-runtime/src/ssh/$f.ts" > /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime/src/ssh/$f.ts + git show "$B:packages/pane-runtime/src/ssh/$f.ts" > packages/pane-runtime/src/ssh/$f.ts +done +for f in helpers ssh-discover.test ssh-resolve.test; do + git show "$B:packages/pane-runtime/src/ssh/__tests__/$f.ts" > packages/pane-runtime/src/ssh/__tests__/$f.ts done ``` -Then open each copied file and confirm the ONLY external imports are `node:*`, `../…` within pane-runtime, or `@internal/subshell-protocol`. If a file imports anything from `ssh-session-*`, `ssh-render`, or the codec (the abandoned runtime path), those are NOT needed for discovery/resolve: remove the unused import and the symbols that use it, or note it for a follow-up. Record any such coupling before deleting. - -- [ ] **Step 2: Write the discovery failing test (real behavior, temp HOME)** - -Create `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts`: - -```ts -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { discoverSshAliases } from "../ssh-discover.js"; - -let home: string; -beforeAll(() => { - home = mkdtempSync(join(tmpdir(), "ssh-discover-")); - mkdirSync(join(home, ".ssh"), { recursive: true, mode: 0o700 }); - writeFileSync(join(home, ".ssh", "config"), "Host alpha\n HostName a.example\nHost beta\n HostName b.example\nHost *\n User nobody\n"); -}); -afterAll(() => rmSync(home, { recursive: true, force: true })); - -describe("discoverSshAliases", () => { - it("lists concrete aliases and excludes the wildcard-only Host", () => { - const { aliases } = discoverSshAliases({ homeDir: home }); - expect(aliases).toEqual(["alpha", "beta"]); - expect(aliases).not.toContain("*"); - }); -}); -``` - -(Adapt the call signature to what `ssh-discover.ts` actually exports - read its top-level function before finalizing the assertion.) +Then confirm each engine's imports are ONLY `node:*`, its `./` siblings inside `ssh/`, or `@internal/subshell-protocol`. The known true edges (keep them): `ssh-resolve.ts` imports `defaultSshConfigPath`, `expandTilde`, `type SshHostBlock`, `type SshWalkBudget`, `walkSshConfig` from `./ssh-discover.js` and `runSshProcess`, `sshChildPath` from `./ssh-spawn.js`; helpers.ts imports a protocol type. A `./ssh-render` / `./ssh-session-*` / `./kill-group` / codec import would be coupling to the abandoned runtime path - none exists on the branch today, so if one appears, record it and de-couple it (never by deleting a protocol import Task 1 supplies). Rewrite the `SSH-SUPPORT.md` pointers as in Task 1 Step 2 and check `grep -l "SSH-SUPPORT" packages/pane-runtime/src/ssh/*.ts packages/pane-runtime/src/ssh/__tests__/*.ts` prints nothing. -- [ ] **Step 3: Run the test to see it fail before wiring the barrel** +- [ ] **Step 2: Run the ported tests** -Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh/__tests__/ssh-discover.test.ts` -Expected: FAIL importing `../ssh-discover.js` if it needs a symbol not yet in the barrel, or PASS if self-contained. Either way the file runs. +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh/__tests__/ssh-discover.test.ts src/ssh/__tests__/ssh-resolve.test.ts` +Expected: PASS, 2 files. The resolve tests run the resolver against a shim `ssh` that `helpers.ts` bakes with canned `-G` output, and end with one case driven by the REAL host `ssh -G`, loud-skipped only where ssh is absent - the guard ships inside the ported test; do not add `missingSshBins` (that lives in the e2e package, which pane-runtime does not depend on). -- [ ] **Step 4: Export the engines from the pane-runtime barrel** +- [ ] **Step 3: Export the engines from the pane-runtime barrel (named exports, the file's existing style)** Append to `packages/pane-runtime/src/index.ts`: ```ts -export * from "./ssh/ssh-spawn.js"; -export * from "./ssh/ssh-discover.js"; -export * from "./ssh/ssh-resolve.js"; +export { runSshProcess, type SshProcessResult, sshChildPath } from "./ssh/ssh-spawn.js"; +export { + defaultSshConfigPath, + discoverSshAliases, + isDiscoverableAlias, + type SshAliasDiscovery, + type SshConfigWalk, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh/ssh-discover.js"; +export { + parseProxyHop, + resolveSshAliasConfig, + type SshResolutionDeps, +} from "./ssh/ssh-resolve.js"; ``` -- [ ] **Step 5: Build + run the engines' tests** +(`expandTilde` stays package-internal like the branch's barrel has it; the engines use it via relative imports.) -Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/pane-runtime && cd packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh` -Expected: PASS. Resolve's test invokes `ssh -G`; keep it behind the sshd/binary guard used elsewhere (`missingSshBins`) or mark it `it.skip` if `ssh` is absent, matching the repo's existing conditional-test idiom. - -- [ ] **Step 6: Commit** +- [ ] **Step 4: Build, typecheck, and commit** ```bash +cd /home/theo/projects/wt-ssh-anywhere +bunx turbo build --filter=@internal/pane-runtime +bunx turbo verify-types --filter=@internal/pane-runtime git add packages/pane-runtime/src/ssh packages/pane-runtime/src/index.ts git commit -m "feat(ssh): port OpenSSH config discovery and ssh -G resolve into pane-runtime" ``` @@ -225,10 +250,9 @@ git commit -m "feat(ssh): port OpenSSH config discovery and ssh -G resolve into - [ ] **Step 1: Copy the fixture and check its imports** ```bash +cd /home/theo/projects/wt-ssh-anywhere B=origin/feat/ssh-support -cd /home/theo/projects/subshell -mkdir -p /home/theo/projects/wt-ssh-anywhere/e2e/fixtures -git show "$B:e2e/fixtures/sshd.ts" > /home/theo/projects/wt-ssh-anywhere/e2e/fixtures/sshd.ts +git show "$B:e2e/fixtures/sshd.ts" > e2e/fixtures/sshd.ts ``` Open it: it must depend only on `node:*` + repo-internal e2e helpers (`../ports`, `../stack`) that already exist on `main`. Fix any import that pointed at a #330-only helper. @@ -253,49 +277,130 @@ git commit -m "test(e2e): port the isolated sshd fixture" - Create: `apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts` - Modify: `apps/server/api/src/db/migrate.ts` - Modify: `apps/server/api/src/db/types/nodes.db-types.ts` -- Test: `apps/server/api/src/db/__tests__/migration-0047.test.ts` +- Test: `apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` (the established migration-test placement and pattern is `0031-node-maintenance.test.ts` beside the migrations, hand-built Kysely over a migration subset; the boot-seeded `local` row does NOT exist in such a DB, so the test proves the default on its own inserted rows) **Interfaces:** - Produces: `NodeTable.sshEnabled: number` (0/1), `NodeTable.sshEnabledAt: string | null`; `NewNode.sshEnabled?: number`. - [ ] **Step 1: Write the failing migration test** +Model it on `0031-node-maintenance.test.ts` read verbatim (`git show origin/main:apps/server/api/src/db/migrations/__tests__/0031-node-maintenance.test.ts`): hand-built Kysely over a migration subset, a row inserted BEFORE the new migration runs (that is the upgrade case), raw `pragma_table_info` checks, and a down/up round trip. The full test: + ```ts -import { describe, expect, it } from "bun:test"; -import { sql } from "kysely"; -import { testDatabase } from "@/__tests__/helpers/test-database.js"; - -describe("migration 0047 nodes.ssh_enabled", () => { - it("adds a not-null default-0 column and a nullable stamp", async () => { - const { db } = await testDatabase(); - const cols = await sql<{ name: string }>`select name from pragma_table_info('nodes')`.execute(db); - const names = cols.rows.map((r) => r.name); +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The SSH capability gate columns. Default 0 is the whole upgrade story: a + * node enrolled before this migration (and the seeded local row) reads OFF, + * because SSH egress and key use are opt-in per machine. + */ +describe("migration 0047-node-ssh-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0047 repository did - no ssh fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0047-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await sshEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); expect(names).toContain("ssh_enabled"); expect(names).toContain("ssh_enabled_at"); - const row = await db.selectFrom("nodes").select(["ssh_enabled"]).where("id", "=", "local").executeTakeFirstOrThrow(); - expect(row.ssh_enabled).toBe(0); // default off, including local + }); + + it("leaves every pre-existing node with SSH off (default-off doctrine)", async () => { + const r = await sql<{ ssh_enabled: number; ssh_enabled_at: string | null }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 0, ssh_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ ssh_enabled: number }>`SELECT ssh_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].ssh_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ ssh_enabled: number; ssh_enabled_at: string }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await sshEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("ssh_enabled"); + expect(names).not.toContain("ssh_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await sshEnabledMigration.up(db); // leave the DB on the current shape }); }); ``` -(Confirm the helper module name/path `@/__tests__/helpers/test-database.js` against the existing `0031-node-maintenance` migration test and mirror it exactly.) +(`Math.random()` is fine here: this is a bun:test file, not a Workflow script. The temp file name follows the 0031 test's own pattern.) - [ ] **Step 2: Run it to confirm it fails** -Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/__tests__/migration-0047.test.ts` -Expected: FAIL (no such column / migration not registered). +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` +Expected: FAIL (module `0047-node-ssh-enabled.js` does not exist yet). -- [ ] **Step 3: Write the migration (copy the 0031 shape)** +- [ ] **Step 3: Write the migration (copy the 0031 shape; kysely's `addColumn` is three-arg: name, type expression, constraint callback)** ```ts import type { Kysely } from "kysely"; -import { sql } from "kysely"; // Adds the per-node SSH capability gate. Default 0 keeps the whole fleet (and -// the control-plane host) off after an upgrade: SSH egress and key use are opt-in. +// the control-plane host) off after an upgrade: SSH egress and key use are +// opt-in per machine (spec 4.3; the maintenance default-0 "upgrade keeps the +// fleet as it was" doctrine). export async function up(db: Kysely): Promise { - await db.schema.alterTable("nodes").addColumn("ssh_enabled", (col) => col.integer().notNull().defaultTo(0)).execute(); - await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", (col) => col.text()).execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled", "integer", (col) => col.notNull().defaultTo(0)).execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", "text").execute(); } export async function down(db: Kysely): Promise { @@ -314,13 +419,13 @@ In `nodes.db-types.ts`, next to `maintenance`/`maintenanceAt`, add `sshEnabled: - [ ] **Step 6: Run the test to confirm it passes** -Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/__tests__/migration-0047.test.ts` +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` Expected: PASS. - [ ] **Step 7: Commit** ```bash -git add apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts apps/server/api/src/db/migrate.ts apps/server/api/src/db/types/nodes.db-types.ts apps/server/api/src/db/__tests__/migration-0047.test.ts +git add apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts apps/server/api/src/db/migrate.ts apps/server/api/src/db/types/nodes.db-types.ts apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts git commit -m "feat(nodes): add ssh_enabled capability column (default off) + migration" ``` @@ -406,45 +511,73 @@ git commit -m "feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabl --- -## Task 6: Plane write + view + `PUT /:id/ssh-enabled` route +## Task 6: Plane write + view + service + `PUT /:id/ssh-enabled` route **Files:** - Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` - Modify: `apps/server/api/src/api/nodes/node-view.ts` -- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (this task: row write + audit; Task 7 adds `pushSetSshEnabled` + `reconcileSshEnabled` to the SAME file) +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` (thin gate + service call, exactly like `set-node-maintenance.route.ts` calling `setNodeMaintenance`) - Modify: `apps/server/api/src/api/nodes/index.ts` -- Test: `apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.test.ts` +- Modify: `docs/security.md` §10 event list and `.claude/rules/security-context.md` (add `node.ssh_enabled.update` to the nodes audit family) +- Test: `apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts` **Interfaces:** -- Consumes (Task 4): row fields; (Task 5): nothing directly (view reports the flag, not the predicate). -- Produces: `repos.nodes.setSshEnabled(id, { on, changedAt }): Promise`; `NodeView.sshEnabled: boolean`; route `PUT /api/nodes/:id/ssh-enabled` body `{ on: boolean }` returning the node view. +- Consumes (Task 4): row fields; (Task 5): nothing directly (the view reports the flag; `nodeCanSsh` is consumed by the Plan 2 launch path). +- Produces: `repos.nodes.setSshEnabled(id, { on, changedAt }): Promise`; `setNodeSshEnabled(write): Promise` - the ONE site of the change act (row write + audit; Task 7 layers the push into this same function). The route must not write or audit itself: `maintenance.ts`'s own doc comment states the reason - "two implementations of a four-step act is two implementations that drift, and the half that drifts is whichever one nobody exercised"; `apps/server/api/src/services/nodes/maintenance.ts` is the file to mirror; +- `NodeView.sshEnabled: boolean`; route `PUT /api/nodes/:id/ssh-enabled` body `{ on: boolean }` returning the node view. -- [ ] **Step 1: Failing route test (owner on, non-owner 403/404, audit written, local admin-only)** +- [ ] **Step 1: Failing route test (owner on, non-owner refused, audit written, local admin-only)** -Mirror the structure of the existing `set-node-maintenance` route test: create a member + a node they own; PUT `{on:true}` as owner → 200 and `sshEnabled:true`; PUT as a non-owner member → 404/403; PUT `local` as non-admin → refused; assert an audit row `node.ssh_enabled` with `{ on }` metadata and no secret. +Mirror the structure of `set-node-maintenance.route.test.ts`: create a member + a node they own; PUT `{on:true}` as owner -> 200 with `sshEnabled: true` and an audit row action `node.ssh_enabled.update` whose `metadataJson` is the JSON string `"{\"on\":true}"` (no secret, no config values); PUT as a member who neither owns nor is granted the node -> the same 404 the maintenance route gives for invisible rows; PUT as an `edit` grantee -> refused (management of the flag is owner-only even though launch is granted - spec 4.3); PUT `local` as member -> refused, as admin -> 200. - [ ] **Step 2: Run to confirm it fails**, then **Step 3: implement `setSshEnabled` in the repository** (copy `setMaintenance`'s shape, only `ssh_enabled`/`ssh_enabled_at`, `ssh_enabled: on ? 1 : 0`). - [ ] **Step 4: add `sshEnabled` to the view** - in `NodeViewSchema` a `sshEnabled: t.Boolean({ description: "…" })`; in `nodeViewBase`, `sshEnabled: row.sshEnabled === 1`. Both list and detail flow through `nodeViewBase`, so one add lands both. -- [ ] **Step 5: write the route** (copy `set-node-maintenance.route.ts`): +- [ ] **Step 5: write the service, then the route** + +`services/nodes/ssh-enabled.ts` (mirror `maintenance.ts`'s audit call exactly - string metadata, `getRequestlessContext()` for repos): + +```ts +export interface SshEnabledWrite { + nodeId: string; + on: boolean; + changedAt: string; + actorUserId: string; +} + +/** Flip one node's SSH capability: row, audit, and (from Task 7) the best-effort + * push - all in this one function, so no caller can perform half the act. */ +export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { + const { repos } = getRequestlessContext(); + await repos.nodes.setSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); + await audit({ + actorUserId: write.actorUserId, + action: "node.ssh_enabled.update", + targetType: "node", + targetId: write.nodeId, + metadataJson: JSON.stringify({ on: write.on }), + }); + // Task 7 appends here: void pushSetSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} +``` + +The route copies `set-node-maintenance.route.ts` (`requireCookieActor` -> `loadNodeGate` -> 404 if missing/invisible -> `if (!gate.canManage)` ForbiddenError -> `await setNodeSshEnabled({ nodeId, on: body.on, changedAt: new Date().toISOString(), actorUserId: user.id })` -> re-read the row -> `toNodeView(row, access, isAdmin, granted)`), with the body schema: ```ts const SshEnabledBodySchema = t.Object({ on: t.Boolean({ description: "Allow this machine to be used for Subshell SSH (outbound ssh and serving its keys). Off by default. Owner only; admin on the control-plane host." }), }); -// requireCookieActor -> loadNodeGate -> 404 if missing/invisible -> if (!gate.canManage) ForbiddenError -// -> await repos.nodes.setSshEnabled(id, { on, changedAt: new Date().toISOString() }) -// -> audit({ actorUserId: user.id, action: "node.ssh_enabled", targetType: "node", targetId: id, metadataJson: { on } }) -// -> void pushSetSshEnabled(id, { on, changedAt }) (Task 7; may be a no-op stub until then) -// -> respond toNodeView(row, access, isAdmin, granted) ``` Mount it in `api/nodes/index.ts` beside the maintenance route. - [ ] **Step 6: Run the route test to confirm pass**, then **rebuild backend so the Eden client sees the route**: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/server --filter=@internal/backend-client`. -- [ ] **Step 7: Commit** (all touched files + the test). +- [ ] **Step 7: Audit-event housekeeping** - add `node.ssh_enabled.update` to the event-name list in `docs/security.md` section 10 (beside `node.maintenance.update`) and to the nodes family line in `.claude/rules/security-context.md`; a name that exists in code but not in the list is a list that lies. + +- [ ] **Step 8: Commit** (all touched files + the test). --- @@ -456,11 +589,11 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. - Modify: `apps/node/agent/src/commands/context.ts`, `.../report.ts`, `.../index.ts`, `apps/node/agent/src/daemon.ts` - Modify: `packages/subshell-protocol/src/node-frames.ts`, `packages/subshell-protocol/src/index.ts` - Modify: `apps/server/api/src/services/nodes/node-events.ts`, `apps/server/api/src/index.ts` -- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` +- Modify: `apps/server/api/src/services/nodes/ssh-enabled.ts` (created in Task 6; this task appends the push and reconcile and enables the push line in `setNodeSshEnabled`) - Test: `apps/node/agent/src/__tests__/ssh-enabled.test.ts` **Interfaces:** -- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`. +- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled(ctx, state: NodeSshEnabledWire)`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`, and `NODE_PROTOCOL_VERSION` 15 -> 16. - [ ] **Step 1: Failing node-side test** - `readSshEnabled` on an empty dir → `{kind:"absent"}`; after `writeSshEnabled({on:true})` → `{kind:"on"}`; a chmod-000 (or unreadable) file → `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". @@ -468,13 +601,13 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. - [ ] **Step 3: Implement `apps/node/agent/src/ssh-enabled.ts`** mirroring `maintenance.ts` exactly (same file-permission doctrine 0600 temp+rename, same `MaintenanceRead`-shaped result with `kind`, the `unreadable` fail-closed arm), but: default/absent means **off**, and there is **no self-toggle** (plane is the only writer). -- [ ] **Step 4: Protocol arms** in `node-frames.ts`: `interface NodeSshEnabledWire { on: boolean; changedAt: string }`, `parseNodeSshEnabled`, a `set_ssh_enabled` command arm, an `ssh_enabled` event arm, and `ready` gains `sshEnabled?: NodeSshEnabledWire` (drop-not-fatal when malformed, like `maintenance`). Re-export from `index.ts`. +- [ ] **Step 4: Protocol arms + version bump** in `node-frames.ts`: `interface NodeSshEnabledWire { on: boolean; changedAt: string }`, `parseNodeSshEnabled`, a `set_ssh_enabled` command arm, an `ssh_enabled` event arm, and `ready` gains `sshEnabled?: NodeSshEnabledWire` (drop-not-fatal when malformed, like `maintenance`). Then bump `NODE_PROTOCOL_VERSION` 15 -> 16 and add the file's history-comment entry: additive, and breaking anyway, because the link gate is exact-match - pre-16 agents must never be wire-ambiguous about honoring `set_ssh_enabled` (spec 4.3). Re-export from `index.ts`. - [ ] **Step 5: Agent executor** `commands/set-ssh-enabled.ts` mirroring `set-maintenance.ts`: write the mirror file, set `ctx.lastReportedSshEnabled`, return `{ ok: true }`. Dispatch it in `commands/index.ts`. -- [ ] **Step 6: Reporting** - in `report.ts` add `reportSshEnabled(ctx, on)`, `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. +- [ ] **Step 6: Reporting** - in `report.ts` add `reportSshEnabled(ctx, state: NodeSshEnabledWire)` (mirror `reportMaintenance(ctx, state)`: the wire carries `{ on, changedAt }`, the `ready` arm cannot be built from `on` alone), `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. -- [ ] **Step 7: Plane reconcile** - `services/nodes/ssh-enabled.ts`: `setNodeSshEnabled` (row write + audit + best-effort push), `pushSetSshEnabled` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. +- [ ] **Step 7: Plane push + reconcile, into Task 6's file** - append to `services/nodes/ssh-enabled.ts`: `pushSetSshEnabled(nodeId, { on, changedAt })` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort, mirroring `maintenance.ts`'s push), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Enable the commented push line inside `setNodeSshEnabled` from Task 6, so the write, the audit, and the push stay one function. Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. - [ ] **Step 8: Run all touched tests + build**: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd apps/node/agent && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-enabled.test.ts && cd ../../server/api && env -u SHELLOPTS -u BASHOPTS bun test src/services/nodes` @@ -494,11 +627,12 @@ bun run verify-types bun run lint:check bun run lint:prose bunx turbo build +bun run test ``` -Expected: all green. `verify-types` is what proves the port engines' barrel edits and the new protocol arms typecheck across dependents. +Expected: all green. `verify-types` is what proves the port engines' barrel edits and the new protocol arms typecheck across dependents, and `bun run test` is mandatory here (pre-push does NOT run it, and this plan touched the protocol version every suite reads). Note the protocol bump is safe locally: all consumers import `NODE_PROTOCOL_VERSION` symbolically (verified on main), so no literal `15` asserts exist outside the constant. -- [ ] **Step 2: Focused suite counts** - run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). +- [ ] **Step 2: Focused suite counts** - re-run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). - [ ] **Step 3: Changeset** - add a minor changeset noting the new `nodes.ssh_enabled` gate (migration 0047) and the ported ssh primitives, so release tooling sees the schema change. @@ -509,10 +643,12 @@ git push --no-verify -u origin HEAD gh pr create --base feat/ssh-anywhere --title "SSH anywhere 1/3: foundations (ssh_enabled gate + ported primitives)" --body "..." ``` +(Base is `feat/ssh-anywhere`, the branch below `feat/ssh-anywhere-1` this plan works on - never the repo default. Open the PR; do not merge it without the operator's word.) + --- ## Self-Review (author, after writing) - **Spec coverage:** Plan 1 covers spec §4.3 (the gate), §7 (`ssh_enabled` column), §15 (port grammar + engines + fixture), and the `nodeCanSsh` predicate from §5/§13. It does NOT cover the destination-first UI (§11), the terminal launch, or host-key pinning - those are Plans 2 and 3, by design. No M2 (relay) items leaked in. -- **Type consistency:** the wire type name is `NodeSshEnabledWire` everywhere; the repo method is `setSshEnabled`; the predicate is `nodeCanSsh`; the audit action is `node.ssh_enabled`; the migration is `0047`. Confirm each before building on it in Plan 2. -- **Placeholders:** every "copy from `maintenance.ts`"/`git show` step names the exact source; new code is shown in full. Where a step says "adapt to the actual export name/`test-database` helper path," that is a deliberate verify-against-source instruction, not a content gap - resolve it during the task, do not leave a stub. +- **Type consistency:** the wire type name is `NodeSshEnabledWire` everywhere; the repo method is `setSshEnabled`; the single change-site is `setNodeSshEnabled` (Task 6 creates it write+audit, Task 7 appends the push); the predicate is `nodeCanSsh` (options object, `isAdmin`, no `granted`); the audit action is `node.ssh_enabled.update`; the migration is `0047`; `NODE_PROTOCOL_VERSION` lands at 16. Confirm each before building on it in Plan 2. +- **Placeholders:** every "copy from X"/`git show` step names the exact source and lands in the worktree; new code is shown in full. Export names were verified against `origin/feat/ssh-support`'s files on 2026-10-07 (the engines' symbols, the grammar test's fixtures module, `kysely`'s three-arg `addColumn`, the hand-built migration-test pattern) - if any drifts when the task runs, the build or the test tells you; do not leave a stub. diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index ad12f8910..1942746ea 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -101,7 +101,12 @@ A (which dials out and serves its key) enabled; a jump hop `J` and the destinati `D` are dialed-to, not gated. Only the owner (admin on `local`) of an enabled machine may route SSH through it (mirrors `nodeCanSsh`, which carries the same local-admin exception `nodeCanManageFor` applies); an `edit` grantee of someone else's enabled node -is not thereby allowed to use that machine's egress or keys. +is not thereby allowed to use that machine's egress or keys. The gate ships with a +`NODE_PROTOCOL_VERSION` bump: the node-link compatibility gate is exact-match, so a +pre-bump agent that never sees `set_ssh_enabled` and reports no `sshEnabled` would +be wire-indistinguishable from a post-bump agent that honors both, and that +ambiguity is exactly what a capability gate cannot have (the file's own history +bumped even for additive frames for this reason). ## 5. Architecture components @@ -133,8 +138,12 @@ Every token in the built command is passed through `shellQuote`; the connection one shell string through tmux, so quoting is the load-bearing defense and is never relaxed. The built `ssh` runs against the rendered snapshot config via `-F` only, never the connecting machine's live `~/.ssh/config`: `~/.ssh/config` is -discovery/resolve input only, so settings the session policy forbids (ForwardAgent, -remote/local forwards, ProxyCommand) cannot reappear at connect time. +discovery/resolve input only, and the `-F` file is rendered solely from the approved +snapshot: settings refused by name at resolve (ProxyCommand, tunnels/forwards, +remote/local commands, env and escape sends) never reach the render, and settings +like `ForwardAgent` that are neither snapshotted nor named cannot reappear at +connect time because they are absent from the rendered file and ssh's own defaults +are off. ### 5.3 Sealed agent relay (Milestone 2) @@ -147,14 +156,19 @@ requests the plane brokered for a live grant, enforces the agent method allow-li back. The plane routes by the recipient id in the envelope header without opening anything. Detail in section 8. -This is not "the primitives used as is." It needs a machine-to-machine identity -roster and a TOFU pin flow that do not fully exist today: nodes already hold a -registration ECDH-ES keypair (`apps/node/agent/src/identity.ts`, private half never -leaves the machine) and an identities store keyed by principal exists, but a node is -not registered there as a readable principal and there is no client-visible read path -or machine-to-machine TOFU pin flow. Building that (a `node:` identity, a read -path, and first-use pinning, and deciding whether the relay obeys -`SUBSHELL_CHANNEL_PIN` or is always strict) is part of the M2 work. It must also +This is not "the primitives used as is." The roster half already exists: a node's +registration ECDH-ES identity (`apps/node/agent/src/identity.ts`, private half never +leaves the machine) is registered in the identities store as a `node:` principal +at enroll time (the enroll route writes it; node deletion cascades it), and +`GET /api/identities/:principalId` already serves any principal's public key behind +a `channels:read` gate. What is missing is the node end: the agent holds no REST +credential, so the peer's JWK must be delivered to it inside a signed command on the +existing node link; the agent has no pin store (the channels `peers.json` lives in +the server app's data dir, not the node's); and there is no operator-visible machine +pin display or recovery flow. Building that (link key delivery, a node-side pin +store, first-use pinning, pin recovery) is part of the M2 work, and the relay is +always pin-strict: `SUBSHELL_CHANNEL_PIN` is the channels' recovery escape and never +relaxes a machine-relay pin. It must also mint and hold a per-machine ES256 signing keypair: `identity.ts` today holds only the ECDH-ES encryption half, and the ES256 command keypair (`control-keys.ts`) is plane-held, so the signing key the relay's origin proof (section 8) needs does not @@ -212,8 +226,9 @@ clean seam the M1 launcher exposes. - `ssh_enabled` (default `false`, including `local`): the per-node capability gate of section 4.3. Plane-authored row value, cached in a node-side mirror file the - node reads only to fail closed; owner/admin to change; audited as - `node.ssh_enabled`. Not a new table (a column on `nodes`). + node reads only to fail closed; owner to change (admin on `local`, section 4.3); + audited as `node.ssh_enabled.update`, the node-family verb pattern, added to the + `docs/security.md` section 10 event list. Not a new table (a column on `nodes`). - `ssh_saved_hosts` (M1): per owner, keyed by the resolved canonical destination `user@host:port` (display alias separate), with the connecting box used and last-connect; recent hosts derive from connects. Also stores the user's default @@ -312,9 +327,10 @@ its own key. That is exactly the first-use window `pin-store.ts` documents for channels (a compromised server swapping a key into a roster slot, undetectable to the sender) and the reason that module exists. Closing it needs an out-of-band pin or a first-secure-contact story, which is M2 design, and `docs/security.md` section 0 -already lists control-plane compromise as not-defended. The relay inherits -`pin-store`'s strictness and its `SUBSHELL_CHANNEL_PIN` escape unless M2 says -otherwise. +already lists control-plane compromise as not-defended. The relay reuses `pin-store`'s +byte-equality rule but not its `SUBSHELL_CHANNEL_PIN` escape: a machine-relay pin is +always strict, and recovery is an explicit re-pairing that replaces the pin +(section 5.3). Unchanged or accepted: the OS-user boundary on A and B is still the operator; a machine you run the agent on is a machine you trust. A already-compromised B can @@ -386,7 +402,7 @@ system: role tokens only, at most two sentences, no em dashes. it is re-ported then, not assumed here. - Outbound SSH is gated per node, off by default including `local`, and fails closed when the setting is unreadable (section 4.3); enabling is owner (admin on `local`) - only and audited (`node.ssh_enabled`). + only and audited (`node.ssh_enabled.update`). - `SSH_AUTH_SOCK` is exported only into the ssh invocation, never the whole pane env, and an agent-capable pane is view-only to sharees (section 5.4). - Static imports only; no dynamic import outside the one sanctioned plugin site. @@ -398,7 +414,9 @@ system: role tokens only, at most two sentences, no em dashes. - Unit, M1: alias discovery budgets and wildcard exclusion; `ssh -G` snapshot parsing and the forbidden-settings refusal; ProxyJump argv construction and quoting; the `ssh_enabled` gate default-off, fail-closed-on-unreadable, - owner/admin gating, and `nodeCanSsh` refusal codes; the scoped `SSH_AUTH_SOCK` + owner/admin gating, and the `nodeCanSsh` boolean matrix (the predicate answers + yes/no; distinct refusal copy belongs to the launch route, section 12); the + scoped `SSH_AUTH_SOCK` export (present on the ssh process, absent from the rest of the pane env); and the view-only rule (a sharee typed into an SSH-terminal pane is refused input, section 5.4). @@ -439,9 +457,10 @@ flow is a cautionary example, not a base. ## 16. Deferred / open items for Milestone 2 -- The machine-identity roster and TOFU pin flow (section 5.3), including whether the - relay obeys `SUBSHELL_CHANNEL_PIN` or is always strict, and how to narrow the - first-use window (section 10). +- The node end of the machine-identity flow (section 5.3): link key delivery, the + node-side pin store, pin display and recovery, and how to narrow the first-use + window (section 10). The strictness question is decided: machine-relay pins are + always strict, no `SUBSHELL_CHANNEL_PIN` escape. - Whether the agent-destination upgrade reuses #330's runtime-session protocol or a lighter boot now that the terminal path exists. - Per-use approval UX on A (notification or prompt to confirm a handshake), versus a From 3bb572ac5d8c08eb43ae5685cc256716d528aea1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:32:25 -0700 Subject: [PATCH 010/170] docs(ssh-anywhere): spec+plan review round 5 - nodes already carry a sess-principal peers.json (machine relay gets a separate always-strict store), grammar port gains ssh-frames (the fixtures' type imports, verify-types-visible), Task 2 edges fixed (login-path kept, expandTilde barrel-internal), predicate JSDoc stops promising codes, worktree cd on all commits, plane-side reconcile test added --- .../2026-10-07-ssh-anywhere-1-foundations.md | 28 +++++++++------- .../specs/2026-10-07-ssh-anywhere-design.md | 32 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md index 10c507f43..8ea9e09e0 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -48,6 +48,7 @@ Ports (verbatim copies off `origin/feat/ssh-support`, then export wiring; the po - Create: `packages/subshell-protocol/src/ssh-limits.ts` (budgets; no `node:` builtins → barrel-safe) - Create: `packages/subshell-protocol/src/ssh-errors.ts` (error codes + shipped descriptions; imports nothing → barrel-safe) - Create: `packages/subshell-protocol/src/ssh-results.ts` (resolve/discover wire outcomes; type-only imports → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-frames.ts` (ssh RPC command grammar; needed by the fixtures' type imports - not barrel-exported in this plan) - Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` - Create: `packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts` (imported by the grammar test) - Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` (`node:*` → pane-runtime barrel only) @@ -85,10 +86,12 @@ Gate (new, mirroring the maintenance template): ## Task 1: Port the ssh grammar into `subshell-protocol` (barrel-safe) **Files:** -- Create: `packages/subshell-protocol/src/ssh-limits.ts`, `.../ssh-config.ts`, `.../ssh-errors.ts`, `.../ssh-results.ts` +- Create: `packages/subshell-protocol/src/ssh-limits.ts`, `.../ssh-config.ts`, `.../ssh-errors.ts`, `.../ssh-results.ts`, `.../ssh-frames.ts` - Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts`, `.../__tests__/fixtures/ssh-fixtures.ts` - Modify: `packages/subshell-protocol/src/index.ts` +(`ssh-frames.ts` - the `ssh_discover_aliases` / `ssh_resolve_config` command grammar, 87 lines, imports only `./guards.js` (already on main) and `./ssh-limits.js` - is ported because the fixtures file imports its two command types, and `verify-types` typechecks `src/**` including `__tests__`, so a dangling import there is a Task 8 failure. It is deliberately NOT barrel-exported: no M1 consumer, and Plan 2 wires its parser when it mounts the ssh RPC verbs.) + **Interfaces:** - Consumes: nothing. - Produces (from `@internal/subshell-protocol`): the limits `SSH_MAX_DISCOVERED_ALIASES`, `SSH_MAX_PROXY_HOPS`, `SSH_NAME_MAX_CHARS`, `SSH_PROBE_DEADLINE_MS`; the grammar `type SshHopWire`, `type SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS`; the named-refusal codes and shipped sentences `SSH_ERROR_CODES`, `type SshErrorCode`, `isSshErrorCode`, `SSH_ERROR_DESCRIPTIONS` (the Plan 3 review UI renders the descriptions by equality, never the wire code); the RPC outcomes `type NodeSshAliasListResult`, `type NodeSshResolveOutcomeWire`. This is exactly what Task 2's engines import from the package barrel (`ssh-resolve.ts` pulls seven of these symbols; a missing one fails its test run at Task 2 Step 2, not here). @@ -99,19 +102,19 @@ Gate (new, mirroring the maintenance template): cd /home/theo/projects/wt-ssh-anywhere B=origin/feat/ssh-support mkdir -p packages/subshell-protocol/src/__tests__/fixtures -for f in ssh-limits ssh-config ssh-errors ssh-results; do +for f in ssh-limits ssh-config ssh-errors ssh-results ssh-frames; do git show "$B:packages/subshell-protocol/src/$f.ts" > packages/subshell-protocol/src/$f.ts done git show "$B:packages/subshell-protocol/src/__tests__/ssh-config.test.ts" > packages/subshell-protocol/src/__tests__/ssh-config.test.ts git show "$B:packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts" > packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts -grep -nE "from \"node:" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts # MUST print nothing +grep -nE "from \"node:" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts # MUST print nothing ``` -Expected: the `grep` prints nothing (all four modules are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. +Expected: the `grep` prints nothing (all five modules are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. - [ ] **Step 2: Rewrite the deleted-document pointers** -The copied files' doc comments cite `SSH-SUPPORT.md`, deleted as superseded. Replace every citation with `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`, keeping the sentence true (drop a `§N` cross-reference when the spec has no matching section rather than pointing at a wrong one). Check: `grep -c "SSH-SUPPORT" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts` prints `0` for all four. +The copied files' doc comments cite `SSH-SUPPORT.md`, deleted as superseded. Replace every citation with `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`, keeping the sentence true (drop a `§N` cross-reference when the spec has no matching section rather than pointing at a wrong one). Check: `grep -c "SSH-SUPPORT" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts` prints `0` for all five. - [ ] **Step 3: Run the grammar test (it imports the copied files by relative path, so it is green before any barrel wiring)** @@ -163,8 +166,8 @@ Expected: nonzero - the built barrel references the grammar; because the four fi ```bash cd /home/theo/projects/wt-ssh-anywhere -git add packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts -git commit -m "feat(ssh): port OpenSSH grammar, budgets, and named-refusal codes into subshell-protocol" +git add packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts +git commit -m "feat(ssh): port OpenSSH grammar, budgets, command frames, and named-refusal codes into subshell-protocol" ``` --- @@ -178,7 +181,7 @@ git commit -m "feat(ssh): port OpenSSH grammar, budgets, and named-refusal codes **Interfaces:** - Consumes (Task 1): `type NodeSshResolveOutcomeWire`, `parseSshConnectionSnapshot`, `SSH_MAX_PROXY_HOPS`, `SSH_PROBE_DEADLINE_MS`, `type SshConnectionSnapshotWire`, `type SshErrorCode`, `type SshHopWire` (ssh-resolve.ts) plus `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS` (ssh-discover.ts), and `type SshConnectionSnapshotWire` in helpers.ts - all from `@internal/subshell-protocol`. -- Produces (from `@internal/pane-runtime`): `runSshProcess`, `type SshProcessResult`, `sshChildPath` (spawn); `defaultSshConfigPath`, `discoverSshAliases`, `isDiscoverableAlias`, `expandTilde`, `walkSshConfig`, `type SshAliasDiscovery`, `type SshConfigWalk`, `type SshHostBlock`, `type SshWalkBudget` (discover); `parseProxyHop`, `resolveSshAliasConfig`, `type SshResolutionDeps` (resolve). These are the branch's real export names, verified from the files themselves on 2026-10-07. +- Produces (from `@internal/pane-runtime`): `runSshProcess`, `type SshProcessResult`, `sshChildPath` (spawn); `defaultSshConfigPath`, `discoverSshAliases`, `isDiscoverableAlias`, `walkSshConfig`, `type SshAliasDiscovery`, `type SshConfigWalk`, `type SshHostBlock`, `type SshWalkBudget` (discover); `parseProxyHop`, `resolveSshAliasConfig`, `type SshResolutionDeps` (resolve). These are the branch's real BARREL export names, verified against the branch's own `pane-runtime/src/index.ts` on 2026-10-07 (`expandTilde` is file-exported but barrel-internal, as on the branch). - [ ] **Step 1: Copy the three engines and their three test files verbatim** @@ -194,7 +197,7 @@ for f in helpers ssh-discover.test ssh-resolve.test; do done ``` -Then confirm each engine's imports are ONLY `node:*`, its `./` siblings inside `ssh/`, or `@internal/subshell-protocol`. The known true edges (keep them): `ssh-resolve.ts` imports `defaultSshConfigPath`, `expandTilde`, `type SshHostBlock`, `type SshWalkBudget`, `walkSshConfig` from `./ssh-discover.js` and `runSshProcess`, `sshChildPath` from `./ssh-spawn.js`; helpers.ts imports a protocol type. A `./ssh-render` / `./ssh-session-*` / `./kill-group` / codec import would be coupling to the abandoned runtime path - none exists on the branch today, so if one appears, record it and de-couple it (never by deleting a protocol import Task 1 supplies). Rewrite the `SSH-SUPPORT.md` pointers as in Task 1 Step 2 and check `grep -l "SSH-SUPPORT" packages/pane-runtime/src/ssh/*.ts packages/pane-runtime/src/ssh/__tests__/*.ts` prints nothing. +Then confirm each engine's imports are ONLY `node:*`, its `./` siblings inside `ssh/`, `../login-path.js`, or `@internal/subshell-protocol`. The known true edges (keep them): `ssh-spawn.ts` imports `loginPathEntries` from `../login-path.js` (a main-resident module, `packages/pane-runtime/src/login-path.ts`); `ssh-resolve.ts` imports `defaultSshConfigPath`, `expandTilde`, `type SshHostBlock`, `type SshWalkBudget`, `walkSshConfig` from `./ssh-discover.js` and `runSshProcess`, `sshChildPath` from `./ssh-spawn.js`; helpers.ts imports a protocol type. A `./ssh-render` / `./ssh-session-*` / `./kill-group` / codec import would be coupling to the abandoned runtime path - none exists on the branch today, so if one appears, record it and de-couple it (never by deleting a protocol import Task 1 supplies). Rewrite the `SSH-SUPPORT.md` pointers as in Task 1 Step 2 and check `grep -l "SSH-SUPPORT" packages/pane-runtime/src/ssh/*.ts packages/pane-runtime/src/ssh/__tests__/*.ts` prints nothing. - [ ] **Step 2: Run the ported tests** @@ -425,6 +428,7 @@ Expected: PASS. - [ ] **Step 7: Commit** ```bash +cd /home/theo/projects/wt-ssh-anywhere git add apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts apps/server/api/src/db/migrate.ts apps/server/api/src/db/types/nodes.db-types.ts apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts git commit -m "feat(nodes): add ssh_enabled capability column (default off) + migration" ``` @@ -484,8 +488,9 @@ Expected: FAIL (`nodeCanSsh` not exported). * server gate they are allowed to enable). Deliberately NOT shaped like * `nodeCanLaunchOn`: local's seeded Everyone grant confers launch, never SSH use, * so there is no `granted` parameter here to widen it. Gated by the row's - * ssh_enabled (fail-closed) and, for `local`, the server-as-node switch. A - * distinct refusal from maintenance/no-launch, so the UI can say why. */ + * ssh_enabled (fail-closed) and, for `local`, the server-as-node switch. Answers + * yes/no only; naming WHY (gate off vs not owner vs maintenance vs no-launch) is + * the caller's copy, per spec section 12. */ export function nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; @@ -591,6 +596,7 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. - Modify: `apps/server/api/src/services/nodes/node-events.ts`, `apps/server/api/src/index.ts` - Modify: `apps/server/api/src/services/nodes/ssh-enabled.ts` (created in Task 6; this task appends the push and reconcile and enables the push line in `setNodeSshEnabled`) - Test: `apps/node/agent/src/__tests__/ssh-enabled.test.ts` +- Test: `apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts` (the plane half: `reconcileSshEnabled`'s decide-table, mirroring `maintenance-decide.test.ts`'s pattern: reported matches row -> no push; reported disagrees -> push the ROW value, never adopt from the node; row on + node silent -> push; `local` -> push is a no-op; and `setNodeSshEnabled` writes + audits exactly one row) **Interfaces:** - Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled(ctx, state: NodeSshEnabledWire)`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`, and `NODE_PROTOCOL_VERSION` 15 -> 16. diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index 1942746ea..2ac355107 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -102,11 +102,11 @@ A (which dials out and serves its key) enabled; a jump hop `J` and the destinati through it (mirrors `nodeCanSsh`, which carries the same local-admin exception `nodeCanManageFor` applies); an `edit` grantee of someone else's enabled node is not thereby allowed to use that machine's egress or keys. The gate ships with a -`NODE_PROTOCOL_VERSION` bump: the node-link compatibility gate is exact-match, so a -pre-bump agent that never sees `set_ssh_enabled` and reports no `sshEnabled` would -be wire-indistinguishable from a post-bump agent that honors both, and that -ambiguity is exactly what a capability gate cannot have (the file's own history -bumped even for additive frames for this reason). +`NODE_PROTOCOL_VERSION` bump because the node-link compatibility gate is exact-match: +a pre-bump agent that never sees `set_ssh_enabled` and reports no `sshEnabled` would +otherwise be wire-indistinguishable from a post-bump agent that honors both, and a +capability gate cannot live with that ambiguity (the file's own history bumped even +for additive frames for this reason). ## 5. Architecture components @@ -163,10 +163,13 @@ at enroll time (the enroll route writes it; node deletion cascades it), and `GET /api/identities/:principalId` already serves any principal's public key behind a `channels:read` gate. What is missing is the node end: the agent holds no REST credential, so the peer's JWK must be delivered to it inside a signed command on the -existing node link; the agent has no pin store (the channels `peers.json` lives in -the server app's data dir, not the node's); and there is no operator-visible machine -pin display or recovery flow. Building that (link key delivery, a node-side pin -store, first-use pinning, pin recovery) is part of the M2 work, and the relay is +existing node link; the agent has no machine pin store (a pane's channel use already +writes a `peers.json` into the agent's data dir, but those are `sess:` channel +pins kept by the pane-side MCP under the `SUBSHELL_CHANNEL_PIN` escape, and the +machine-relay store must be a separate one that never inherits that laxity); and +there is no operator-visible machine pin display or recovery flow. Building that +(link key delivery, a node-side machine pin store, first-use pinning, pin recovery) +is part of the M2 work, and the relay is always pin-strict: `SUBSHELL_CHANNEL_PIN` is the channels' recovery escape and never relaxes a machine-relay pin. It must also mint and hold a per-machine ES256 signing keypair: `identity.ts` today holds only @@ -247,9 +250,11 @@ clean seam the M1 launcher exposes. ## 8. Relay protocol and security (M2) -- Sealing and pinning reuse `packages/mcp-core/src/crypto.ts` (`seal`/`open`, - ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing) and - `packages/mcp-core/src/pin-store.ts` (TOFU, byte-equality, `peers.json`). The +- Sealing reuses `packages/mcp-core/src/crypto.ts` (`seal`/`open`, + ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing); + pinning follows `packages/mcp-core/src/pin-store.ts`'s TOFU byte-equality rule in + a SEPARATE machine-pin store (section 5.3) - not the channels' `peers.json`, and + not the module-global `SUBSHELL_CHANNEL_PIN` settings its public entry honors. The envelope the plane stores and routes is the same opaque blob the encrypted channels already relay. - The plane brokers a relay pairing when a grant is used: it tells A to expect @@ -406,7 +411,8 @@ system: role tokens only, at most two sentences, no em dashes. - `SSH_AUTH_SOCK` is exported only into the ssh invocation, never the whole pane env, and an agent-capable pane is view-only to sharees (section 5.4). - Static imports only; no dynamic import outside the one sanctioned plugin site. -- Machine-to-machine sealed payloads reuse the channel crypto and TOFU store, not a +- Machine-to-machine sealed payloads reuse the channel crypto and the TOFU + byte-equality rule (in their own always-strict machine store, section 5.3), not a new bespoke cipher. ## 14. Testing From ec94aa0796727b217ce128b3de963e04ac4c77bb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:34:15 -0700 Subject: [PATCH 011/170] docs(ssh-anywhere): spec+plan review round 6 - worktree cd on Task 5's commit, node-side mirror write carries the whole {on, changedAt} wire like writeMaintenance --- .../plans/2026-10-07-ssh-anywhere-1-foundations.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md index 8ea9e09e0..185a9c422 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -510,6 +510,7 @@ export function nodeCanSsh(opts: { Run then: ```bash +cd /home/theo/projects/wt-ssh-anywhere git add apps/server/api/src/lib/node-access.ts apps/server/api/src/lib/__tests__/node-access.test.ts git commit -m "feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabled)" ``` @@ -599,9 +600,9 @@ Mount it in `api/nodes/index.ts` beside the maintenance route. - Test: `apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts` (the plane half: `reconcileSshEnabled`'s decide-table, mirroring `maintenance-decide.test.ts`'s pattern: reported matches row -> no push; reported disagrees -> push the ROW value, never adopt from the node; row on + node silent -> push; `local` -> push is a no-op; and `setNodeSshEnabled` writes + audits exactly one row) **Interfaces:** -- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled`, `reportSshEnabled(ctx, state: NodeSshEnabledWire)`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`, and `NODE_PROTOCOL_VERSION` 15 -> 16. +- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled(dataDir, state: NodeSshEnabledWire)` (stores the whole wire, mirroring `writeMaintenance`), `reportSshEnabled(ctx, state: NodeSshEnabledWire)`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`, and `NODE_PROTOCOL_VERSION` 15 -> 16. -- [ ] **Step 1: Failing node-side test** - `readSshEnabled` on an empty dir → `{kind:"absent"}`; after `writeSshEnabled({on:true})` → `{kind:"on"}`; a chmod-000 (or unreadable) file → `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". +- [ ] **Step 1: Failing node-side test** - `readSshEnabled(dataDir)` on an empty dir -> `{kind:"absent"}`; after `writeSshEnabled(dataDir, { on: true, changedAt: "2026-10-07T10:00:00.000Z" })` -> `{kind:"on", changedAt: "2026-10-07T10:00:00.000Z"}` (the mirror stores the whole wire, exactly like `writeMaintenance(dataDir, state)` keeps `{ on, changedAt }` verbatim - `ready.sshEnabled` must be buildable from what was written); a chmod-000 (or unreadable) file -> `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". - [ ] **Step 2: Run to confirm fail.** From 3f9da8486dec9a7c14e74b1a1da2892a4fe81f54 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:41:29 -0700 Subject: [PATCH 012/170] feat(ssh): port OpenSSH grammar, budgets, command frames, and named-refusal codes into subshell-protocol --- .../src/__tests__/fixtures/ssh-fixtures.ts | 120 +++++++++ .../src/__tests__/ssh-config.test.ts | 82 ++++++ packages/subshell-protocol/src/index.ts | 22 ++ packages/subshell-protocol/src/ssh-config.ts | 247 ++++++++++++++++++ packages/subshell-protocol/src/ssh-errors.ts | 148 +++++++++++ packages/subshell-protocol/src/ssh-frames.ts | 87 ++++++ packages/subshell-protocol/src/ssh-limits.ts | 133 ++++++++++ packages/subshell-protocol/src/ssh-results.ts | 60 +++++ 8 files changed, 899 insertions(+) create mode 100644 packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-config.test.ts create mode 100644 packages/subshell-protocol/src/ssh-config.ts create mode 100644 packages/subshell-protocol/src/ssh-errors.ts create mode 100644 packages/subshell-protocol/src/ssh-frames.ts create mode 100644 packages/subshell-protocol/src/ssh-limits.ts create mode 100644 packages/subshell-protocol/src/ssh-results.ts diff --git a/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts new file mode 100644 index 000000000..90e798278 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts @@ -0,0 +1,120 @@ +/** + * Shared SSH fixtures (Gate A, pruned with the destination product by + * Workstream C: the run/terminal/test/control factories deleted with their + * wire arms). Workstreams B, C, D and G build their suites + * from THESE factories rather than hand-rolling frames: a fixture that stops + * parsing is a contract break caught in the protocol package, not four silent + * drifts downstream. Every factory returns the WIRE type (plain data), every + * "boundary" export is the exact legal extreme, and `invalidSnapshotVariants` + * is the adversarial set the node-side validator must refuse - one member per + * absent-forbidden field plus the hygiene refusals. + * + * Pure data + spread overrides; imports nothing from `bun:test` so production + * code could (but should not) share it. + */ + +import type { SshConnectionSnapshotWire, SshHopWire } from "../../ssh-config.js"; +import type { SshErrorCode } from "../../ssh-errors.js"; +import type { SshDiscoverAliasesCommand, SshResolveConfigCommand } from "../../ssh-frames.js"; +import { SSH_MAX_IDENTITY_REFS, SSH_MAX_PROXY_HOPS } from "../../ssh-limits.js"; +import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "../../ssh-results.js"; + +/** A well-formed hop (fixture arg for chains). */ +export function makeHop(n = 0): SshHopWire { + return { host: `jump-${n}.example.net`, user: n % 2 === 0 ? `jumpuser${n}` : null, port: 22 }; +} + +/** The canonical approved snapshot: two identities, an agent, two known-hosts files, one hop. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "app02", + host: "app-02.example.net", + user: "deploy", + port: 22, + identityFiles: ["/home/deploy/.ssh/id_ed25519", "/home/deploy/.ssh/deploy_key"], + certificateFiles: ["/home/deploy/.ssh/id_ed25519-cert.pub"], + authAgentSocket: "/run/user/1000/ssh-agent.sock", + knownHostsFiles: ["/home/deploy/.ssh/known_hosts", "/etc/ssh/ssh_known_hosts"], + hostKeyAlias: null, + proxyJumps: [makeHop(0)], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** + * An invalid snapshot per forbidden field, with a realistic offending value. + * The node validator must refuse EVERY entry here, and the resolve/UI story + * must be able to name which field blocked. + */ +export function invalidSnapshotVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "proxyCommand", snapshot: { ...base, proxyCommand: "nc proxy 8443" } }, + { field: "forwards", snapshot: { ...base, forwards: "LocalForward 9000 socks" } }, + { field: "tunnels", snapshot: { ...base, tunnels: "yes" } }, + { field: "localCommands", snapshot: { ...base, localCommands: "notify-send up" } }, + { field: "remoteCommand", snapshot: { ...base, remoteCommand: "sh -i" } }, + { field: "sendEnv", snapshot: { ...base, sendEnv: "LANG LC_*" } }, + { field: "setEnv", snapshot: { ...base, setEnv: "FOO=bar" } }, + { field: "escapes", snapshot: { ...base, escapes: "~C" } }, + ]; +} + +/** Hygiene violations the snapshot parser must refuse (beyond the forbidden members). */ +export function invalidSnapshotHygieneVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "optionLikeHost", snapshot: { ...base, host: "-oProxyCommand=touch_pwned" } }, + { field: "controlCharUser", snapshot: { ...base, user: "deploy\x00" } }, + { field: "relativeIdentity", snapshot: { ...base, identityFiles: [".ssh/id_ed25519"] } }, + { field: "zeroPort", snapshot: { ...base, port: 0 } }, + { + field: "overMaxHops", + snapshot: { ...base, proxyJumps: Array.from({ length: SSH_MAX_PROXY_HOPS + 1 }, (_, i) => makeHop(i)) }, + }, + { + field: "identityListTooLong", + snapshot: { + ...base, + identityFiles: Array.from({ length: SSH_MAX_IDENTITY_REFS + 1 }, (_, i) => `/home/deploy/.ssh/id_${i}`), + }, + }, + { field: "missingUserKey", snapshot: Object.fromEntries(Object.entries(base).filter(([k]) => k !== "user")) }, + ]; +} + +/* ------------------------------------------------------------------ */ +/* commands */ +/* ------------------------------------------------------------------ */ + +/** The one input-free command, spelled for census loops. */ +export function makeDiscoverAliases(): SshDiscoverAliasesCommand { + return { type: "ssh_discover_aliases" }; +} + +export function makeResolveConfig(overrides: Partial = {}): SshResolveConfigCommand { + return { type: "ssh_resolve_config", alias: "app02", ...overrides }; +} + +export function makeAliasList(overrides: Partial = {}): NodeSshAliasListResult { + return { aliases: ["app01", "app02", "staging"], includeCycle: false, truncated: false, ...overrides }; +} + +export function makeResolveOk(snapshot = makeSnapshot()): NodeSshResolveOutcomeWire { + return { accepted: true, snapshot }; +} + +export function makeResolveRefused( + code: SshErrorCode = "unsupported_setting", + settings = ["ProxyCommand"], +): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} diff --git a/packages/subshell-protocol/src/__tests__/ssh-config.test.ts b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts new file mode 100644 index 000000000..67aab9922 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { parseSshConnectionSnapshot, SSH_FORBIDDEN_SNAPSHOT_FIELDS } from "../ssh-config.js"; +import { SSH_MAX_PROXY_HOPS } from "../ssh-limits.js"; +import { + invalidSnapshotHygieneVariants, + invalidSnapshotVariants, + makeHop, + makeSnapshot, +} from "./fixtures/ssh-fixtures.js"; + +/** + * The Gate A snapshot grammar: what the node re-checks on every command, so + * these refusals ARE the security story of §2's "configuration is executable" + * rule at the boundary. A green suite here means a `ProxyCommand` cannot + * reach a resolver's argv through a snapshot, whatever the plane stored. + */ +describe("parseSshConnectionSnapshot", () => { + it("round-trips the canonical snapshot with every field intact", () => { + const snapshot = makeSnapshot(); + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toEqual(snapshot); + }); + + it("accepts an all-null minimal snapshot (no user, no agent, no hops)", () => { + const minimal = makeSnapshot({ + user: null, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + proxyJumps: [], + }); + expect(parseSshConnectionSnapshot(minimal)).toEqual(minimal); + }); + + it("rebuilt output is not the input object (stray keys die with the copy)", () => { + const input = { ...makeSnapshot(), proxyUseExec: true } as unknown as Record; + const out = parseSshConnectionSnapshot(input); + expect(out).not.toBeNull(); + expect("proxyUseExec" in (out as unknown as Record)).toBe(false); + expect(out).not.toBe(input); + }); + + it("refuses every absent-forbidden member carrying a value", () => { + for (const { field, snapshot } of invalidSnapshotVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + // The list is the documented eight; a fixture drift (a variant whose + // field is not in the constant) fails right here, not in a downstream + // matcher that forgot to check one. + expect((SSH_FORBIDDEN_SNAPSHOT_FIELDS as readonly string[]).includes(field)).toBe(true); + } + }); + + it("refuses explicit non-null spellings too (true, 0, empty string: only null is absent)", () => { + for (const value of [true, false, 0, "", [], {}] as unknown[]) { + expect(parseSshConnectionSnapshot({ ...makeSnapshot(), forwards: value })).toBeNull(); + } + }); + + it("hygiene refusals: option-like hosts, control characters, relative paths, bad ports, over-cap lists, missing keys", () => { + for (const { field, snapshot } of invalidSnapshotHygieneVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + expect(field).not.toEqual(""); + } + }); + + it("a hop chain exactly at the cap parses; one past it does not", () => { + const hops = Array.from({ length: SSH_MAX_PROXY_HOPS }, (_, i) => makeHop(i)); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: hops }))).not.toBeNull(); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: [...hops, makeHop(99)] }))).toBeNull(); + }); + + it("an IPv6 literal host and a bracketed alias survive (colons are legal there)", () => { + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]:22" }))).toBeNull(); // a port baked into host is NOT the shape; port is its own field + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]" }))).not.toBeNull(); + }); + + it("rejects null and non-objects whole", () => { + expect(parseSshConnectionSnapshot(null)).toBeNull(); + expect(parseSshConnectionSnapshot("app02")).toBeNull(); + expect(parseSshConnectionSnapshot([])).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 005d4852e..0496c65e2 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -226,5 +226,27 @@ export { type SizingPolicy, type ViewerCapacity, } from "./shared-geometry.js"; +export { + parseSshConnectionSnapshot, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, + type SshConnectionSnapshotWire, + type SshHopWire, +} from "./ssh-config.js"; +export { + isSshErrorCode, + SSH_ERROR_CODES, + SSH_ERROR_DESCRIPTIONS, + type SshErrorCode, +} from "./ssh-errors.js"; +export { + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PROBE_DEADLINE_MS, +} from "./ssh-limits.js"; +export type { + NodeSshAliasListResult, + NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; export { MAX_UPLOAD_BYTES } from "./uploads.js"; export { MIN_NODE_VERSION, nodeVersionSupported, semverLt } from "./versions.js"; diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts new file mode 100644 index 000000000..5ce94ed0d --- /dev/null +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -0,0 +1,247 @@ +/** + * The approved normalized connection snapshot (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, + * section 5.2). Configuration here is executable, not passive data. + * + * This is the ONLY connection shape the wire ever carries. OpenSSH config can + * start local helpers, forward ports and reuse ambient sockets, so nothing + * here is a passthrough: the human-facing resolution step produces this shape + * once, and the NODE re-validates it with {@link + * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot + * (resolve, test, run start, terminal launch). The plane approving a snapshot + * is not the load-bearing defense - this validator refusing a forbidden + * member is, on the machine that will actually run ssh. + * + * The eight absent-forbidden members (`proxyCommand`, `forwards`, `tunnels`, + * `localCommands`, `remoteCommand`, `sendEnv`, `setEnv`, `escapes`) are typed + * as literal `null` ON PURPOSE: they exist in the shape to be refused. A + * resolver that faithfully recorded a `ProxyCommand` produced a value this + * parser rejects, which is the "refuse with a named limitation rather than + * silently changing connection semantics" rule expressed in grammar. The + * runtime check is stricter than the type: any present non-null value fails, + * so hand-built JSON cannot smuggle one past TypeScript. + * + * Hand-rolled in the `node-frames.ts` validator style; imports no `node:` + * builtin; lives in the Metro-safe barrel. + */ + +import { isInt, isRecord, isStr } from "./guards.js"; +import { + SSH_MAX_CERT_REFS, + SSH_MAX_IDENTITY_REFS, + SSH_MAX_KNOWN_HOSTS_FILES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, +} from "./ssh-limits.js"; + +/** + * One ProxyJump hop as the approved snapshot carries it: a destination and + * nothing else. Hops are normalized "under the same restrictions" as the + * final destination (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md) - they never carry identity files or + * options; authentication for the whole chain comes from the snapshot's own + * refs, which is what makes the chain reviewable as a route. + * + * A `type` alias, not an interface: snapshots ride inside JSON command fields, + * and an interface has no implicit index signature. + */ +export type SshHopWire = { + /** Hop hostname (same hygiene as the destination host) */ + host: string; + /** Hop user; null = the connecting account's default */ + user: string | null; + /** Hop port, already resolved (an absent `Port` means 22) */ + port: number; +}; + +/** + * A resolved, approved SSH destination, normalized to the settings the approved + * grammar supports and nothing more. Persisted server-side per connection revision and + * embedded verbatim in every node command that connects. + */ +export interface SshConnectionSnapshotWire { + /** + * The config token the human chose (display and review context only - + * routing uses `host`, and a manual alias with no config behind it still + * carries the destination it resolved to). + */ + alias: string; + /** Resolved destination hostname. Never an option-like string: the validator refuses a leading `-`. */ + host: string; + /** Destination user; null means the connecting account's own default */ + user: string | null; + /** Destination port, resolved (1..65535; a default-until-resolved `Port` never reaches the wire as 0) */ + port: number; + /** + * Identity file references: ABSOLUTE POSIX paths on the CONNECTING NODE, + * never key contents. Credential material stays where the OS put it. + */ + identityFiles: string[]; + /** Certificate file references (OpenSSH allows several `CertificateFile` lines), same path-only rule. */ + certificateFiles: string[]; + /** + * The authentication-agent socket to use: an absolute path from the + * connecting account's trusted setup, or null to run with no agent. An + * already-unlocked agent MAY be used (the key home's agent, per + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md); this field is + * the only channel through which one is named. + */ + authAgentSocket: string | null; + /** Known-hosts files to consult. Revocation/CA semantics inside them are the runtime's to preserve; the refs are the snapshot's to carry. */ + knownHostsFiles: string[]; + /** OpenSSH `HostKeyAlias` when the config sets one; the name strict checking looks up. */ + hostKeyAlias: string | null; + /** Bounded ProxyJump chain, outermost first, at most {@link SSH_MAX_PROXY_HOPS} hops. */ + proxyJumps: SshHopWire[]; + /** Literal null: a resolved `ProxyCommand` makes this snapshot REFUSABLE, never renderable. */ + proxyCommand: null; + /** Literal null: `LocalForward`/`RemoteForward`/`DynamicForward` found at resolution fail setup. */ + forwards: null; + /** Literal null: `Tunnel`/`PermitRemoteOpen` found at resolution fail setup. */ + tunnels: null; + /** Literal null: `LocalCommand`, `PermitLocalCommand`, or a `Match exec` found at resolution fail setup. */ + localCommands: null; + /** Literal null: `RemoteCommand` found at resolution fails setup; it would replace the command contract. */ + remoteCommand: null; + /** Literal null: `SendEnv` found at resolution fails setup; no environment rides the link. */ + sendEnv: null; + /** Literal null: `SetEnv` found at resolution fails setup; same reason, remote side. */ + setEnv: null; + /** Literal null: live escape characters fail setup; the ~ menu is an interactive local shell. */ + escapes: null; +} + +/** The absent-forbidden members, in the order the parser refuses them; the resolve `settings` list names these strings back. */ +export const SSH_FORBIDDEN_SNAPSHOT_FIELDS = [ + "proxyCommand", + "forwards", + "tunnels", + "localCommands", + "remoteCommand", + "sendEnv", + "setEnv", + "escapes", +] as const; + +/** One member of {@link SSH_FORBIDDEN_SNAPSHOT_FIELDS}. */ +export type SshForbiddenSnapshotField = (typeof SSH_FORBIDDEN_SNAPSHOT_FIELDS)[number]; + +/** + * Control characters and space: nothing a host, user, or alias may contain on + * any sane SSH stack. Spelled as the Unicode category (C0+C1 controls + * including DEL) because biome refuses the raw `\x00` spellings, and the + * category is the rule stated as its own name anyway. + */ +const NAME_UNSAFE_RE = /\s|\p{Cc}/u; + +/** + * A hostname: a DNS-ish label run (this grammar admits IPv4 literals and + * underscores, which occur in real deployments) OR a bracketed IPv6 literal. + * A port baked onto the host (`[::1]:2222`) is refused on purpose: `port` is + * its own field, and accepting the composed spelling would let a host string + * carry a second authority the reviewer never saw. + */ +const HOST_RE = /^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])$/; + +/** A POSIX-ish username: the characters ssh's `user@host` split and PAM actually meet. */ +const USER_RE = /^[A-Za-z0-9._-]+$/; + +/** + * A wire name (config alias, `HostKeyAlias`): length-capped, no whitespace or + * control characters, and NOT option-like (a leading `-` is how a value + * becomes a flag when argv is assembled - the wire-name injection rule, checked where + * the value is born rather than trusting every later composer). + */ +function isWireName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !NAME_UNSAFE_RE.test(value) + ); +} + +/** A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. */ +function isHostName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && HOST_RE.test(value); +} + +/** A username, by grammar: see {@link USER_RE}. Length is bounded by the same cap. */ +function isUserName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && USER_RE.test(value); +} + +/** An absolute POSIX path: leading `/`, length-capped, no control characters. Existence and ownership are the node's to check; shape is ours. */ +function isAbsPosixPath(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS && !/\p{Cc}/u.test(value); +} + +/** A resolved port: a positive integer inside the legal range. */ +function isPort(value: unknown): value is number { + return isInt(value) && (value as number) >= 1 && (value as number) <= 65535; +} + +/** A path list within `cap`, or empty. Every ref list shares this grammar. */ +function isPathListWithin(value: unknown, cap: number): value is string[] { + return Array.isArray(value) && value.length <= cap && value.every(isAbsPosixPath); +} + +function isHop(value: unknown): value is SshHopWire { + if (!isRecord(value) || !isHostName(value.host) || !isPort(value.port)) return false; + if (!("user" in value)) return false; + return value.user === null || isUserName(value.user); +} + +/** + * Validates and narrows an arbitrary value into an approved connection + * snapshot. Both sides call it: the plane before it stores or dispatches one, + * the node before it renders a command line from one. A snapshot that fails + * ANY check is refused whole - there is no partial approval, and the + * forbidden-member refusal is deliberately a PARSE failure rather than a + * field drop (dropping a `ProxyCommand` silently is the exact defect the refusal-by-name rule names). + * + * @param value - candidate snapshot (parsed JSON; unknown provenance) + * @returns the narrowed snapshot, or null when malformed or non-approvable + */ +export function parseSshConnectionSnapshot(value: unknown): SshConnectionSnapshotWire | null { + if (!isRecord(value)) return null; + // Forbidden members first: any present non-null value refuses the whole + // snapshot, checked before the shape of everything else so a resolver bug + // surfaces as "this is not approvable", never as "incomplete input". + for (const field of SSH_FORBIDDEN_SNAPSHOT_FIELDS) { + if (value[field] != null) return null; + } + if (!isWireName(value.alias) || !isHostName(value.host) || !isPort(value.port)) return null; + if (!("user" in value) || !(value.user === null || isUserName(value.user))) return null; + if (!isPathListWithin(value.identityFiles, SSH_MAX_IDENTITY_REFS)) return null; + if (!isPathListWithin(value.certificateFiles, SSH_MAX_CERT_REFS)) return null; + if (!isPathListWithin(value.knownHostsFiles, SSH_MAX_KNOWN_HOSTS_FILES)) return null; + if (!("authAgentSocket" in value) || !(value.authAgentSocket === null || isAbsPosixPath(value.authAgentSocket))) + return null; + if (!("hostKeyAlias" in value) || !(value.hostKeyAlias === null || isWireName(value.hostKeyAlias))) return null; + if (!Array.isArray(value.proxyJumps) || value.proxyJumps.length > SSH_MAX_PROXY_HOPS) return null; + for (const hop of value.proxyJumps) if (!isHop(hop)) return null; + // Return a REBUILT object, never the input cast: the eight forbidden fields + // are written as literal null here, so what the caller holds is provably a + // snapshot the grammar can express - stray keys on the input die with it. + return { + alias: value.alias, + host: value.host, + user: value.user === undefined ? null : (value.user as string | null), + port: value.port, + identityFiles: [...(value.identityFiles as string[])], + certificateFiles: [...(value.certificateFiles as string[])], + authAgentSocket: value.authAgentSocket === undefined ? null : (value.authAgentSocket as string | null), + knownHostsFiles: [...(value.knownHostsFiles as string[])], + hostKeyAlias: value.hostKeyAlias === undefined ? null : (value.hostKeyAlias as string | null), + proxyJumps: (value.proxyJumps as SshHopWire[]).map((h) => ({ host: h.host, user: h.user, port: h.port })), + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + }; +} diff --git a/packages/subshell-protocol/src/ssh-errors.ts b/packages/subshell-protocol/src/ssh-errors.ts new file mode 100644 index 000000000..f23750d4c --- /dev/null +++ b/packages/subshell-protocol/src/ssh-errors.ts @@ -0,0 +1,148 @@ +/** + * The named SSH refusal/limit codes, frozen (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): + * a refused connection names its limitation rather than silently changing + * connection semantics. One set for the whole feature: the node answers a refused + * command with the BARE code as its `result{error}` string when the plane must + * map it (the exact posture of the `NODE_RESULT_*` constants - the plane + * compares `NodeRpcError.detail` by EQUALITY, never a substring, so a refusal + * that wants a typed remedy is this string and nothing else); the resolution + * and test result envelopes carry the same codes in their `code` fields; and + * the server's policy refusals and API error metadata surface them verbatim. + * + * The set was FROZEN after Gate A; the retirement of the destination + * execution product (design 2026-10-05 §7) pruned the three quota/storage + * codes only ITS runtimes emitted (`quota_runs`, `quota_terminals`, + * `storage_full`), a deletion that is legal precisely because protocol 17 + * was never released. What remains is what the SURVIVING surfaces still + * answer: resolution refusals for the wizard, and the bare codes the + * session-runtime supervisor maps by equality. Adding a code is a + * coordinated change; renaming or deleting one is a wire change like any + * other. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/** The refusal set as a runtime list. Exported beside the type because the parsers, the routes and the SPA all iterate it. */ +export const SSH_ERROR_CODES = [ + /** Resolution found a setting the approved normalization cannot represent (`ProxyCommand`, forwards, tunnels, local commands, `RemoteCommand`, `SendEnv`, `SetEnv`, escapes, …). The resolve answer's `settings` list names which. */ + "unsupported_setting", + /** The alias does not appear in the account's config (or an include) the discovery parsed. */ + "config_missing", + /** The alias matches conflicting `Host` blocks the normalizer cannot uniquely order; a human edits the config or enters the destination differently. */ + "config_ambiguous", + /** Strict host checking found no trust entry for the destination (or a hop). There is no accept endpoint in v1: the human trusts the key on the connecting account outside Subshell. */ + "host_key_unknown", + /** The destination's key does not match the trusted entry. Fail closed; never an automatic re-trust. */ + "host_key_changed", + /** The trusted entry is a revocation marker (`@revoked`) or the certificate chains to a revoked CA. */ + "host_key_revoked", + /** A named identity file is unreadable or needs a passphrase no in-band path can answer (key unlocking lives outside Subshell). */ + "key_unavailable", + /** The destination demands password, MFA or keyboard-interactive auth. v1 is key/certificate auth only. */ + "auth_mode_unsupported", + /** The resolved `ProxyJump` chain exceeds {@link SSH_MAX_PROXY_HOPS}. */ + "proxy_chain_too_long", + /** + * A signed command arrived past its freshness window after a reconnect. + * Rejecting it is the no-automatic-replay rule; the caller re-decides, the + * node never does. RESERVED, emitted nowhere (review M8): the freshness + * window the JWS signature carries is what actually refuses a stale start + * (the signing layer rejects the expired command before any handler runs), + * so no dispatch site names this code. It stays in the frozen set on purpose + * - the wire grammar and every reader's equality table are protocol-frozen + * and a bump cannot reclaim a removed name; the note is what keeps a future + * reader from "fixing" the apparent dead code by emitting it. + */ + "stale_command", + /** A start carried an ID the node already accepted for a DIFFERENT request digest. Durable dedup refused it; nothing was spawned twice. The session-open dedup kept the spelling (design §3). */ + "run_conflict", + /** The node never accepted this ID (or its record expired). A surviving equality code: the session arms answer it for an unknown session ref. */ + "run_unknown", + /** The pre-spawn connect probe failed for no more specific reason - the session open's reachability probe kept this spelling too. The one code that must never be dressed up as a diagnosis. */ + "connection_failed", + /** + * The session-runtime probe found no runtime binary on the destination + * (design 2026-10-05 §3: the `command -v` probe before the child spawns; + * the design names this refusal `SSH_RUNTIME_MISSING`). The remedy is the + * binary install ONLY - never enrollment, never `subshell setup` - and + * `SSH_ERROR_DESCRIPTIONS` keeps it to exactly that sentence. + */ + "runtime_missing", + /** Opening a session was refused at the per-node active-session quota (design §3, sibling of `quota_terminals`). */ + "session_quota", + /** The brokered child spoke something whose first frame is not a runtime hello (design §2: malformed leading bytes fail the open; the broker names it, the plane relays it). */ + "session_protocol", + /** + * The destination already carries a LIVE session: the second serve died at + * its callback-door bind (review m2 - the door refuses a live listener, and + * the bind site is the ONE enforcement point, since a plane-side + * second-session gate would break §6's reopen-and-adopt journey). The + * remedy is the other session - close it, do not touch the binary. + */ + "session_in_use", +] as const; + +/** One named refusal from {@link SSH_ERROR_CODES}. */ +export type SshErrorCode = (typeof SSH_ERROR_CODES)[number]; + +/** Whether `value` is one of the frozen codes. */ +export function isSshErrorCode(value: unknown): value is SshErrorCode { + return typeof value === "string" && (SSH_ERROR_CODES as readonly string[]).includes(value); +} + +/** + * One honest sentence per code, for any surface that must explain a refusal + * it cannot elaborate (MCP prose, SPA detail rows). Shipped strings: plain + * words, no markup, no secrets, and each one names the remedy's LOCATION + * (this account, this config, retry-later) because a code the human cannot + * act on is a bug report waiting to be filed. + */ +export const SSH_ERROR_DESCRIPTIONS: Record = { + unsupported_setting: + "The resolved SSH config needs a setting Subshell will not run (proxy commands, forwarding, tunnels, local commands, remote commands, or environment injection). Edit the config or choose another alias.", + config_missing: "That alias is not in the connecting account's SSH config. Check the spelling or add it there.", + config_ambiguous: + "The alias matches conflicting config blocks that cannot be reduced to one destination. Simplify the config or enter the alias differently.", + host_key_unknown: + "The destination has no trusted host key on the connecting account. Trust it there first; Subshell never accepts keys on your behalf.", + host_key_changed: + "The destination's host key no longer matches the trusted entry. Verify out of band and update the entry on the connecting account.", + host_key_revoked: + "The trusted host key or certificate chain is revoked. Restore a valid trust entry on the connecting account.", + key_unavailable: + "A referenced identity key cannot be used as-is (unreadable, or passphrase-protected). Unlock or reconfigure it on the connecting account.", + auth_mode_unsupported: + "The destination requires password or interactive authentication. Subshell supports key and certificate auth only.", + proxy_chain_too_long: "The jump-host chain is longer than Subshell will review and run. Shorten the ProxyJump path.", + stale_command: "The request expired before it reached the node. Nothing ran; send it again if it should.", + run_conflict: + "A run with this ID already exists with different contents. The earlier request stands; this one was refused.", + run_unknown: + "This node has no record of that run. Its result may never have existed or its history expired; never assume it completed.", + connection_failed: + "The connection test failed. Check the destination, the network, and the connecting account's SSH setup.", + runtime_missing: + "The destination has no Subshell binary. Install the `subshell` binary there (the same install as any node's agent, without enrollment); no account setup or daemon is needed.", + session_quota: "This machine already carries its share of open SSH sessions. Close one first.", + session_protocol: + "The destination answered the session open with something that is not the Subshell runtime's handshake. Check that the named program is the `subshell` binary.", + session_in_use: "This destination already has a live session. Close that session there first, then connect again.", +}; + +/** + * `result.error` from an input or prompt-delivery the node refused because its + * per-pane generation had moved on: a takeover or a revocation fenced it. + * + * THE single frozen wire spelling for a generation refusal. There is + * deliberately no matching member of `SSH_ERROR_CODES` (the Gate A review + * ruled one event, one name): the plane matches `NodeRpcError.detail` against + * THIS constant by equality, exactly like every other bare `NODE_RESULT_*`. + * Relocated here from the retired run-facts module (design 2026-10-05 §7). + * What keeps the spelling alive is the fence's SEAM, not its use: with the + * destination product retired, nothing records a generation, so every pane + * is an ordinary pane under the agent's deny-by-construction rule and this + * refusal cannot fire today. The wire field, the monotonic mirror, and this + * frozen answer are what the next control-transition feature adopts instead + * of reinventing (the honest header: apps/node/agent/src/input-generation.ts). + */ +export const NODE_RESULT_SSH_GENERATION_STALE = "stale input generation"; diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts new file mode 100644 index 000000000..661b5ac83 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -0,0 +1,87 @@ +/** + * The two surviving SSH node commands (design 2026-10-05 §7): the bounded + * config DISCOVERY and the single-alias RESOLUTION that feed the wizard's + * review step. They ride the existing signed/encrypted command transport + * unchanged - signing proves WHO ordered it, this file freezes WHAT was + * ordered. + * + * The rest of the family the earlier command table named (`ssh_test_ + * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ + * launch`, `ssh_input_control`) retired with the destination execution + * product; protocol 17 never shipped, so their `type` arms are deleted, not + * refused. The brokered-session commands live in `ssh-session-frames.ts`. + * + * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted + * `local` node executes the same runtime in-process against these exact + * bodies, so nothing may assume a socket, a jti, or a signature - those are + * the transport's, and they live outside the body. + * + * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin; + * lives in the Metro-safe barrel. + */ + +import { isRecord, isStr } from "./guards.js"; +import { SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; + +/* ------------------------------------------------------------------ */ +/* command bodies */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded parse of the connecting account's SSH config for alias NAMES. Takes + * no input beyond the command itself (which node answers it is the + * transport's, not the body's). Human-only at the API level; the node holds + * no concept of who asked - the signature already did that part. + */ +export interface SshDiscoverAliasesCommand { + type: "ssh_discover_aliases"; +} + +/** Resolve one alias into the approved normalized snapshot (or a named refusal). Human config step. */ +export interface SshResolveConfigCommand { + type: "ssh_resolve_config"; + /** The config token to resolve. Shape-checked here; whether it EXISTS in the account's config is what the command is for. */ + alias: string; +} + +/** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ +export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand; + +/** Every SSH command `type`, for census tests and dispatch tables. */ +export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config"] as const; + +/* ------------------------------------------------------------------ */ +/* arm validators (delegated from parseNodeCommandBody) */ +/* ------------------------------------------------------------------ */ + +/** Same hygiene the snapshot applies to names; a config alias is the same kind of string. */ +function isAliasName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !/\s|\p{Cc}/u.test(value) + ); +} + +/** + * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` + * routes its two `type` arms here so the SSH grammar lives in ONE file + * beside the commands it narrows; the contract it upholds is the same: a + * NON-null return is safe to switch on by `type`. + * + * @param value - candidate payload whose `type` starts with `ssh_` + * @returns the narrowed command, or null when malformed + */ +export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | null { + if (!isRecord(value) || !isStr(value.type)) return null; + switch (value.type) { + case "ssh_discover_aliases": + return { type: "ssh_discover_aliases" }; + case "ssh_resolve_config": + return isAliasName(value.alias) ? { type: "ssh_resolve_config", alias: value.alias } : null; + default: + return null; + } +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts new file mode 100644 index 000000000..b4a265558 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -0,0 +1,133 @@ +/** + * The SSH resource limits, frozen (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md). + * + * Everything here is PURE data: the frozen spec values, each named, plus + * the derived bounds the wire parsers enforce so neither end of the link can + * drift. These values are law after Gate A: the node runtime enforces them at the + * connecting machine, the plane refuses dispatch past them before a command + * is ever signed, and the surfaces render the same numbers the enforcement + * uses. Changing one is a coordinated edit, not a local convenience - + * which is why they live here rather than in the packages that act on them. + * + * The destination execution product's limits (run deadlines, active-run and + * terminal quotas, output retention and aggregate storage, the read window + * and long-poll caps) deleted with it (design 2026-10-05 §7). What remains: + * the probe deadline, the snapshot/grammar bounds, the cancel grace the + * session supervisor borrows, and the session-runtime table. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/* ------------------------------------------------------------------ */ +/* frozen spec values (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md), one export each */ +/* ------------------------------------------------------------------ */ + +/** + * Connection/setup probe: a 30-second OVERALL deadline, and no automatic + * execution retry ever (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md; the no-retry rule is + * the durable-dispatch contract, not a tuning knob). + */ +export const SSH_PROBE_DEADLINE_MS = 30 * 1000; + +/* ------------------------------------------------------------------ */ +/* derived contract bounds (chosen at the Gate A freeze, parser-enforced) */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded ProxyJump chain (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 4.1). + * FOUR is a freeze decision, not a spec value: beyond four hops a normalized + * hop table stops being humanly reviewable, which is the whole point of + * reviewing the route before saving it. Resolution refuses longer chains by + * name (`proxy_chain_too_long`); the hop count a snapshot may carry is + * validated at every command that embeds one. + */ +export const SSH_MAX_PROXY_HOPS = 4; + +/** Identity-file references one approved snapshot may name. Absolute paths, never contents. */ +export const SSH_MAX_IDENTITY_REFS = 8; + +/** Certificate-file references one approved snapshot may name (OpenSSH allows several `CertificateFile` lines). */ +export const SSH_MAX_CERT_REFS = 8; + +/** Known-hosts files one approved snapshot may name (the trust refs the design preserves: docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 9). */ +export const SSH_MAX_KNOWN_HOSTS_FILES = 8; + +/** Aliases one `ssh_discover_aliases` answer may carry; past it the answer sets `truncated`. */ +export const SSH_MAX_DISCOVERED_ALIASES = 500; + +/** Longest config alias / hostname / username the wire carries (253 is DNS's practical ceiling; the SSH token cannot legitimately exceed it). */ +export const SSH_NAME_MAX_CHARS = 253; + +/** Longest absolute POSIX path the wire carries (Linux's PATH_MAX is 4096; a path needing more is not ours to route). */ +export const SSH_PATH_MAX_CHARS = 4094; + +/** + * Grace period a SIGTERM gets before the group is SIGKILLed. Born with the + * structured runs, kept because the session supervisor kills the brokered + * child's GROUP with exactly this grace (design §3). Remote descendants are + * NEVER confirmed dead by any grace period - that honesty survived the run + * family. + */ +export const SSH_CANCEL_GRACE_MS = 5_000; + +/* ------------------------------------------------------------------ */ +/* session-runtime limits (design 2026-10-05 §2/§3) */ +/* ------------------------------------------------------------------ */ + +/** + * Inbound runtime-frame queue bound per session (design §2: 128 frames). + * Overflow CLOSES the session fail-closed - a plane that cannot drain its own + * session must stop the flow at the source rather than buffer without bound. + */ +export const SSH_SESSION_INBOUND_QUEUE_FRAMES = 128; + +/** + * Open deadline: spawn the child AND receive its parsed hello within this + * window, or the open fails - never a half-open session, never a retry (the + * no-automatic-execution-replay rule reads the probe's failure as final here + * too). + */ +export const SSH_SESSION_OPEN_DEADLINE_MS = 30_000; + +/** Active brokered sessions one connecting node may hold, across all owners. */ +export const SSH_SESSIONS_PER_NODE = 8; + +/** + * Longest callback correlation id (`rest_request`/`rest_response` `reqId`) + * the wire carries, printable characters only. The bound is load-bearing + * beyond tidiness: the plane echoes the id back inside a size-capped + * `rest_response` frame, and its answer-truncation budget is measured with + * the ACTUAL id, so the frame-cap arithmetic needs it finite and its escape + * cost knowable BEFORE the answer is sized. The runtime mints uuids (36 + * chars); 64 matches the session-ref ceiling, so no honest id is refused. + */ +export const SSH_REQ_ID_MAX_CHARS = 64; + +/** + * The ONE runtime-safe transfer window, in RAW bytes, for anything whose answer + * rides the session link as base64: a `log_read` window and a live-tail + * `output` chunk alike cap at this. The node link's own windows + * (`LOG_TAIL_BYTES` 256 KiB, `TAIL_CHUNK_BYTES` 192 KiB) are sized for the + * 1 MiB node frame and BREAK the session codec: base64 inflates 4/3, so 256 + * KiB raw is ~349,528 chars and even 192 KiB raw lands at 262,144 chars - + * exactly {@link SSH_SESSION_FRAME_MAX_BYTES} before the JSON envelope is + * glued on - and `encodeSshSessionFrame` throws past the cap. 128 KiB raw + * base64s to ~174,764 chars; the worst `result`/`output` envelope around it + * (uuid refs, numeric offsets, key quotes) costs well under 200 bytes, so a + * full window encodes at about 175 KiB against the 262,144-byte cap. The + * plane clamps its `log_read` asks to this and the runtime chunks its tail + * pump to this (`ctx.outputChunkCeilingBytes`); neither end splits a frame + * after composition, and the sizes that overflow are pinned refused by test. + */ +export const SSH_SESSION_LOG_WINDOW_BYTES = 128 * 1024; + +/** + * Largest raw stdout slice one brokered `session_frame` event may carry + * (design §3's pump, restated as a number: the node-frames `session_frame` + * doc always claimed "in ≤ 192 KiB pieces", and the broker's read can return + * more in one chunk). 192 KiB raw base64s to 262,144 chars, comfortably under + * the node link's own 1 MiB frame cap; the session's codec reassembles frames + * across pushes, so a chunk boundary is a chunk boundary and splitting costs + * the stream nothing but an event. + */ +export const SSH_SESSION_PUMP_CHUNK_BYTES = 192 * 1024; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts new file mode 100644 index 000000000..2020ebd1d --- /dev/null +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -0,0 +1,60 @@ +import type { SshConnectionSnapshotWire } from "./ssh-config.js"; +import type { SshErrorCode } from "./ssh-errors.js"; + +/** + * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery + * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; + * exported NAMES unchanged). The test/run/control envelopes retired with the + * destination product (design 2026-10-05 §7). The `parse*` validators for + * these live in `node-results.ts` beside every other result validator (the + * four-site rule in the integration maps). + * + * Imports no `node:` builtin; lives in the Metro-safe barrel. + */ + +/** + * `ssh_discover_aliases` answer: NAMES of usable aliases, never config file + * contents (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md). Wildcard-only entries are already excluded + * and include cycles detected (the cycle is a FACT the human reviewing the + * list needs, never a silent stop). + */ +export interface NodeSshAliasListResult { + /** Alias names, sorted, deduplicated, at most {@link SSH_MAX_DISCOVERED_ALIASES}. */ + aliases: string[]; + /** An include cycle was detected during the bounded parse; the list is what parsed before it. */ + includeCycle: boolean; + /** The alias cap was hit; more exist. */ + truncated: boolean; +} + +/** + * `ssh_resolve_config` answer. The refusal is IN THE DATA, not an `ok:false` + * result: "this config needs a ProxyCommand" is a SUCCESSFUL resolution + * outcome the human must read (which setting blocked), not a transport error. + * `settings` names the blocked settings by the config keyword (a `ProxyCommand` + * row, a `LocalForward` row, …) so the human can edit the config and re-resolve. + */ +export type NodeSshResolveOutcomeWire = + | { + /** The alias normalized cleanly into an approved snapshot. */ + accepted: true; + /** The snapshot the human reviews, then saves. */ + snapshot: SshConnectionSnapshotWire; + /** + * The connecting account's OS user name, when the node could report it + * - the wizard's review of the resolved destination needs the connecting + * OS account named, which the snapshot itself never carries. Optional by + * design: an agent that cannot resolve `os.userInfo` answers without + * it and the review proceeds (the destination facts are the load- + * bearing half; this is display). + */ + connectingAccount?: string; + } + | { + /** Resolution refused: the config needs more than the approved normalization can run, or the destination is unusable. */ + accepted: false; + /** The named limitation ({@link SshErrorCode} subset: unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, …). */ + code: SshErrorCode; + /** Config keywords that blocked acceptance; empty when the code names the whole cause. */ + settings: string[]; + }; From 7d93d4baa0fc4c184c4d2c85717af0b547b616c4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:45:17 -0700 Subject: [PATCH 013/170] feat(ssh): port OpenSSH config discovery and ssh -G resolve into pane-runtime --- packages/pane-runtime/src/index.ts | 16 + .../pane-runtime/src/ssh/__tests__/helpers.ts | 149 ++++++ .../src/ssh/__tests__/ssh-discover.test.ts | 158 +++++++ .../src/ssh/__tests__/ssh-resolve.test.ts | 435 ++++++++++++++++++ packages/pane-runtime/src/ssh/ssh-discover.ts | 364 +++++++++++++++ packages/pane-runtime/src/ssh/ssh-resolve.ts | 363 +++++++++++++++ packages/pane-runtime/src/ssh/ssh-spawn.ts | 133 ++++++ 7 files changed, 1618 insertions(+) create mode 100644 packages/pane-runtime/src/ssh/__tests__/helpers.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-discover.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-resolve.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-spawn.ts diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 2a585070d..919629169 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -139,6 +139,22 @@ export { runBounded, } from "./run-bounded.js"; export { shellQuote } from "./shell.js"; +export { + defaultSshConfigPath, + discoverSshAliases, + isDiscoverableAlias, + type SshAliasDiscovery, + type SshConfigWalk, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh/ssh-discover.js"; +export { + parseProxyHop, + resolveSshAliasConfig, + type SshResolutionDeps, +} from "./ssh/ssh-resolve.js"; +export { runSshProcess, type SshProcessResult, sshChildPath } from "./ssh/ssh-spawn.js"; export { safeTransferPath } from "./tar-blocks.js"; export { type ExtractResult, extractTarGz } from "./tar-extractor.js"; export { extractTgz, type TarEntry, type TgzLimits } from "./tar-vendor.js"; diff --git a/packages/pane-runtime/src/ssh/__tests__/helpers.ts b/packages/pane-runtime/src/ssh/__tests__/helpers.ts new file mode 100644 index 000000000..528bcd0a5 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/helpers.ts @@ -0,0 +1,149 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; + +/** + * SSH test fixtures. NOTHING here may touch a developer's `~/.ssh` or the + * live instance (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): every config the tests read is a file + * under a fresh temp dir, every "ssh" the tests run is a shell script this + * file bakes with absolute paths (the runtime's child env is an allowlist, so + * a knob delivered by env would be a knob the PRODUCTION child could never + * see — baked constants are the honest test seam), and the isolated-sshd + * suite owns its temp HOME end to end. + */ + +/** Fresh temp root (realpath'd: macOS `/var` symlink noise breaks prefix asserts). */ +export function tempRoot(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function cleanup(root: string): void { + rmSync(root, { recursive: true, force: true }); +} + +/** A grammatically valid approved snapshot; override any field. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "deploy-app02", + host: "app-02.example.com", + user: "deploy", + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: ["/home/deploy/.ssh/known_hosts"], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** A sha256-looking request digest. */ +export function makeDigest(seed: string): string { + let h = 0; + for (const ch of seed) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0; + return h.toString(16).padStart(8, "0").repeat(8); +} + +/** Valid plane-shaped run id (hex + hyphen, ≤ 64). */ +export function makeRunId(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +} + +/** Read every `ARGS:` line a recording shim appended, in order (one joined argv string per spawn). */ +export function shimArgs(logPath: string): string[] { + try { + return readFileSync(logPath, "utf8") + .split("\n") + .filter((l) => l.startsWith("ARGS:")) + .map((l) => l.slice("ARGS:".length)); + } catch { + return []; + } +} + +/** All recorded lines from a shim log. */ +export function shimLog(logPath: string): string { + try { + return readFileSync(logPath, "utf8"); + } catch { + return ""; + } +} + +/** + * Write the recording ssh shim into `dir/ssh`. Knobs are BAKED CONSTANTS: + * + * - `opts.dashG`: when set, an invocation with `-G` anywhere cats this text + * and exits 0 (the resolution evaluator's canned answer). + * - `opts.stdout` / `opts.stdoutFile` / `opts.stderr` / `opts.stderrFile`: + * bytes to emit (inline or from a file this function seeds). + * - `opts.sleep`: seconds to `exec sleep` after emitting (the long-running + * run the cancel/deadline paths stop). + * - `opts.exitCode`: the exit status. + * + * The script appends `ARGS:` plus `ENV:` probe lines naming the + * auth-agent, askpass, DISPLAY, TERM, and the full key list of its + * environment, and `CFG:` when an `-F` is present. + * The `-G` branch runs BEFORE the ARGS recording only for -G invocations so + * resolution tests can assert on the emitted config; every other invocation + * records first. + */ +export function writeSshShim( + dir: string, + opts: { + dashG?: string; + stdout?: string; + stdoutFile?: string; + stderr?: string; + stderrFile?: string; + sleep?: number; + exitCode?: number; + } = {}, +): { bin: string; log: string } { + mkdirSync(dir, { recursive: true }); + const log = join(dir, "shim.log"); + const parts: string[] = ["#!/bin/sh"]; + parts.push(`LOG='${log}'`); + if (opts.stdoutFile) parts.push(`OUTF='${opts.stdoutFile}'`); + if (opts.stderrFile) parts.push(`ERRF='${opts.stderrFile}'`); + parts.push(`printf 'ARGS:%s\\n' "$*" >> "$LOG"`); + parts.push( + `printf 'ENV:auth=%s ask=%s disp=%s term=%s\\n' "\${SSH_AUTH_SOCK-}" "\${SSH_ASKPASS-}" "\${DISPLAY-}" "\${TERM-}" >> "$LOG"`, + ); + parts.push(`printf 'ENVKEYS:%s\\n' "$(env | cut -d= -f1 | sort | tr '\\n' ' ')" >> "$LOG"`); + parts.push( + `prev=""; for a in "$@"; do if [ "$prev" = "-F" ]; then printf 'CFG:<<\\n'; cat "$a"; printf '>>\\n' >> "$LOG"; fi; prev="$a"; done >> "$LOG" 2>/dev/null`, + ); + if (opts.dashG !== undefined) { + parts.push(`for a in "$@"; do if [ "$a" = "-G" ]; then printf 'GARGS:%s\\n' "$*" >> "$LOG"; cat <<'GEOF'`); + parts.push(opts.dashG); + parts.push("GEOF\nexit 0\nfi; done"); + } + if (opts.stdout !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stdout)}`); + if (opts.stdoutFile) parts.push(`[ -n "\${OUTF-}" ] && cat "$OUTF"`); + if (opts.stderr !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stderr)} 1>&2`); + if (opts.stderrFile) parts.push(`[ -n "\${ERRF-}" ] && cat "$ERRF" 1>&2`); + if (opts.sleep !== undefined) parts.push(`exec sleep ${opts.sleep}`); + parts.push(`exit ${opts.exitCode ?? 0}`); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${parts.join("\n")}\n`, { mode: 0o755 }); + chmodSync(bin, 0o755); + return { bin, log }; +} + +function shellSingleQuote(v: string): string { + return `'${v.replaceAll("'", `'\\''`)}'`; +} + +/** `~`-less POSIX single-quote for fixture config text (paths here are literals). */ +export { shellSingleQuote as sq }; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts new file mode 100644 index 000000000..db45fab8c --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultSshConfigPath, discoverSshAliases, walkSshConfig } from "../ssh-discover.js"; +import { cleanup, tempRoot } from "./helpers.js"; + +/** + * Discovery is a NAME-HUNT with budgets (ssh-discover.ts module doc): what it + * must get right is which names appear, which are excluded, and what the + * two "the list may be incomplete" facts report. Config CONTENT never + * escapes the walk — the answers carry no directive values at all. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-discover-"); +}); + +afterAll(() => { + cleanup(root); +}); + +function fixtureConfig(tag: string, body: string): string { + const dir = join(root, tag); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config"); + writeFileSync(path, body); + return path; +} + +describe("discoverSshAliases", () => { + it("reports plain Host names, sorted and deduplicated", () => { + const config = fixtureConfig( + "plain", + [ + "# a comment", + "Host beta", + " HostName beta.example.com", + "Host alpha gamma", + " User deploy", + "Host beta", // duplicate name across blocks + "", + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["alpha", "beta", "gamma"]); + expect(found.includeCycle).toBe(false); + expect(found.truncated).toBe(false); + }); + + it("omits wildcard-only patterns and negations, keeps plain siblings", () => { + const config = fixtureConfig( + "wildcards", + [ + "Host *.example.com !secret.example.com", // nothing plain on this line + " User x", + "Host prod-*", // pattern prefix, not an alias + "Host qa-server", + " User y", + "Host q?", // single-char pattern + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["qa-server"]); + }); + + it("follows includes and detects an include cycle", () => { + const dir = join(root, "cycle"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "a.conf"), ["Include b.conf", "Host alias-a", " User x"].join("\n")); + writeFileSync( + join(dir, "b.conf"), + ["Include a.conf", "Host alias-b"].join("\n"), // back into a's ancestry + ); + const walk = walkSshConfig(join(dir, "a.conf"), root); + expect(walk.includeCycle).toBe(true); + expect(walk.files.length).toBe(2); // a then b; the re-include stopped at the cycle, did not spin + }); + + it("dedupes a diamond include WITHOUT reporting a cycle", () => { + const dir = join(root, "diamond"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "common.conf"), "Host shared"); + writeFileSync(join(dir, "top.conf"), ["Include common.conf", "Include common.conf", "Host other"].join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: join(dir, "top.conf") }); + expect(found.aliases).toEqual(["other", "shared"]); + expect(found.includeCycle).toBe(false); + }); + + it("expands glob includes with ~", () => { + const dir = join(root, "glob"); + const inc = join(dir, "conf.d"); + mkdirSync(inc, { recursive: true }); + writeFileSync(join(inc, "one.conf"), "Host g-one"); + writeFileSync(join(inc, "two.conf"), "Host g-two"); + writeFileSync(join(inc, "skip.txt"), "Host g-not-included"); + // ~ expansion: homeDir is the fixture root. + const found = discoverSshAliases({ + homeDir: root, + configPath: fixtureConfig("glob-root", "Include ~/glob/conf.d/*.conf"), + }); + expect(found.aliases).toEqual(["g-one", "g-two"]); + }); + + it("flags truncated when the file budget is exhausted", () => { + const dir = join(root, "budget"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "b.conf"), "Host from-b"); + writeFileSync(join(dir, "c.conf"), "Host from-c"); + const configPath = join(dir, "config"); + writeFileSync(configPath, ["Include b.conf", "Include c.conf", "Host from-root"].join("\n")); + const found = discoverSshAliases({ homeDir: dir, configPath, budget: { maxFiles: 2 } }); // root + b; c is never read + expect(found.truncated).toBe(true); + expect(found.aliases).toContain("from-b"); + expect(found.aliases).not.toContain("from-c"); + }); + + it("flags truncated when the alias cap is reached", () => { + const lines: string[] = []; + for (let i = 0; i < 501; i++) lines.push(`Host alias-${String(i).padStart(4, "0")}`); + const config = fixtureConfig("cap", lines.join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases.length).toBe(500); + expect(found.truncated).toBe(true); + }); + + it("sees Match exec in the walk (the local-execution signal §2 refuses on)", () => { + const config = fixtureConfig("matchexec", ["Match exec echo hi", " User x", "Host m-alias"].join("\n")); + const walk = walkSshConfig(config, root); + expect(walk.matchExecSeen).toBe(true); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["m-alias"]); // Match blocks carry no names + }); + + it("missing config file yields an empty honest list, never a throw", () => { + const found = discoverSshAliases({ homeDir: root, configPath: join(root, "nope", "config") }); + expect(found).toEqual({ aliases: [], includeCycle: false, truncated: false }); + }); + + it("the default path is the home config", () => { + expect(defaultSshConfigPath("/home/zed")).toBe("/home/zed/.ssh/config"); + }); + + it("answers names only: no directive value text appears anywhere in the result", () => { + const config = fixtureConfig( + "namesonly", + ["Host top-secret-host", " IdentityFile /home/deploy/.ssh/private_key_material", " User supersecret"].join( + "\n", + ), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + const blob = JSON.stringify(found); + expect(blob).toContain("top-secret-host"); // the NAME is the answer + expect(blob).not.toContain("private_key_material"); + expect(blob).not.toContain("supersecret"); + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts new file mode 100644 index 000000000..0a708603d --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts @@ -0,0 +1,435 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseProxyHop, resolveSshAliasConfig } from "../ssh-resolve.js"; +import { cleanup, tempRoot, writeSshShim } from "./helpers.js"; + +/** + * Resolution tests run pane-runtime's resolver against a SHIM ssh that emits + * canned `ssh -G` output — the shape of the answer is what the filter must + * decide on, and the shim keeps the decision hermetic. (The real sshd suite + * in `ssh-loopback-sshd.test.ts` covers the same paths against the actual + * OpenSSH evaluator; here we test the filter, not OpenSSH.) + * + * The canned fixtures mirror the REAL grammar measured on OpenSSH 10.2p1 + * (review finding C1: the first fixtures omitted `-G`'s default + * `tunnel false` / `permitremoteopen any` lines and the tilde-form default + * identityfiles, so a filter bug hiding under those spellings was invisible + * to every fixture). The suite ends with the pin the fixture mirror cannot + * be trusted to be honest about: `resolveSshAliasConfig` driven by the REAL + * host `ssh -G`, loud-skipped only where ssh is absent. + * + * The shim's `-F` handling is deliberately bypassed for the `-G` branch + * except to confirm the resolver names the fixture config file: the canned + * output IS the fake OpenSSH answer. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-resolve-"); +}); + +afterAll(() => cleanup(root)); + +function gConfig(body: string, _aliasLines = true): { homeDir: string; configPath: string } { + const dir = tempRoot("subshell-ssh-resolve-cfg-"); + const configPath = join(dir, "config"); + writeFileSync(configPath, body); + return { homeDir: dir, configPath }; +} + +/** Run the resolver with a shim whose -G output is `gOutput`; also hands back the fixture home (tilde-expansion asserts). */ +async function resolveWith( + alias: string, + gOutput: string, + opts: { + configBody?: string; + env?: Record; + account?: string; + } = {}, +): Promise<{ out: Awaited>; homeDir: string }> { + const dir = tempRoot("subshell-ssh-resolve-run-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: gOutput }); + const cfg = gConfig(opts.configBody ?? `Host ${alias}\n HostName resolved.example\n`); + const out = await resolveSshAliasConfig(alias, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin", HOME: cfg.homeDir, ...opts.env }, + connectingAccount: opts.account ?? "opsuser", + timeoutMs: 5_000, + }); + return { out, homeDir: cfg.homeDir }; +} + +/** + * The filter's own clean answer, spelled like a real 10.x `-G`: the default + * `tunnel false` / `permitremoteopen any` / `permitlocalcommand no` lines are + * PRESENT (an unconfigured option is echoed with its default spelling, and + * the filter must know that spelling — a fixture that omitted them let the + * every-alias-refused bug through). + */ +const CLEAN_G = [ + "host resolved.example", + "hostname resolved.example", + "user deploy", + "port 2222", + "identityfile /home/ops/.ssh/id_ed25519", + "certificatefile /home/ops/.ssh/id_ed25519-cert.pub", + "userknownhostsfile /home/ops/.ssh/known_hosts", + "hostkeyalias app02-web", + "forwardagent no", + "gssapiauthentication no", + "tunnel false", + "tunneldevice any:any", + "permitremoteopen any", + "permitlocalcommand no", + "addkeystoagent false", + "controlmaster false", +].join("\n"); + +/** + * A plain-alias answer straight out of OpenSSH 10.x defaults: the identity + * files arrive in `~/.ssh/...` TILDE FORM (the `-G` default spelling), the + * known-hosts file is already absolute, and there is no explicit + * IdentityAgent line. This is the dump that turned every alias without an + * explicit IdentityFile into `config_ambiguous` before the tilde expanded. + */ +const REAL10X_G = [ + "host plainhost", + "hostname plainhost", + "user opsuser", + "port 22", + "addressfamily any", + "batchmode no", + "checkhostip no", + "compression no", + "controlmaster false", + "forwardx11 no", + "gssapiauthentication no", + "hashknownhosts no", + "identitiesonly no", + "kbdinteractiveauthentication yes", + "passwordauthentication yes", + "permitlocalcommand no", + "pubkeyauthentication true", + "requesttty auto", + "stricthostkeychecking ask", + "tcpkeepalive yes", + "tunnel false", + "verifyhostkeydns false", + "visualhostkey no", + "canonicalizemaxdots 1", + "connectionattempts 1", + "serveralivecountmax 3", + "serveraliveinterval 0", + "requiredrsasize 1024", + "ciphers chacha20-poly1305@openssh.com,aes128-gcm@openssh.com", + "loglevel INFO", + "identityfile ~/.ssh/id_rsa", + "identityfile ~/.ssh/id_ecdsa", + "identityfile ~/.ssh/id_ecdsa_sk", + "identityfile ~/.ssh/id_ed25519", + "identityfile ~/.ssh/id_ed25519_sk", + "globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", + "userknownhostsfile /home/opsuser/.ssh/known_hosts /home/opsuser/.ssh/known_hosts2", + "logverbose none", + "channeltimeout none", + "permitremoteopen any", + "addkeystoagent false", + "forwardagent no", + "connecttimeout none", + "tunneldevice any:any", +].join("\n"); + +describe("resolveSshAliasConfig", () => { + it("normalizes a clean answer into the approved snapshot", async () => { + const { out } = await resolveWith("app02", CLEAN_G); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("resolved.example"); + expect(out.snapshot.user).toBe("deploy"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.identityFiles).toEqual(["/home/ops/.ssh/id_ed25519"]); + expect(out.snapshot.certificateFiles).toEqual(["/home/ops/.ssh/id_ed25519-cert.pub"]); + expect(out.snapshot.knownHostsFiles).toEqual(["/home/ops/.ssh/known_hosts"]); + expect(out.snapshot.hostKeyAlias).toBe("app02-web"); + expect(out.snapshot.alias).toBe("app02"); + expect(out.connectingAccount).toBe("opsuser"); + // forbidden members exist as null, by grammar + expect(out.snapshot.proxyCommand).toBeNull(); + } + }); + + it("accepts a whole-dump of real OpenSSH 10.x defaults, expanding tilde identity refs", async () => { + const { out, homeDir } = await resolveWith("plainhost", REAL10X_G, { + configBody: "Host something-else\n HostName elsewhere\n", + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + // The tilde defaults expanded against the fixture home; nothing on the + // snapshot may stay tilde-form (the parser refuses it, and refusing a + // DEFAULT config is the C1 defect). + expect(out.snapshot.identityFiles).toEqual([ + join(homeDir, ".ssh/id_rsa"), + join(homeDir, ".ssh/id_ecdsa"), + join(homeDir, ".ssh/id_ecdsa_sk"), + join(homeDir, ".ssh/id_ed25519"), + join(homeDir, ".ssh/id_ed25519_sk"), + ]); + expect(out.snapshot.identityFiles.every((p) => p.startsWith("/"))).toBe(true); + expect(out.snapshot.knownHostsFiles).toEqual([ + "/home/opsuser/.ssh/known_hosts", + "/home/opsuser/.ssh/known_hosts2", + ]); + expect(out.snapshot.certificateFiles).toEqual([]); + expect(out.snapshot.hostKeyAlias).toBeNull(); + expect(out.snapshot.user).toBeNull(); // -G echoed the connecting account's own default + } + }); + + it("blocks a CONFIGURED tunnel or permitremoteopen while the default spellings pass", async () => { + const defaults = await resolveWith("app02", CLEAN_G); + expect(defaults.out.accepted).toBe(true); + + const tunnel = await resolveWith("app02", `${CLEAN_G.replace("tunnel false", "tunnel yes")}`); + expect(tunnel.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Tunnel"] }); + + const remote = await resolveWith("app02", CLEAN_G.replace("permitremoteopen any", "permitremoteopen host:1080")); + expect(remote.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["PermitRemoteOpen"] }); + }); + + it("reads one HostName plus one Port as ONE route, not a conflict (real configs set both)", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " HostName resolved.example", " Port 2222"].join("\n"), + }); + expect(out.accepted).toBe(true); + }); + + it("maps a config ProxyCommand to a named refusal naming the keyword", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxycommand nc -X proxy 443 %h %p`); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }); + }); + + it("refuses each unrepresentable family by name, together when several are set", async () => { + const { out } = await resolveWith( + "app02", + [ + CLEAN_G, + "locallyforward 8080 localhost:80", + "permitremoteopen yes", + "remotecommand /bin/sh", + "sendenv FOO", + "localcommand /bin/true", + ].join("\n"), + ); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toEqual(["LocalForward", "PermitRemoteOpen", "LocalCommand", "RemoteCommand", "SendEnv"]); + } + }); + + it("refuses a Match exec config before the evaluator even decides", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: "Match exec curl -s trust.example\n User x\nHost app02\n HostName resolved.example\n", + }); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Match exec"] }); + }); + + it("refuses a hop chain longer than the frozen cap", async () => { + const hops = ["a.example", "b.example", "c.example", "d.example", "e.example"].join(","); + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ${hops}`); + expect(out).toEqual({ accepted: false, code: "proxy_chain_too_long", settings: [] }); + }); + + it("normalizes a bounded ProxyJump chain into approved hops", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ops@jump.example.com:2222,bastion`); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.proxyJumps).toEqual([ + { host: "jump.example.com", user: "ops", port: 2222 }, + { host: "bastion", user: "deploy", port: 22 }, + ]); + } + }); + + it("treats a token that is not in the config as a manual destination only when -G echoes it as the host", async () => { + const manual = await resolveWith( + "fresh.host.example", + "host fresh.host.example\nhostname fresh.host.example\nuser opsuser\nport 22", + { + configBody: "Host something-else\n HostName elsewhere\n", + }, + ); + expect(manual.out.accepted).toBe(true); + if (manual.out.accepted) expect(manual.out.snapshot.host).toBe("fresh.host.example"); + + const rewritten = await resolveWith( + "ghost", + "host not-the-alias.example\nhostname not-the-alias.example\nuser opsuser\nport 22", + { + configBody: "Host unrelated\n User x\n", + }, + ); + expect(rewritten.out).toEqual({ accepted: false, code: "config_missing", settings: [] }); + }); + + it("resolves a conflicting-Hostname alias to config_ambiguous", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Hostname one.example", "Host app02", " Hostname two.example"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("resolves a conflicting-Port alias (same host, two ports) to config_ambiguous", async () => { + // The ports arm ALONE must refuse: no HostName difference is present to + // trip the hostNames set, so this pins the `ports.size > 1` branch. + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Port 22", "Host app02", " Port 2222"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("names the agent socket from the account env, and an explicit IdentityAgent path", async () => { + const viaEnv = await resolveWith("app02", `${CLEAN_G}\nidentityagent ssh-agent`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(viaEnv.out.accepted && viaEnv.out.snapshot.authAgentSocket).toBe("/run/user/1000/ssh.sock"); + + const explicit = await resolveWith("app02", `${CLEAN_G}\nidentityagent /home/ops/.ssh/custom-agent.sock`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(explicit.out.accepted && explicit.out.snapshot.authAgentSocket).toBe("/home/ops/.ssh/custom-agent.sock"); + + const none = await resolveWith("app02", `${CLEAN_G}\nidentityagent none`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(none.out.accepted && none.out.snapshot.authAgentSocket).toBeNull(); + }); + + it("stores user null when -G reports the connecting account's own default", async () => { + const out = await resolveWith("app02", CLEAN_G.replace("user deploy", "user opsuser"), { account: "opsuser" }); + expect(out.out.accepted && out.out.snapshot.user).toBeNull(); + }); + + it("refuses option-like and empty aliases before spawning anything", async () => { + const dir = tempRoot("subshell-ssh-resolve-bad-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: CLEAN_G }); + const cfg = gConfig("Host x"); + for (const bad of ["-oProxyCommand=evil", "with space", ""]) { + const out = await resolveSshAliasConfig(bad, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + } + }); + + it("a failing evaluator answers config_ambiguous, never a crash", async () => { + const dir = tempRoot("subshell-ssh-resolve-fail-"); + const shim = writeSshShim(join(dir, "bin"), { exitCode: 255, stderr: "bad configuration line" }); + // no dashG: the shim exits 255 for the -G call too. + const cfg = gConfig("Host app02\n HostName resolved.example\n"); + const out = await resolveSshAliasConfig("app02", { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) expect(out.code).toBe("config_ambiguous"); + }); + + // THE C1 pin: the fixture mirror can only be as honest as the hand that + // wrote it, so the filter is finally run against the REAL host OpenSSH. + // The loud-skip pattern is `ssh-loopback-sshd.test.ts`'s: no ssh binary, + // loud warn, every case skipped, the shim suites above still run. + const HAVE_SSH = Bun.which("ssh") !== null; + if (!HAVE_SSH) { + console.warn( + "[ssh-resolve] ssh not present: REAL `ssh -G` GRAMMAR PIN SKIPPED (loud skip; shim fixtures above mirror 10.x output)", + ); + } + + describe("against the real host ssh -G", () => { + it.skipIf(!HAVE_SSH)( + "accepts an alias with NO explicit IdentityFile (tilde defaults + default tunnel spellings)", + async () => { + const cfg = gConfig("Host realg\n HostName 127.0.0.1\n Port 2222\n User targetuser\n"); + const out = await resolveSshAliasConfig("realg", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("127.0.0.1"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.user).toBe("targetuser"); + // The whole C1 chain in one assert: real defaults printed, no + // unsupported_setting (the tunnel/permitremoteopen arms), and no + // config_ambiguous (the tilde identityfiles expanded). + expect(out.snapshot.identityFiles.length).toBeGreaterThan(0); + expect(out.snapshot.identityFiles.every((p) => p.startsWith(`${cfg.homeDir}/.ssh/`))).toBe(true); + } + }, + 20_000, + ); + + it.skipIf(!HAVE_SSH)( + "still refuses, by name, what a real config sets", + async () => { + const cfg = gConfig( + "Host realbad\n HostName 127.0.0.1\n PermitRemoteOpen host:1080\n LocalCommand touch /tmp/nope\n", + ); + const out = await resolveSshAliasConfig("realbad", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toContain("PermitRemoteOpen"); + expect(out.settings).toContain("LocalCommand"); // the M1 typo, on the real evaluator's own echo + } + }, + 20_000, + ); + }); +}); + +describe("parseProxyHop", () => { + it("parses the whole token grammar", () => { + expect(parseProxyHop("jump.example.com", null)).toEqual({ host: "jump.example.com", user: null, port: 22 }); + expect(parseProxyHop("ops@jump.example.com:2222", null)).toEqual({ + host: "jump.example.com", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("[2001:db8::1]", null)).toEqual({ host: "[2001:db8::1]", user: null, port: 22 }); + expect(parseProxyHop("ops@[2001:db8::1]:2222", null)).toEqual({ + host: "[2001:db8::1]", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("@jump.example.com", null)).toBeNull(); + expect(parseProxyHop("host:notaport", null)).toBeNull(); + expect(parseProxyHop("host:99999", null)).toBeNull(); + expect(parseProxyHop("host:0", null)).toBeNull(); + }); +}); diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts new file mode 100644 index 000000000..ec02ece3c --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -0,0 +1,364 @@ +import { closeSync, constants, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; + +/** + * Bounded discovery of SSH alias NAMES (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, + * section 5.2): discover aliases by bounded parsing of the account's config + * and includes; omit wildcard-only entries, detect include cycles. + * + * This parser answers NAMES, never config contents, and it is deliberately a + * NAME-HUNT and not an implementation of ssh_config semantics: the authoritative + * resolution of what an alias means is `ssh -G` (`ssh-resolve.ts`), which is + * OpenSSH's own evaluator. What must not be outsourced is the BOUNDS: reading + * the account's config is the one step an alias-rich `Include` chain can turn + * into arbitrary filesystem traversal and unbounded work, so the walk carries + * hard budgets (file count, total bytes, include-expansion width) and a cycle + * detector over its own ancestor chain. A cycle is a FACT the human reviewing + * the list needs (`includeCycle`), never a silent stop. + * + * A few ssh_config facts this parser must still get right, because they decide + * which names are reported: `Host` takes a whitespace-separated pattern list + * (wildcard patterns and `!`-negations are not aliases), `Include` takes + * whitespace-separated globs with `~` expansion, `Match` blocks carry no names, + * and `#` comments run from unquoted whitespace-preceded hashes. Anything + * subtler than that is `ssh -G`'s problem by design. + */ + +/** The alias list the command answers; mirrors the frozen `NodeSshAliasListResult` shape. */ +export interface SshAliasDiscovery { + /** Alias names, deduplicated, sorted; wildcard-only and negated patterns excluded. */ + aliases: string[]; + /** One config file appeared in another's include ANCESTRY chain; the list is what parsed before the repeat. */ + includeCycle: boolean; + /** A budget was exhausted or the name cap was reached; more may exist than were returned. */ + truncated: boolean; +} + +/** One parsed `Host` block: its pattern tokens and the directives set under it. */ +export interface SshHostBlock { + /** The pattern tokens after `Host` (verbatim, including wildcards/negations). */ + tokens: string[]; + /** Directives under this block, in file order; keyword lowercased, value raw. */ + directives: { keyword: string; value: string }[]; +} + +/** What one bounded walk produced, for discovery AND resolution to share. */ +export interface SshConfigWalk { + /** Every `Host` block in include order, plus the pre-first-Host global directives. */ + hostBlocks: SshHostBlock[]; + /** Directives seen before any `Host` line (the implicit-global context). */ + globalDirectives: { keyword: string; value: string }[]; + /** A `Match exec` line was seen anywhere in the walk (a local-execution signal §2 refuses on). */ + matchExecSeen: boolean; + /** The config files that were actually read, walk order (cycle detection is done by the walk itself). */ + files: string[]; + includeCycle: boolean; + truncated: boolean; +} + +/** Budgets for one walk; defaults are the shipped numbers, tests pass smaller. */ +export interface SshWalkBudget { + /** Max config files read in one walk (includes count). */ + maxFiles?: number; + /** Max bytes read across all files. */ + maxTotalBytes?: number; + /** Max globs expanded per `Include` directive. */ + maxIncludeMatches?: number; + /** Longest single line kept (openbsd caps config lines too; longer lines are dropped). */ + maxLineBytes?: number; +} + +const DEFAULT_BUDGET = { + maxFiles: 64, + maxTotalBytes: 2 * 1024 * 1024, + maxIncludeMatches: 256, + maxLineBytes: 4096, +}; + +/** Default location of the connecting account's SSH client config. */ +export function defaultSshConfigPath(homeDir: string): string { + return join(homeDir, ".ssh", "config"); +} + +/** + * An alias name worth reporting: pattern syntax (`*`, `?`), negation (`!`), + * option-like leading `-`, whitespace, and control characters are all excluded, + * matching the hygiene the wire grammar applies to aliases + * (`parseSshNodeCommandBody`'s `isAliasName`) so a name discovery reports is a + * name resolution can be asked for. + */ +export function isDiscoverableAlias(token: string): boolean { + return ( + token.length > 0 && + token.length <= SSH_NAME_MAX_CHARS && + !token.includes("*") && + !token.includes("?") && + !token.startsWith("!") && + !token.startsWith("-") && + !/\s|[\p{Cc}]/u.test(token) + ); +} + +/** Keyword/value split: `keyword value`, `keyword=value`, case-insensitive keyword. */ +function splitKeywordArg(line: string): { keyword: string; arg: string } | null { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) return null; + // Strip comments: an unquoted `#` preceded by whitespace ends the meaningful + // text. Quoted values (rare, and only meaningful for Match/exec arguments + // this parser never evaluates) are left alone rather than half-parsed. + const comment = findCommentStart(trimmed); + const body = comment === -1 ? trimmed : trimmed.slice(0, comment).trimEnd(); + if (body === "") return null; + const eq = body.indexOf("="); + const ws = body.search(/\s/); + const cut = eq === -1 ? ws : ws === -1 ? eq : Math.min(eq, ws); + if (cut === -1) return { keyword: body.toLowerCase(), arg: "" }; + const keyword = body.slice(0, cut).toLowerCase(); + let arg = body.slice(cut); + if (arg.startsWith("=")) arg = arg.slice(1); + return { keyword, arg: arg.trim() }; +} + +/** Index of the comment start: a `#` at position 0 or preceded by whitespace (and not inside quotes). */ +function findCommentStart(line: string): number { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) return i; + } + return -1; +} + +/** Expand a leading `~` / `~/` against the home dir; anything else passes through. Exported for {@link resolveSshAliasConfig}: `ssh -G` prints default identity paths in tilde form, and the snapshot grammar takes only absolutes. */ +export function expandTilde(raw: string, homeDir: string): string { + if (raw === "~") return homeDir; + if (raw.startsWith("~/")) return join(homeDir, raw.slice(2)); + return raw; +} + +/** + * Expand one Include glob to concrete file paths. The pattern is anchored at + * the longest non-globbing directory prefix; the remainder is matched + * segment-by-segment against `readdirSync`, capped by `maxMatches`. This is a + * deliberately small glob engine (`*` and `?` within a segment, no `**`, no + * bracket classes): ssh_config Include globs that this cannot expand yield + * fewer aliases, never an unbounded walk. + */ +function expandIncludeGlob(pattern: string, includingFile: string, homeDir: string, maxMatches: number): string[] { + let expanded = expandTilde(pattern, homeDir); + if (!isAbsolute(expanded)) expanded = resolve(dirname(includingFile), expanded); + const segments = expanded.split("/").filter((s) => s !== ""); + const out: string[] = ["/"]; + for (const segment of segments) { + const next: string[] = []; + const globbing = segment.includes("*") || segment.includes("?"); + if (!globbing) { + for (const base of out) next.push(join(base, segment)); + } else { + const re = globToRe(segment); + for (const base of out) { + let names: string[]; + try { + names = readdirSync(base); + } catch { + continue; // unreadable directory: OpenSSH would skip it too + } + for (const name of names) { + if (re.test(name)) next.push(join(base, name)); + // Cap hit mid-walk: `next` already holds `maxMatches` real matches, + // so return it (the old `atRoot ? [] : next` was dead - `atRoot` is + // false at every point this line runs, review M7). + if (next.length >= maxMatches) return next; + } + } + } + out.length = 0; + out.push(...next); + } + const files: string[] = []; + for (const p of out) { + try { + if (lstatSync(p).isFile()) files.push(p); + } catch { + // missing/unreachable: not an error, an empty match + } + if (files.length >= maxMatches) break; + } + return files; +} + +function globToRe(segment: string): RegExp { + const escaped = segment + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replaceAll("*", ".*") + .replaceAll("?", "."); + return new RegExp(`^${escaped}$`); +} + +/** Read one config file within the budgets; returns its lines and mutates the byte total. */ +function readCapped(file: string, bytesLeft: number, maxLineBytes: number): { lines: string[]; used: number } { + let fd: number; + try { + // O_NOFOLLOW: a planted symlink at an Include target is refused, not + // chased — the walk reads the account's config, not whatever a symlink + // on its path was pointed at. + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch { + return { lines: [], used: 0 }; + } + try { + const cap = Math.max(0, Math.min(bytesLeft, DEFAULT_BUDGET.maxTotalBytes)); + if (cap === 0) return { lines: [], used: 0 }; + const buf = Buffer.allocUnsafe(cap + 1); + const n = readSync(fd, buf, 0, cap + 1, null); + const used = Math.min(n, cap); + const text = buf.subarray(0, used).toString("utf8"); + const lines = text.split("\n"); + if (n > cap) lines.pop(); // a cut tail line is a partial line, not data + return { + lines: lines.map((l) => (l.length > maxLineBytes ? l.slice(0, maxLineBytes) : l)), + used: n, // over-budget reads still count what was attempted + }; + } finally { + closeSync(fd); + } +} + +/** + * A quoted Include token: OpenSSH accepts `"…"` around a pattern containing + * spaces; peel them so the space-split token list stays correct. + */ +function peelQuotes(token: string): string { + if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) return token.slice(1, -1); + return token; +} + +/** + * Walk one account's ssh_config: read the root file, follow `Include` globs + * with budgets, and collect `Host` block structure + `Match exec` presence. + * Cycle detection is over the walk's own ACTIVE ancestor chain (a file under + * itself), so a diamond (two includes of one shared file) dedupes silently + * while a true cycle raises `includeCycle`. NEVER throws: every fs surprise + * ends the walk of THAT file, not the discovery. + * + * @param rootFile - absolute path of the account's config (may not exist) + * @param homeDir - the connecting account's home, for `~` expansion + * @param budget - walk budgets (see {@link SshWalkBudget}) + */ +export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalkBudget = {}): SshConfigWalk { + const caps = { ...DEFAULT_BUDGET, ...budget }; + const walk: SshConfigWalk = { + hostBlocks: [], + globalDirectives: [], + matchExecSeen: false, + files: [], + includeCycle: false, + truncated: false, + }; + let filesRead = 0; + let bytesUsed = 0; + let currentBlock: SshHostBlock | null = null; + const seen = new Set(); // every file already parsed (dedupe) + const active = new Set(); // the ancestor chain (cycle detector) + + const openBlock = (): SshHostBlock => { + const block: SshHostBlock = { tokens: [], directives: [] }; + walk.hostBlocks.push(block); + currentBlock = block; + return block; + }; + + const visit = (file: string): void => { + let real: string; + try { + real = realpathSync(file); + } catch { + return; + } + if (active.has(real)) { + walk.includeCycle = true; // a file under its own includer: the §2 fact + return; + } + if (seen.has(real)) return; // a second ordinary include: dedupe, not a cycle + if (filesRead >= caps.maxFiles || bytesUsed >= caps.maxTotalBytes) { + walk.truncated = true; + return; + } + active.add(real); + seen.add(real); + filesRead += 1; + walk.files.push(real); + const budgetLeft = caps.maxTotalBytes - bytesUsed; + const { lines, used } = readCapped(real, budgetLeft, caps.maxLineBytes); + bytesUsed += used; + if (used > budgetLeft) walk.truncated = true; // the file was cut by the byte budget + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + const parsed = splitKeywordArg(line); + if (!parsed) continue; + const { keyword, arg } = parsed; + if (keyword === "host") { + const block = openBlock(); + block.tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + } else if (keyword === "include") { + const tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + for (const token of tokens) { + for (const included of expandIncludeGlob(token, real, homeDir, caps.maxIncludeMatches)) { + visit(included); + } + } + } else if (keyword === "match") { + // `Match exec …` runs a command locally (the §2 execution fact); the + // criteria keyword pair is what matters, not the command. + const criteria = arg.toLowerCase(); + if (/(^|\s)exec(\s|$)/.test(criteria)) walk.matchExecSeen = true; + } else if (keyword !== "") { + (currentBlock ? currentBlock.directives : walk.globalDirectives).push({ keyword, value: arg }); + } + } + active.delete(real); + }; + + visit(rootFile); + return walk; +} + +/** + * The `ssh_discover_aliases` engine: walk the account's config and answer the + * deduplicated, sorted alias names under the wire cap. Returns NAMES only — + * the frozen result envelope carries no config content, and neither does this. + * + * @param opts.homeDir - the connecting account's home (default: this process's) + * @param opts.configPath - the account's config file (default: `/.ssh/config`) + * @param opts.budget - walk budgets + */ +export function discoverSshAliases( + opts: { homeDir?: string; configPath?: string; budget?: SshWalkBudget } = {}, +): SshAliasDiscovery { + const homeDir = opts.homeDir ?? homedir(); + const walk = walkSshConfig(opts.configPath ?? defaultSshConfigPath(homeDir), homeDir, opts.budget); + const names = new Set(); + for (const block of walk.hostBlocks) { + for (const token of block.tokens) { + if (isDiscoverableAlias(token)) names.add(token); + } + } + let truncated = walk.truncated; + let aliases = [...names].sort(); + if (aliases.length > SSH_MAX_DISCOVERED_ALIASES) { + aliases = aliases.slice(0, SSH_MAX_DISCOVERED_ALIASES); + truncated = true; + } + return { aliases, includeCycle: walk.includeCycle, truncated }; +} diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts new file mode 100644 index 000000000..3810376dc --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -0,0 +1,363 @@ +import { existsSync } from "node:fs"; +import { userInfo } from "node:os"; +import { + type NodeSshResolveOutcomeWire, + parseSshConnectionSnapshot, + SSH_MAX_PROXY_HOPS, + SSH_PROBE_DEADLINE_MS, + type SshConnectionSnapshotWire, + type SshErrorCode, + type SshHopWire, +} from "@internal/subshell-protocol"; +import { + defaultSshConfigPath, + expandTilde, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh-discover.js"; +import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; + +/** + * Resolution: alias in, approved snapshot out (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, + * section 5.2): human-only resolution followed by an approved normalized + * configuration. + * + * The evaluator is OpenSSH itself — `ssh -G ` prints the effective + * config for a destination without connecting, which is the only answer that + * honors include precedence, defaults, and `Match` the way the real client + * would apply them. What this module adds is the FILTER: every resolved + * setting is either represented in the frozen snapshot grammar or refused by + * name, and the finished snapshot is re-validated through + * {@link parseSshConnectionSnapshot} before it is ever returned, so the + * resolver cannot emit a shape the wire would later reject. + * + * **Disclosure this step owes the human:** `ssh -G` EVALUATES the account's + * config, which means a trusted `Match exec` can run a LOCAL command during + * resolution. The walk separately detects `Match exec` and refuses to make + * such a config a saved connection, but detection happens after the eval, so + * the refusal is about what gets stored, not about what already ran. The + * resolve command's JSDoc on the wire names this; UI copy must render it + * before the button (configuration is executable: + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 5.2). + */ + +/** Inputs {@link resolveSshAliasConfig} needs; every one is injectable so tests never touch a developer's `~/.ssh`. */ +export interface SshResolutionDeps { + /** Absolute path of the ssh(1) binary (the agent resolves the ladder; nothing here re-searches PATH to pick the executable). */ + sshBin: string; + /** The connecting account's home directory (HOME for the child, `~` expansion). */ + homeDir: string; + /** The account's config file (default `/.ssh/config`). */ + configPath?: string; + /** Process env whose `SSH_AUTH_SOCK` participates in agent resolution (default: this process's). */ + env?: Record; + /** The connecting OS account name (display fact); default `os.userInfo().username` when obtainable. */ + connectingAccount?: string; + /** Overall deadline for the `ssh -G` evaluation. */ + timeoutMs?: number; + /** Config-walk budgets (tests pass small ones). */ + walkBudget?: SshWalkBudget; +} + +/** One line of `ssh -G` output, lowercased keyword + raw argument string. */ +type GLine = { keyword: string; value: string }; + +/** Refusal with no config keywords to name (the code itself is the whole diagnosis). */ +function refuse(code: SshErrorCode, settings: string[] = []): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} + +/** Parse `-G` stdout: one `keyword value...` line each; blank/continuation-free by OpenSSH's own format. */ +function parseDashG(stdout: string): GLine[] { + const lines: GLine[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const cut = line.search(/\s/); + if (cut === -1) lines.push({ keyword: line.toLowerCase(), value: "" }); + else lines.push({ keyword: line.slice(0, cut).toLowerCase(), value: line.slice(cut + 1).trim() }); + } + return lines; +} + +/** All values of any of the accepted spellings, whitespace-split (the list options' own delimiter). */ +function valuesOf(g: GLine[], ...keywords: string[]): string[] { + const out: string[] = []; + for (const line of g) { + if (keywords.includes(line.keyword)) out.push(...line.value.split(/\s+/).filter((v) => v !== "")); + } + return out; +} + +/** True when any value of the keyword is "set" (not none/off/no/0 — the option's unconfigured spelling). */ +function setWhere(g: GLine[], keyword: string, unconfigured: readonly string[] = ["none"]): boolean { + return setWhereAny(g, [keyword], unconfigured); +} + +function setWhereAny(g: GLine[], keywords: readonly string[], unconfigured: readonly string[] = ["none"]): boolean { + return valuesOf(g, ...keywords).some((v) => !unconfigured.includes(v.toLowerCase())); +} + +/** + * The forward keyword, BOTH ways. OpenSSH's `ssh -G` echoes the option + * keyword, and the historical/config spellings of these three differ across + * versions (`LocalForward` vs the `locallyforward` echo, likewise remote) — + * accepting both spellings means a version drift can only ever make us + * REFUSE on a present directive, never miss one. Missing a forward would be + * the silent-omission defect §2 names. + */ +const FORWARD_LOCAL_KEYWORDS = ["localforward", "locallyforward"]; +const FORWARD_REMOTE_KEYWORDS = ["remoteforward", "remotelyforward"]; +const FORWARD_DYNAMIC_KEYWORDS = ["dynamicforward"]; + +/** Parse one ProxyJump token `[user@]host[:port]` into the frozen hop shape; bracketed IPv6 keeps its brackets (the snapshot grammar wants them). */ +export function parseProxyHop(token: string, defaultUser: string | null): SshHopWire | null { + if (token === "") return null; + let rest = token; + let user: string | null = null; + const at = rest.lastIndexOf("@"); + if (at > 0) { + user = rest.slice(0, at); + rest = rest.slice(at + 1); + if (user === "") user = null; + } else if (at === 0) { + return null; // "@host" — an empty user is not a route fact to store + } + let port: number | null = null; + let host = rest; + if (rest.startsWith("[")) { + const close = rest.indexOf("]"); + if (close === -1) return null; + host = rest.slice(0, close + 1); + const tail = rest.slice(close + 1); + if (tail.startsWith(":")) { + port = Number(tail.slice(1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } else if (tail !== "") { + return null; + } + } else { + const colon = rest.lastIndexOf(":"); + if (colon !== -1) { + host = rest.slice(0, colon); + port = Number(rest.slice(colon + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } + } + if (host === "") return null; + return { host, user: user ?? defaultUser, port: port ?? 22 }; +} + +/** Case-insensitive alias membership among a Host block's pattern tokens (negations excluded — `!x` never makes a block match x). */ +function blockMatchesAlias(block: SshHostBlock, alias: string): boolean { + return block.tokens.some((t) => !t.startsWith("!") && !/[?*]/.test(t) && t.toLowerCase() === alias.toLowerCase()); +} + +/** + * Resolve one alias on this machine into the approved snapshot, or a refusal + * naming WHY (§2: "refuse with a named limitation rather than silently + * changing connection semantics"). A refusal is an ACCEPTED command answer + * (it rides `accepted:false`, in-the-data) so the human can read which + * setting blocked and go edit the config. + * + * @param alias - the config token the human chose (also a manual destination + * when the config has no block for it and the token is itself a hostname) + * @param deps - binary path, home, config path, env, budgets (all injectable) + */ +export async function resolveSshAliasConfig( + alias: string, + deps: SshResolutionDeps, +): Promise { + // Shape gate FIRST (defense-in-depth beside the wire parser): this string + // reaches `ssh -G` as an argv tail — an option-like or control-bearing + // token must die here, not at getopt. + if (alias.length === 0 || alias.length > 253 || alias.startsWith("-") || /\s|\p{Cc}/u.test(alias)) { + return refuse("config_missing"); + } + const configPath = deps.configPath ?? defaultSshConfigPath(deps.homeDir); + const env = deps.env ?? (process.env as Record); + const connectingAccount = deps.connectingAccount ?? safeUsername(); + + // The structural walk happens before the evaluation: `Match exec` is a + // config-shape fact the walk sees without running anything, and alias + // membership (manual alias vs config alias) comes from the same walk the + // discovery command answers with — one parser, one truth about "in config". + const walk = walkSshConfig(configPath, deps.homeDir, deps.walkBudget); + if (walk.matchExecSeen) return refuse("unsupported_setting", ["Match exec"]); + + const argv = [deps.sshBin, "-G"]; + if (existsSync(configPath)) argv.push("-F", configPath); + argv.push(alias); + const childEnv: Record = { + PATH: await sshChildPath(), + HOME: deps.homeDir, + ...(env.LANG ? { LANG: env.LANG } : {}), + ...(env.TMPDIR ? { TMPDIR: env.TMPDIR } : {}), + ...(env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: env.SSH_AUTH_SOCK } : {}), + }; + for (const [k, v] of Object.entries(env)) if (k.startsWith("LC_") && v) childEnv[k] = v; + const run = await runSshProcess(argv, childEnv, deps.timeoutMs ?? SSH_PROBE_DEADLINE_MS); + if (run.timedOut || run.spawnError || run.code !== 0) { + // The evaluator could not be brought to answer: the destination cannot be + // reduced to one reviewable route. `config_ambiguous` is the frozen code + // for "cannot be uniquely resolved"; settings carries ssh's own last line + // so the human sees a hint that is not a diagnosis (never a config blob). + const last = run.stderr.trim().split("\n").pop() ?? ""; + return refuse("config_ambiguous", last === "" ? [] : [`ssh -G failed: ${last.slice(0, 200)}`]); + } + const g = parseDashG(run.stdout); + + // --- forbidden settings: any of these present at RESOLUTION fails setup --- + const blocked: string[] = []; + if (setWhere(g, "proxycommand")) blocked.push("ProxyCommand"); + if (setWhereAny(g, FORWARD_LOCAL_KEYWORDS)) blocked.push("LocalForward"); + if (setWhereAny(g, FORWARD_REMOTE_KEYWORDS)) blocked.push("RemoteForward"); + if (setWhereAny(g, FORWARD_DYNAMIC_KEYWORDS)) blocked.push("DynamicForward"); + // The unconfigured spellings are the ones OpenSSH's `-G` PRINTS for an + // unconfigured option, measured on 10.2p1: `tunnel false` and + // `permitremoteopen any`. A config-set value is whatever the config chose + // (`tunnel yes`, `permitremoteopen hosts:socks:1080`), which lands outside + // these lists and blocks. The historical `no` spelling stays accepted: + // older `-G` output and hand-fed fixtures use it. + if (setWhere(g, "tunnel", ["no", "false"])) blocked.push("Tunnel"); + if (setWhere(g, "permitremoteopen", ["none", "any"])) blocked.push("PermitRemoteOpen"); + if (setWhere(g, "permitlocalcommand", ["no"])) blocked.push("PermitLocalCommand"); + if (setWhere(g, "localcommand")) blocked.push("LocalCommand"); + if (setWhere(g, "remotecommand")) blocked.push("RemoteCommand"); + if (g.some((l) => l.keyword === "sendenv" && l.value !== "")) blocked.push("SendEnv"); + if (g.some((l) => l.keyword === "setenv" && l.value !== "")) blocked.push("SetEnv"); + if (setWhere(g, "knownhostscommand")) blocked.push("KnownHostsCommand"); + if (blocked.length > 0) return refuse("unsupported_setting", blocked); + // NOTE on what is NOT refused here: ForwardAgent/ForwardX11/ControlPath/ + // EscapeChar/GSSAPI found in config are killed by the MANDATORY runtime + // policy (ssh-render.ts applies it to every hop regardless), so they change + // nothing the human approved and stay representable. A ProxyCommand has no + // kill switch — it IS the route — so it fails setup instead. That asymmetry + // is the grammar's, not an accident. + + // --- destination facts --- + const hostValues = valuesOf(g, "hostname"); + const hostLine = valuesOf(g, "host")[0] ?? alias; + const host = hostValues[0] ?? hostLine; + const matchedBlocks = walk.hostBlocks.filter((b) => blockMatchesAlias(b, alias)); + const inConfig = matchedBlocks.length > 0; + if (!inConfig) { + // Manual-alias path: only legitimate when OpenSSH itself treated the + // token as the hostname (no config rewrite happened). Anything else means + // the token is neither an alias nor a destination the account can reach. + if (host.toLowerCase() !== alias.toLowerCase()) return refuse("config_missing"); + } else { + // Two blocks setting CONFLICTING destination facts cannot be reduced to + // one route a human reviewed (first-obtained-wins makes -G deterministic, + // but the ORDER that decided it is config-invisible noise the reviewer + // would never catch) — §2's config_ambiguous. One HostName plus one Port + // is NOT a conflict (measured on 10.2p1: the shared-set spelling refused + // every `HostName x` + `Port n` block, the ordinary config shape) — each + // family is counted on its own, and only a family with two different + // values is the ambiguity this check names. + const hostNames = new Set(); + const ports = new Set(); + for (const b of [...walk.globalDirectives, ...matchedBlocks.flatMap((m) => m.directives)]) { + if (b.keyword === "hostname" && b.value !== "") hostNames.add(b.value.toLowerCase()); + if (b.keyword === "port" && b.value !== "") ports.add(b.value); + } + if (hostNames.size > 1 || ports.size > 1) return refuse("config_ambiguous"); + } + + const portRaw = Number(valuesOf(g, "port")[0] ?? "22"); + const port = Number.isInteger(portRaw) ? portRaw : NaN; + const userRaw = valuesOf(g, "user")[0] ?? null; + // -G always prints SOME user (the connecting account's own default); null + // means exactly that default, so the snapshot stores null for it and a real + // value only when the config chose a different account. + const user = userRaw !== null && userRaw.toLowerCase() !== (connectingAccount ?? "").toLowerCase() ? userRaw : null; + + // --- trust + identity refs (paths on THIS machine; the snapshot carries refs, never contents) --- + // `-G` prints the DEFAULT identity files in tilde form (`identityfile + // ~/.ssh/id_rsa`); the snapshot grammar takes only absolute paths, so the + // tilde expands against the same home ssh itself would expand it against. + // `~user/...` does not expand: the parser refuses the ref whole, which is + // fail-closed, not a silent route change. + const refPaths = (keyword: string): string[] => + valuesOf(g, keyword) + .filter((v) => v !== "" && v !== "none") + .map((v) => expandTilde(v, deps.homeDir)); + const identityFiles = refPaths("identityfile"); + const certificateFiles = refPaths("certificatefile"); + const knownHostsFiles = refPaths("userknownhostsfile"); + const hostKeyAliasRaw = valuesOf(g, "hostkeyalias")[0] ?? "none"; + const hostKeyAlias = hostKeyAliasRaw !== "none" && hostKeyAliasRaw !== "" ? hostKeyAliasRaw : null; + + // --- agent: ONLY an absolute socket from the account's trusted setup (an + // explicit IdentityAgent path, else the account's own SSH_AUTH_SOCK) --- + const identityAgent = valuesOf(g, "identityagent")[0] ?? null; + let authAgentSocket: string | null = null; + if (identityAgent?.startsWith("/")) { + authAgentSocket = identityAgent; + } else if (identityAgent !== "none" && env.SSH_AUTH_SOCK?.startsWith("/")) { + // Everything that is not a path and not `none` — the explicit `ssh-agent` + // spelling, any future one, and the keyword's absence — means "use the + // account's agent", whose socket is exactly the account's env + // `SSH_AUTH_SOCK`. `none` above is the only way to say "no agent"; the + // env sock is trusted because it is the connecting account's own setup, + // and the child env carries it only when the snapshot names a socket. + authAgentSocket = env.SSH_AUTH_SOCK; + } + + // --- jump chain: comma-separated per directive, all directives concatenated in config order --- + const hops: SshHopWire[] = []; + for (const line of g.filter((l) => l.keyword === "proxyjump")) { + for (const token of line.value + .split(",") + .map((t) => t.trim()) + .filter((t) => t !== "")) { + const hop = parseProxyHop(token, user); + if (!hop) return refuse("config_ambiguous", ["ProxyJump"]); + hops.push(hop); + } + } + if (hops.length > SSH_MAX_PROXY_HOPS) return refuse("proxy_chain_too_long"); + + const candidate = { + alias, + host, + user, + port, + identityFiles, + certificateFiles, + authAgentSocket, + knownHostsFiles, + hostKeyAlias, + proxyJumps: hops, + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + } satisfies SshConnectionSnapshotWire; + + const approved = parseSshConnectionSnapshot(candidate); + if (approved === null) { + // The normalized facts do not fit the grammar (a hostname with spaces in + // config, an out-of-range port, a relative identity path from a broken + // build): unrepresentable, and refused whole. + return refuse("config_ambiguous"); + } + return { + accepted: true, + snapshot: approved, + ...(connectingAccount !== undefined ? { connectingAccount } : {}), + }; +} + +function safeUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return undefined; + } +} diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts new file mode 100644 index 000000000..44baaeebf --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -0,0 +1,133 @@ +/** + * One ssh child, run to completion, captured, with a deadline that holds. + * + * This is the short-RPC sibling of the brokered-session supervisor: + * `ssh -G` resolution and the session probe both finish or die inside a + * bounded window and their stdout/stderr + * ARE the answer, so they need capture, not a streaming output store. The + * deadline posture is `run-bounded.ts`'s (kill AND cancel the readers, then + * escalate to SIGKILL — killing the child does not close pipes its children + * hold), simplified to a captured two-stream read because nothing here + * streams lines; a chatty ssh cannot matter because `-G` and a bounded probe + * produce a fixed small shape, and the capture caps keep even a pathological + * child inside the RPC envelope. + */ + +import { loginPathEntries } from "../login-path.js"; + +/** Captured-output cap per stream for the short ssh RPCs (config dumps are small; a runaway is capped, not followed). */ +const CAPTURE_CAP_BYTES = 256 * 1024; + +/** Escalation gap between SIGTERM and SIGKILL on the deadline path. */ +const KILL_GRACE_MS = 2_000; + +/** The outcome of one short ssh process. */ +export interface SshProcessResult { + /** Exit code, or null when the process was killed by a signal or never spawned. */ + code: number | null; + stdout: string; + stderr: string; + /** The deadline ended the run. */ + timedOut: boolean; + /** The child could not be spawned at all (no such binary); the message is on stderr. */ + spawnError: boolean; +} + +/** + * Run `argv` to completion or deadline, capturing both streams separately. + * + * @param argv - the command; argv[0] must be an absolute path — the callers + * resolve `ssh` through the binary ladder BEFORE calling, and nothing here + * consults PATH to pick an executable (the §2 "explicit argv" rule). + * @param env - the COMPLETE child environment; no inheritance, by design + * @param timeoutMs - hard deadline; the child is terminated at it + */ +export async function runSshProcess( + argv: readonly string[], + env: Record, + timeoutMs: number, +): Promise { + let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; + try { + proc = Bun.spawn([...argv], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + }) as Bun.Subprocess<"ignore", "pipe", "pipe">; + } catch (err) { + return { + code: null, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + timedOut: false, + spawnError: true, + }; + } + const outReader: CappedReader = proc.stdout.getReader(); + const errReader: CappedReader = proc.stderr.getReader(); + let timedOut = false; + let escalate: ReturnType | undefined; + const stop = (): void => { + timedOut = true; + proc.kill(); + void outReader.cancel().catch(() => {}); + void errReader.cancel().catch(() => {}); + escalate ??= setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // already reaped + } + }, KILL_GRACE_MS); + escalate.unref?.(); + }; + const timer = setTimeout(stop, timeoutMs); + timer.unref?.(); + try { + const [stdout, stderr] = await Promise.all([drainCapped(outReader), drainCapped(errReader)]); + const code = await proc.exited; + return { code: timedOut ? null : code, stdout, stderr, timedOut, spawnError: false }; + } catch { + return { code: null, stdout: "", stderr: "", timedOut, spawnError: false }; + } finally { + clearTimeout(timer); + if (escalate !== undefined) clearTimeout(escalate); + } +} + +/** The minimal reader shape the drain needs (structural, like `run-bounded.ts`'s — the exact reader class varies by stream generic). */ +interface CappedReader { + read(): Promise<{ done?: boolean; value?: Uint8Array }>; + cancel(): Promise; +} + +/** Read a stream up to the cap; past the cap KEEP DRAINING (a child blocked on a full, unread pipe never exits). */ +async function drainCapped(reader: CappedReader): Promise { + const chunks: Uint8Array[] = []; + let kept = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value !== undefined && kept < CAPTURE_CAP_BYTES) { + chunks.push(value); + kept += value.byteLength; + } + } + } catch { + // cancelled by the deadline: return what arrived + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +/** + * The PATH value an ssh child gets: this process's PATH plus the login + * shell's entries (a systemd-launched daemon's PATH is baked at install time + * and cannot see a Homebrew /usr/local/bin ssh installed later). Exposed so + * the supervised run's spawn and the short RPCs state the PATH rule once. + */ +export async function sshChildPath(): Promise { + const entries = [...(process.env.PATH ?? "").split(":"), ...(await loginPathEntries())].filter(Boolean); + return [...new Set(entries)].join(":"); +} From 4bd1559572e82c8bc59d833cde0f26bf6f52f490 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:46:56 -0700 Subject: [PATCH 014/170] test(e2e): port the isolated sshd fixture --- e2e/fixtures/sshd.ts | 298 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 e2e/fixtures/sshd.ts diff --git a/e2e/fixtures/sshd.ts b/e2e/fixtures/sshd.ts new file mode 100644 index 000000000..f9c00e87b --- /dev/null +++ b/e2e/fixtures/sshd.ts @@ -0,0 +1,298 @@ +/** + * The e2e sshd fixture (specs 22 and 23): a REAL isolated sshd, the port of + * `packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts`'s recipe + * from Bun APIs to Node ones (the Playwright runner is Node). It owns and + * cleans only its own pieces: generated host + client keys, one sshd on a + * loopback ephemeral port (own pid tracked), and an `sshConfigHome` the node + * agent runs as its HOME. The developer's `~/.ssh` is never touched — every + * byte lives under the caller's temp root. + * + * Trust choice (one pick, documented per the brief): a PRE-TRUSTED + * `known_hosts` at `/.ssh/known_hosts` — exactly where the + * node's `ssh -G` resolves the default `UserKnownHostsFile` (the child HOME + * is this dir), so the approved snapshot carries the fixture's trusted file + * and the runtime's mandatory `StrictHostKeyChecking yes` passes on the + * pinned key. No `accept-new` first-contact pinning: the feature requires + * existing verified trust, and the fixture enters that contract already + * trusted rather than exercising a mode production never uses. + */ +import { spawn, spawnSync } from "node:child_process"; +import { accessSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { createServer } from "node:net"; +import { userInfo } from "node:os"; +import path from "node:path"; + +/** The alias every consumer of this fixture references (`~/.ssh/config`). */ +export const SSH_FIXTURE_ALIAS = "e2edest"; + +/** sshd's known install locations beyond PATH (probe fallbacks, the ladder's shape). */ +const SSHD_KNOWN_PATHS = ["/usr/sbin/sshd"]; + +/** PATH probe (the Node stand-in for `Bun.which`); sshd also checks the known install paths and honors `E2E_SSHD_BIN`. */ +function which(bin: string, extraPaths: readonly string[] = []): string | null { + const override = bin === "sshd" ? process.env["E2E_SSHD_BIN"] : undefined; + if (override !== undefined && override !== "") return override; + const dirs = (process.env.PATH ?? "").split(path.delimiter).filter((d) => d !== ""); + for (const dir of [...dirs, ...extraPaths]) { + try { + accessSync(path.join(dir, bin)); + return path.join(dir, bin); + } catch { + // not here + } + } + return null; +} + +/** The missing pieces, empty when the host can run the real-daemon suite. */ +export function missingSshBins(): string[] { + const missing: string[] = []; + if (which("ssh") === null) missing.push("ssh"); + if (which("ssh-keygen") === null) missing.push("ssh-keygen"); + if (which("sshd", SSHD_KNOWN_PATHS) === null) missing.push("sshd"); + return missing; +} + +/** A loopback ephemeral port, freed the instant it is named (the Node stand-in for `Bun.listen`). */ +async function freePort(): Promise { + const srv = createServer(); + await new Promise((resolve, reject) => { + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => resolve()); + }); + const { port } = srv.address() as AddressInfo; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +/** The probe asks "is anything listening"; StrictHostKeyChecking=no is the probe's own trust, never the feature's (the real trust is the pre-trusted known_hosts). `-F /dev/null` seals the ambient `~/.ssh/config` out of a probe meant to be ambient-free. */ +const PROBE_OPTS = [ + "BatchMode=yes", + "ConnectTimeout=1", + "StrictHostKeyChecking=no", + "UserKnownHostsFile=/dev/null", +].flatMap((o) => ["-o", o]); + +/** sshd's readiness, probed through ssh itself: "not refused" means the daemon answers (the bun recipe's rule). */ +async function waitListening(sshBin: string, port: number, logPath: string): Promise { + for (let i = 0; i < 100; i++) { + const probe = spawnSync(sshBin, ["-F", "/dev/null", "-p", String(port), ...PROBE_OPTS, "127.0.0.1", "true"], { + stdio: ["ignore", "ignore", "pipe"], + }); + const err = probe.stderr?.toString() ?? ""; + if (!/Connection refused|connect to host 127\.0\.0\.1 port|Connection timed out/i.test(err)) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`sshd did not come up on 127.0.0.1:${port}\n${readFileSync(logPath, "utf8").slice(-2000)}`); +} + +export interface SshFixture { + /** The loopback port the fixture sshd answers on. */ + port: number; + /** A loopback port THIS fixture holds with an accept-then-destroy listener: the dead-run target answers with an immediate connection reset (a freed port could be silently rebound between free and dial, turning the honest 255 into a ssh-connect timeout). */ + deadPort: number; + /** The fixture user — the host's own account (sshd refuses passwords; keys only). */ + user: string; + /** The directory to hand the node agent as HOME (its `.ssh/` carries config + known_hosts). */ + sshConfigHome: string; + /** Absolute path of the passphrase-less client key sshd authorizes. */ + trustedKeyPath: string; + /** The daemon's own stderr log — a real "Accepted publickey" line is a connect truth no client-side fact gives. */ + logPath: string; + /** SIGTERM the daemon this fixture started (and only that one). */ + stop(): Promise; +} + +/** Live fixture daemons, killed on process exit so a crashed worker never strands an sshd. */ +const live = new Set(); +process.on("exit", () => { + for (const pid of live) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } +}); + +/** + * Start the fixture under `root` (a caller-mkdtemp'd dir; the caller owns the + * bytes, this function owns the daemon). Generates ed25519 host + client keys, + * writes the account config alias `e2edest`, the pre-trusted `known_hosts`, + * and sshd's own config (loopback-only, key-only auth, no PAM). + * + * `envPath` (spec 23) adds one `SetEnv PATH=` line. sshd resets a session's + * PATH to the platform login default regardless of the daemon's own + * environment (measured: a PATH prepended before spawn never reaches the + * remote command), so a destination that must find a scratch binary on PATH + * needs the daemon to hand it out. Absent, the fixture is exactly the + * default-PATH world it has always been. + */ +export async function startSshFixture(root: string, options: { envPath?: string } = {}): Promise { + const sshBin = which("ssh"); + const keygenBin = which("ssh-keygen"); + const sshdBin = which("sshd", SSHD_KNOWN_PATHS); + if (sshBin === null || keygenBin === null || sshdBin === null) { + throw new Error(`startSshFixture called on a host without ssh/ssh-keygen/sshd (${missingSshBins().join(", ")})`); + } + // The connecting account NAME, deterministically. `os.userInfo().username` + // is env-driven on bun (the Playwright runner IS bun) and passwd-driven on + // node: in a container with no USER/LOGNAME env bun answers "unknown" while + // sshd, matching the uid not the env, logs the real account ("root"). + // uid 0 is root on every supported OS; otherwise env, then the passwd + // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-runtime) so + // the daemon's own `connectingAccount` says the same thing everywhere. + const user = process.getuid?.() === 0 ? "root" : (process.env.USER ?? process.env.LOGNAME ?? userInfo().username); + const home = path.join(root, "home"); + mkdirSync(home, { recursive: true, mode: 0o700 }); + mkdirSync(path.join(home, ".ssh"), { mode: 0o700 }); + chmodSync(home, 0o700); + + const hostKey = path.join(root, "hostkey"); + const clientKey = path.join(root, "clientkey"); + for (const f of [hostKey, clientKey]) { + const kg = spawnSync(keygenBin, ["-q", "-t", "ed25519", "-N", "", "-f", f], { stdio: "ignore" }); + if (kg.status !== 0) throw new Error(`ssh-keygen failed for ${f}: exit ${kg.status}`); + } + + const port = await freePort(); + // The dead-run target, HELD for the fixture's life (review M2): we bind the + // named-free port ourselves and destroy every inbound socket, so a dial is + // refused fast (ssh: "Connection closed", exit 255 - exactly what + // sshTransportFailure corroborates) and NO third party can re-bind the name + // between our freePort and the dead run's connect. + const deadPort = await freePort(); + const refuseHold = createServer((sock) => sock.destroy()); + await new Promise((resolve, reject) => { + refuseHold.once("error", reject); + refuseHold.listen(deadPort, "127.0.0.1", () => resolve()); + }); + + // Pre-trusted known_hosts, named ABSOLUTE in the config below. ssh resolves + // the DEFAULT `userknownhostsfile` against the passwd home, not the injected + // $HOME, so a bare `~/.ssh/known_hosts` default would make the approved + // snapshot point at the host user's real file and the mandatory + // `StrictHostKeyChecking yes` probe fail host_key_unknown. The bracketed + // `[host]:port` authority form is what the non-22 port matches. + const knownHosts = path.join(home, ".ssh", "known_hosts"); + const keyTypeAndBlob = readFileSync(`${hostKey}.pub`, "utf8").trim().split(" ").slice(0, 2).join(" "); + writeFileSync(knownHosts, `[127.0.0.1]:${port} ${keyTypeAndBlob}\n`); + + // The account's config: the alias every consumer references. `IdentityAgent + // none` keeps an ambient ssh-agent out of resolution (its keys could crowd + // the fixture's single trusted key against sshd's MaxAuthTries). + writeFileSync( + path.join(home, ".ssh", "config"), + [ + `Host ${SSH_FIXTURE_ALIAS}`, + " HostName 127.0.0.1", + ` Port ${port}`, + ` User ${user}`, + ` IdentityFile ${clientKey}`, + " IdentitiesOnly yes", + " IdentityAgent none", + ` UserKnownHostsFile ${knownHosts}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + // sshd trusts exactly the fixture's client key; the host's own `~/.ssh` is + // never consulted (StrictModes off lets authorized_keys live at the root). + const authorizedKeys = path.join(root, "authorized_keys"); + writeFileSync(authorizedKeys, `${readFileSync(`${clientKey}.pub`, "utf8").trim()}\n`); + + const sshdDir = path.join(root, "sshd"); + mkdirSync(sshdDir, { recursive: true }); + const sshdConfig = path.join(sshdDir, "sshd_config"); + const sshdLog = path.join(sshdDir, "sshd.log"); + writeFileSync( + sshdConfig, + [ + `Port ${port}`, + "ListenAddress 127.0.0.1", + "AddressFamily inet", + `HostKey ${hostKey}`, + `PidFile ${path.join(sshdDir, "sshd.pid")}`, + `AuthorizedKeysFile ${authorizedKeys}`, + "StrictModes no", + "UsePAM no", + "UseDNS no", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "PermitUserEnvironment no", + "PrintMotd no", + "LogLevel VERBOSE", + // The spec-23 seam: a session PATH the daemon hands out (the default + // login PATH would otherwise hide any scratch binary). One token, no + // spaces: sshd's SetEnv value grammar. + ...(options.envPath !== undefined && options.envPath !== "" ? [`SetEnv PATH=${options.envPath}`] : []), + ].join("\n"), + ); + // sshd's privilege-separation directory is compiled in (/run/sshd; no + // sshd_config keyword overrides it) and the daemon refuses to start without + // it. Measured: the CI builder image's openssh-server postinst does NOT + // create it in a non-systemd container (job run 37274033881 died exactly + // there), while dev hosts usually have one because sshd is installed and + // running. Ensure it opportunistically: an existing dir or a non-writable + // /run surfaces through sshd's own stderr in the readiness failure below. + try { + mkdirSync("/run/sshd", { mode: 0o755 }); + } catch { + // already there, or not ours to make - sshd's log will say which + } + // sshd -e means "log to stderr" and takes NO file argument: the fixture + // redirects the child's stderr into its own file (the bun recipe's rule). + const logFd = openSync(sshdLog, "w"); + const child = spawn(sshdBin, ["-D", "-f", sshdConfig, "-e"], { + stdio: ["ignore", "ignore", logFd], + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + }); + closeSync(logFd); + const pid = child.pid; + if (pid === undefined) throw new Error("sshd spawn returned no pid"); + live.add(pid); + child.once("exit", () => live.delete(pid)); + // A spawn-adjacent failure is async on a long-lived child: hold the message + // for the readiness failure text rather than letting it throw unhandled. + let spawnError: string | null = null; + child.once("error", (err) => { + spawnError = String(err); + }); + try { + await waitListening(sshBin, port, sshdLog); + } catch (err) { + throw new Error(`${String(err)}${spawnError === null ? "" : `\nspawn error: ${spawnError}`}`); + } + + return { + port, + deadPort, + user, + sshConfigHome: home, + trustedKeyPath: clientKey, + logPath: sshdLog, + async stop(): Promise { + await new Promise((resolve) => refuseHold.close(() => resolve())); + live.delete(pid); + for (const sig of ["SIGTERM", "SIGKILL"] as const) { + try { + process.kill(pid, sig); + } catch { + return; + } + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + } + }, + }; +} From fa0ce9b7050e9c7b853401b5f74c22a96e26cd99 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:49:18 -0700 Subject: [PATCH 015/170] feat(nodes): add ssh_enabled capability column (default off) + migration --- apps/server/api/src/db/migrate.ts | 4 + .../db/migrations/0047-node-ssh-enabled.ts | 18 ++++ .../__tests__/0047-node-ssh-enabled.test.ts | 99 +++++++++++++++++++ .../src/db/repositories/nodes.repository.ts | 4 + .../server/api/src/db/types/nodes.db-types.ts | 14 +++ .../nodes/__tests__/remote-launcher.test.ts | 2 + 6 files changed, 141 insertions(+) create mode 100644 apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 9d68dc0b0..25a4a654e 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -46,6 +46,7 @@ import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cros import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback-base.js"; import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; /** * Runs all pending Kysely migrations against the app database. @@ -132,6 +133,9 @@ export async function runMigrations(): Promise { "0044-oauth-callback-base": oauthCallbackBaseMigration, "0045-node-reregistration": nodeReregistrationMigration, "0046-backup-recovery": backupRecoveryMigration, + // The per-node SSH capability gate: a flag plus its changedAt stamp, + // default OFF so an upgrade never turns SSH egress on anywhere (spec 4.3). + "0047-node-ssh-enabled": sshEnabledMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts new file mode 100644 index 000000000..f52bf23b9 --- /dev/null +++ b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node SSH capability gate. Default 0 keeps the whole fleet (and +// the control-plane host) off after an upgrade: SSH egress and key use are +// opt-in per machine (spec 4.3; the maintenance default-0 "upgrade keeps the +// fleet as it was" doctrine). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("ssh_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("ssh_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts new file mode 100644 index 000000000..bd1e322ba --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The SSH capability gate columns. Default 0 is the whole upgrade story: a + * node enrolled before this migration (and the seeded local row) reads OFF, + * because SSH egress and key use are opt-in per machine. + */ +describe("migration 0047-node-ssh-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0047 repository did - no ssh fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0047-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await sshEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).toContain("ssh_enabled"); + expect(names).toContain("ssh_enabled_at"); + }); + + it("leaves every pre-existing node with SSH off (default-off doctrine)", async () => { + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string | null; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 0, ssh_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ ssh_enabled: number }>`SELECT ssh_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].ssh_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await sshEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("ssh_enabled"); + expect(names).not.toContain("ssh_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await sshEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index f54455f90..628365bdd 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -56,6 +56,10 @@ export class NodesRepository extends BaseRepository { maintenance: input.maintenance ?? 0, maintenanceAt: input.maintenanceAt ?? null, maintenanceSource: input.maintenanceSource ?? null, + // A new node has SSH off: `0` is the column default (migration 0047) + // and the gate is opt-in per machine (spec 4.3); mirror it the same way. + sshEnabled: input.sshEnabled ?? 0, + sshEnabledAt: input.sshEnabledAt ?? null, createdAt: input.createdAt ?? now, updatedAt: now, }) diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 98c3f6794..80c215583 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -87,6 +87,20 @@ export interface NodeTable { maintenanceAt: string | null; /** Which end wrote the standing value ({@link MaintenanceSource}); null when none has. */ maintenanceSource: MaintenanceSource | null; + /** + * 1 = SSH egress and key use are enabled on this machine (spec 4.3). + * Opt-in per node, never a fleet default: 0 for every row that predates + * the column, so an upgrade leaves the whole fleet (and the control-plane + * host) with SSH off until someone flips it. + */ + sshEnabled: number; + /** + * ISO 8601 of the write that last changed {@link sshEnabled} (the wire's + * `changedAt`), or null when the flag was never written. The stamp rides + * the flag on every push, the shape {@link maintenanceAt} has for + * maintenance. + */ + sshEnabledAt: string | null; /** ISO 8601 creation time */ createdAt: string; /** ISO 8601 last update time */ diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index cf2083078..ed438d787 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -173,6 +173,8 @@ function nodeRow(over: Partial = {}): NodeTable { maintenance: 0, maintenanceAt: null, maintenanceSource: null, + sshEnabled: 0, + sshEnabledAt: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", ...over, From 57437212d525310b66dc79b6f8a839770a069f74 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:51:05 -0700 Subject: [PATCH 016/170] feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabled) --- .../api/src/lib/__tests__/node-access.test.ts | 42 +++++++++++++++++++ apps/server/api/src/lib/node-access.ts | 24 +++++++++++ 2 files changed, 66 insertions(+) diff --git a/apps/server/api/src/lib/__tests__/node-access.test.ts b/apps/server/api/src/lib/__tests__/node-access.test.ts index d69333662..570f37569 100644 --- a/apps/server/api/src/lib/__tests__/node-access.test.ts +++ b/apps/server/api/src/lib/__tests__/node-access.test.ts @@ -13,6 +13,7 @@ import { nodeCanManage, nodeCanManageFor, nodeCanRetire, + nodeCanSsh, resolveNodeAccess, } from "@/lib/node-access.js"; @@ -322,3 +323,44 @@ describe("nodeCanLaunchOn", () => { }); }); }); + +describe("nodeCanSsh", () => { + it("refuses when the node's SSH gate is off, whoever the actor is", () => { + expect( + nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: false }), + ).toBe(false); + }); + it("refuses a non-owner even when enabled and shared edit", () => { + expect( + nodeCanSsh({ kind: "agent", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("refuses an admin on someone else's node: the boost confers management, never SSH use", () => { + expect( + nodeCanSsh({ kind: "agent", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("allows an owner of an enabled agent node", () => { + expect( + nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + }); + it("allows an admin on the enabled local server (admins resolve to edit, never owner)", () => { + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + }); + it("the seeded Everyone edit grant on local does not confer SSH use", () => { + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("local honors the server-as-node switch", () => { + expect( + nodeCanSsh({ kind: "local", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: false, sshEnabled: true }), + ).toBe(false); + }); +}); diff --git a/apps/server/api/src/lib/node-access.ts b/apps/server/api/src/lib/node-access.ts index 8630e1a6e..6e30d45e2 100644 --- a/apps/server/api/src/lib/node-access.ts +++ b/apps/server/api/src/lib/node-access.ts @@ -97,6 +97,30 @@ export function nodeCanLaunchOn( return kind === "local" ? nodeCanLaunch(granted) : nodeCanLaunch(access); } +/** Whether this node may be used for Subshell SSH at all (dial out or serve keys). + * Owner-only, matching who may broker an OS account's credentials, with the same + * local-node admin exception `nodeCanManageFor` applies (the resolver ranks admins + * at `edit`, never `owner`, so without `isAdmin` an admin could never USE the + * server gate they are allowed to enable). Deliberately NOT shaped like + * `nodeCanLaunchOn`: local's seeded Everyone grant confers launch, never SSH use, + * so there is no `granted` parameter here to widen it. Gated by the row's + * ssh_enabled (fail-closed) and, for `local`, the server-as-node switch. Answers + * yes/no only; naming WHY (gate off vs not owner vs maintenance vs no-launch) is + * the caller's copy, per spec section 12. */ +export function nodeCanSsh(opts: { + kind: NodeKind; + access: NodeAccess; + isAdmin: boolean; + serverAccountEnabled: boolean; + sshEnabled: boolean; +}): boolean { + if (!opts.sshEnabled) return false; + if (opts.kind === "local") { + return (opts.access === "owner" || opts.isAdmin) && opts.serverAccountEnabled; + } + return opts.access === "owner"; +} + /** * TRUE when ANY share level grants launch — deliberately NOT the subshell rule, * where launch sits at edit. Product decision (spec 2026-08-31 §2): a `view` From b6afb00b46e71a1f813abf5352a9644187fda950 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 01:53:35 -0700 Subject: [PATCH 017/170] feat(nodes): ssh_enabled plane write + view + service + PUT /:id/ssh-enabled route setNodeSshEnabled is the one change-site (row + audit, Task 7 layers the push); the route is gate-only. Owner-only management (canManage, admin on local), audit metadata is {on} alone. Node view carries sshEnabled on both list and detail. --- .claude/rules/security-context.md | 2 +- .../set-node-ssh-enabled.route.test.ts | 171 ++++++++++++++++++ apps/server/api/src/api/nodes/index.ts | 2 + apps/server/api/src/api/nodes/node-view.ts | 5 + .../api/nodes/set-node-ssh-enabled.route.ts | 82 +++++++++ .../src/db/repositories/nodes.repository.ts | 29 +++ .../api/src/services/nodes/ssh-enabled.ts | 32 ++++ docs/security.md | 3 +- 8 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts create mode 100644 apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts create mode 100644 apps/server/api/src/services/nodes/ssh-enabled.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 2e7f5236a..a037fd698 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts new file mode 100644 index 000000000..7c59fb915 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/ssh-enabled` (spec 4.3) — the per-node SSH capability + * gate, opt-in and off by default. + * + * OWNER-only (`gate.canManage`, with the seeded-`local` admin exception), the + * same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. The act is what decides it: + * flipping this on lets the plane dial out of and serve keys from an OS + * account this machine owns; a grantee may restart the agent or read its log, + * but widening what SSH can do from someone else's machine is not theirs. + * + * The route performs no write and records no audit itself — `setNodeSshEnabled` + * is the one change-site (row + audit, and from Task 7 the push), so this file + * asserts the ROUTE wired the acting human into that row. + */ +describe("node ssh-enabled route", () => { + const pw = "ssh-enabled-1"; + const ownerEmail = `sh-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `sh-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `sh-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `sh-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-sh-${crypto.randomUUID()}`; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + /** Every `node.ssh_enabled.update` row this file's node has collected, id-keyed. */ + async function sshAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.ssh_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string, on: boolean) { + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/ssh-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + body: JSON.stringify({ on }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found: every file in this + // package gets its own database, but a flipped host gate still reads as + // the fleet default if a later test in THIS file's process re-reads it. + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + const view = (await res.json()) as { sshEnabled: boolean }; + expect(view.sshEnabled).toBe(true); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + // The ROUTE is the only place `actorUserId: user.id` is wired in — the + // service takes it as an argument, so a regression to a null actor passes + // every test of the service alone. + await put(NODE, ownerCookie, false); + const before = await sshAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered, so a newest-first read + // cannot name this act's row. + const added = [...(await sshAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + // The metadata is the STRING `"{\"on\":true}"`: the flag and nothing else + // — no key material, no config values (spec 4.3; audit never carries secrets). + expect(added[0][1].metadataJson).toBe('{"on":true}'); + }); + + it("turning it off lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(false); + }); + + it("an edit grantee is refused — it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; enabling SSH through someone else's OS account is a different act. + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, true)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there — refused, not invisible (a node on the page must + // not answer "not found"). + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + // Unlike maintenance, this flip stops nothing, so ON is safe to test on + // the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index 177879980..8d51a6280 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -18,6 +18,7 @@ import { setNodeAllowedDirsRoute } from "@/api/nodes/set-node-allowed-dirs.route import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route.js"; import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; +import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; import { updateNodeRoute } from "@/api/nodes/update-node.route.js"; /** @@ -46,6 +47,7 @@ export const nodesRoutes = new Elysia({ prefix: "/api/nodes" }) .use(setNodeSharesRoute) .use(setNodeAllowedDirsRoute) .use(setNodeMaintenanceRoute) + .use(setNodeSshEnabledRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) .use(recheckNodeRoute) diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 0dfdac980..b199f2ad0 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -155,6 +155,10 @@ export const NodeViewSchema = t.Object({ "Which end declared the current value (a browser or the machine's own `subshell maintenance` verb), so a person reading the page learns whether someone at the keyboard did this; null when it was never set", }, ), + sshEnabled: t.Boolean({ + description: + "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", + }), held: t.Nullable( t.Object({ reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], { @@ -368,6 +372,7 @@ function nodeViewBase( maintenance: row.maintenance === 1, maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, + sshEnabled: row.sshEnabled === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, // and a column would go stale the moment the process ends. It is NOT diff --git a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts new file mode 100644 index 000000000..b056c529d --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts @@ -0,0 +1,82 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, ForbiddenError, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { setNodeSshEnabled } from "@/services/nodes/ssh-enabled.js"; + +const SshEnabledBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow this machine to be used for Subshell SSH (outbound ssh and serving its keys). Off by default. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/ssh-enabled` `{on}` — open or close Subshell SSH on one + * machine (spec 4.3). Cookie-only. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. Enabling SSH widens what + * can be done THROUGH this machine's OS account (dial out with its keys, + * serve them to peers); restarting the agent or reading its log is + * administration, deciding who may SSH from it is a different act. + * + * The route writes nothing and audits nothing itself: `setNodeSshEnabled` is + * the one change-site (row + audit, and from Task 7 the best-effort push to + * the machine), so no caller — including the node-side paths Task 7 adds — + * can perform half the act. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` (Task 7's + * reconcile). Refusing would make the flag settable only while the machine is + * reachable, which is backwards for a gate that exists to be closed. + */ +export const setNodeSshEnabledRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .put( + "/:id/ssh-enabled", + async ({ params, body, user, actor, status }) => { + requireCookieActor(actor, "Node SSH enablement is restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setNodeSshEnabled({ + nodeId: gate.row.id, + on: body.on, + changedAt: new Date().toISOString(), + actorUserId: user.id, + }); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored — including the stamp + // it chose — rather than what this handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: SshEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setNodeSshEnabled", + tags: ["nodes"], + description: + "Allow or refuse Subshell SSH on a node (owner only; admin on the control-plane host). Off by default; enabling lets the plane dial out of and serve keys from this machine.", + }, + }, + ); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 628365bdd..14045c278 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -226,6 +226,35 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the SSH capability flag and its stamp (spec 4.3). + * + * Same shape as {@link setMaintenance} minus the `source` column: both + * fields move together, and the CALLER supplies `changedAt` — the stamp is + * the reconciliation protocol between the plane's row and the node's mirror + * file (Task 7), so a relay must not re-stamp the fact it is carrying. + */ + async setSshEnabled( + id: string, + state: { + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** ISO 8601 of the write that produced `on` — never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + sshEnabled: state.on ? 1 : 0, + sshEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * The unfinished subshells on one node, as FULL rows. * diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts new file mode 100644 index 000000000..1f1451e92 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -0,0 +1,32 @@ +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; + +/** + * The node SSH capability flag (spec 4.3), written in exactly one place — the + * posture `maintenance.ts` established and for the same reason: two + * implementations of the change act is two implementations that drift, and + * the half that drifts is whichever one nobody exercised. Task 7 layers the + * node-side mirror (push + reconcile) into this same function. + */ + +export interface SshEnabledWrite { + nodeId: string; + on: boolean; + changedAt: string; + actorUserId: string; +} + +/** Flip one node's SSH capability: row, audit, and (from Task 7) the best-effort + * push - all in this one function, so no caller can perform half the act. */ +export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { + const { repos } = getRequestlessContext(); + await repos.nodes.setSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); + await audit({ + actorUserId: write.actorUserId, + action: "node.ssh_enabled.update", + targetType: "node", + targetId: write.nodeId, + metadataJson: JSON.stringify({ on: write.on }), + }); + // Task 7 appends here: void pushSetSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} diff --git a/docs/security.md b/docs/security.md index bcfdff107..95bfb308f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2276,7 +2276,8 @@ Audit events are written, grouped by family: records acts, and a pre-gate 403 is not one. - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, - `node.maintenance.update`, `node.logging.update`, `node.update`, + `node.maintenance.update`, `node.ssh_enabled.update`, + `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart verb shares its action with the rest of the service surface). From 68c26c0c850e03fd5a76150af27bd23ae117cbeb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:04:40 -0700 Subject: [PATCH 018/170] feat(nodes): ssh gate loop - node mirror file, set_ssh_enabled push, ready reporting (protocol 16) Completes the loop Task 6 opened: the plane is the sole author of nodes.ssh_enabled, and this is the machinery that reaches the machine. - node: ssh-enabled.ts mirror (0600 temp+rename like maintenance, but fail-CLOSED the other way: absent and unreadable both refuse, only a parsed on:true allows); set_ssh_enabled executor writes it verbatim and memoizes as reported; the ssh_enabled event carries a mirror that moved mid-session (corruption, chiefly); ready.sshEnabled rides the same synchronous seed + optional-spread as maintenance. - protocol 15 -> 16: NodeSshEnabledWire/parseNodeSshEnabled, the set_ssh_enabled arm, the ssh_enabled event, ready.sshEnabled (drop-not-fatal). Breaking on purpose: pre-16 agents are wire-indistinguishable from a 16 agent whose mirror says no, and a capability gate cannot live with that ambiguity (spec 4.3). - plane: pushSetSshEnabled (local is a no-op - the row is its mirror) wired into setNodeSshEnabled as the one-function write+audit+push; decideSshEnabled + reconcileSshEnabled never adopt - every disagreement, and every row-ON-with-silent-node, ends with the ROW pushed down, stamped verbatim, and no audit row (pushes are not acts). Tests: node 866/866 (new 17), plane services/nodes 694/694 (new 14 + 4 hook-door cases), protocol 364/364 (version pin + ssh arm cases). --- .../agent/src/__tests__/ssh-enabled.test.ts | 290 ++++++++++++++++++ apps/node/agent/src/commands/context.ts | 16 + apps/node/agent/src/commands/index.ts | 7 +- apps/node/agent/src/commands/report.ts | 62 +++- .../agent/src/commands/set-ssh-enabled.ts | 30 ++ apps/node/agent/src/daemon.ts | 27 +- apps/node/agent/src/ssh-enabled.ts | 174 +++++++++++ apps/server/api/src/index.ts | 4 + .../nodes/__tests__/node-events.test.ts | 2 + .../nodes/__tests__/node-ws-handler.test.ts | 83 +++++ .../nodes/__tests__/ssh-enabled.test.ts | 289 +++++++++++++++++ .../api/src/services/nodes/node-events.ts | 17 +- .../api/src/services/nodes/node-ws-handler.ts | 20 ++ .../api/src/services/nodes/ssh-enabled.ts | 140 ++++++++- .../src/__tests__/node-frames.test.ts | 64 +++- packages/subshell-protocol/src/index.ts | 2 + packages/subshell-protocol/src/node-frames.ts | 114 ++++++- 17 files changed, 1323 insertions(+), 18 deletions(-) create mode 100644 apps/node/agent/src/__tests__/ssh-enabled.test.ts create mode 100644 apps/node/agent/src/commands/set-ssh-enabled.ts create mode 100644 apps/node/agent/src/ssh-enabled.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts diff --git a/apps/node/agent/src/__tests__/ssh-enabled.test.ts b/apps/node/agent/src/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..2b164bf2b --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-enabled.test.ts @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { maybeReportSshEnabled, seedSshEnabledMemo } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { readSshEnabled, reportableSshEnabled, sshAllowed, sshEnabledPath, writeSshEnabled } from "../ssh-enabled.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { captureLogs } from "./helpers/capture-logs.js"; + +/** + * The SSH-capability mirror (spec 2026-10-07 §4.3): persistence, the + * file-mode doctrine borrowed from `maintenance.ts`, and the FAIL-CLOSED read + * — but with the default INVERTED. Maintenance refuses when it reads `on`; + * this refuses unless it reads `on`, because the plane is the only writer and + * "never told" is not "allowed" for a widening of egress and keys. + * + * The wiring half goes through `dispatchCommand`, so the frame parser and the + * switch are exercised too (the `commands-maintenance.test.ts` pattern). + */ + +const STAMP = "2026-10-07T10:00:00.000Z"; +const made: string[] = []; + +function freshDataDir(): string { + // realpath'd like the commands-maintenance harness: macOS hides /private + // behind the temp dir, and an assertion about the file path should test the + // path the daemon itself would write. + const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-ssh-on-"))); + made.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The mirror's own mtime, as the unreadable branch stamps it. */ +function mtimeOf(dataDir: string): string { + return statSync(sshEnabledPath(dataDir)).mtime.toISOString(); +} + +/** A context whose tmux THROWS on anything unstubbed (the maintenance harness). */ +function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext { + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const tmux = new Proxy( + {}, + { + get: () => () => { + throw new Error("tmux must not be reached"); + }, + }, + ); + return { + config, + tmux: tmux as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("persistence", () => { + it("an empty dir reads `absent`: nothing has said yes, so SSH is refused", () => { + expect(readSshEnabled(freshDataDir())).toEqual({ kind: "absent" }); + }); + + it("round-trips the exact stamp it was given", () => { + const dataDir = freshDataDir(); + expect(writeSshEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + }); + + it("stores the whole wire verbatim — `ready.sshEnabled` is buildable from what was written", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); + }); + + it("stores `on: false` as a value too, but the mirror's ANSWER for it is refusal", () => { + // The plane stamps the file when it turns SSH off, and the bytes stay on + // disk; what the reader answers is the gate's answer, and an off file and + // no file give the same one. There is no node-side writer, so the off + // stamp is not something anyone later reconciles against — the plane's + // own row is the record, and it agrees. + const dataDir = freshDataDir(); + const state = { on: false, changedAt: "2026-10-07T11:00:00.000Z" }; + writeSshEnabled(dataDir, state); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual(state); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("writes the file 0600, and creates a missing data dir rather than throwing", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(statSync(sshEnabledPath(dataDir)).mode & 0o777).toBe(0o600); + const nested = join(freshDataDir(), "nested", "data"); + writeSshEnabled(nested, { on: true, changedAt: STAMP }); + expect(exists(sshEnabledPath(nested))).toBe(true); + }); + + it("leaves no temp file behind, and the file it leaves is whole", () => { + // Atomic because a half-written file parses as corrupt, and corrupt means + // REFUSED here — a torn write would cut SSH for the duration of the write. + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + writeSshEnabled(dataDir, { on: false, changedAt: "2026-10-07T12:00:00.000Z" }); + expect(readdirSync(dataDir)).toEqual(["ssh-enabled.json"]); + }); + + function exists(file: string): boolean { + try { + statSync(file); + return true; + } catch { + return false; + } + } +}); + +describe("reading is fail-CLOSED", () => { + it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => { + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + } finally { + cap.restore(); + } + expect(cap.lines.filter((l) => l.includes("ssh-enabled"))).toHaveLength(1); + }); + + it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => { + // Same classification maintenance gives garbage, and the same reason: both + // halves or nothing. The brief's unreadable fixtures are content fixtures + // (a corrupt file) rather than chmod-000 — an EACCES read fails into the + // same branch, so the three spellings of "cannot read" must not answer + // differently. + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: true })); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP })); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + } finally { + cap.restore(); + } + }); +}); + +describe("the gate classifier", () => { + it("allows ONLY a parsed on:true mirror; absent and unreadable both refuse", () => { + // The whole point of the flag: a node that cannot read its own setting + // refuses — the opposite of the directory allowlist's deliberate fail-open + // (spec 2026-10-07 §4.3). + expect(sshAllowed({ kind: "on", changedAt: STAMP })).toBe(true); + expect(sshAllowed({ kind: "absent" })).toBe(false); + expect(sshAllowed({ kind: "unreadable", changedAt: STAMP })).toBe(false); + expect(sshAllowed({ kind: "unreadable" })).toBe(false); + }); +}); + +describe("what a read is worth announcing", () => { + it("announces `on` verbatim; an unreadable mirror as the REFUSAL it produces", () => { + // Maintenance reports an unreadable mirror as `on` because that is what + // it refuses with; here the refusal is spelled `on: false`. Either way + // the plane hears the answer the machine is actually acting on, and a row + // that disagrees earns the push that rewrites the broken file. + expect(reportableSshEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(reportableSshEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP }); + }); + + it("sends nothing for absent, and nothing an unstamped unreadable", () => { + expect(reportableSshEnabled({ kind: "absent" })).toBeUndefined(); + expect(reportableSshEnabled({ kind: "unreadable" })).toBeUndefined(); + }); +}); + +describe("set_ssh_enabled (the plane's half)", () => { + it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + const cmd = { type: "set_ssh_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody; + expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + expect(events).toEqual([]); // the command answers with a result; it announces nothing + }); + + it("an off flip also lands verbatim, and the machine says nothing about it", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + await dispatchCommand(ctx, { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + }); +}); + +describe("the heartbeat belt", () => { + it("reports a mirror that moved outside the plane's own push, once", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved + + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + + expect(events).toEqual([{ type: "ssh_enabled", on: true, changedAt: STAMP }]); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + }); + + it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => { + // The repair is the plane's push (its row wins); a node overwriting the + // corrupt file would destroy the bytes an operator may want to look at, + // and inventing an `off` here would mask the disagreement the report + // exists to raise. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const stamp = mtimeOf(dataDir); + const ctx = makeCtx(dataDir, events); + const cap = captureLogs(); + try { + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([{ type: "ssh_enabled", on: false, changedAt: stamp }]); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + }); + + it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => { + // Maintenance re-writes a vanished mirror from its memo because a CLI at + // the keyboard can delete it legitimately. There is no such writer here: + // hand-deleting this file is not an interface, and the next `ready` makes + // the plane push the row value back. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + seedSshEnabledMemo(ctx); + unlinkSync(sshEnabledPath(dataDir)); + + maybeReportSshEnabled(ctx); + + expect(events).toEqual([]); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("seed returns the value ready carries and silences the first heartbeat for it", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(seedSshEnabledMemo(ctx)).toEqual({ on: true, changedAt: STAMP }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + expect(seedSshEnabledMemo(makeCtx(freshDataDir()))).toBeUndefined(); + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 201aa481f..a3de26868 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -5,6 +5,7 @@ import type { NodeEvent, NodeMaintenanceWire, NodeRuntimeReport, + NodeSshEnabledWire, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; import type { ServiceDeps } from "../service.js"; @@ -155,6 +156,21 @@ export interface CommandContext { * from echoing the plane's own write back at it one tick later. */ lastReportedMaintenance?: NodeMaintenanceWire; + /** + * The `{ on, changedAt }` this connection last told the plane about its SSH + * capability, or undefined when it has said nothing (spec 2026-10-07 §4.3). + * + * The same echo-suppression the maintenance memo exists for — but with a + * simpler job here, because the plane is this flag's ONLY writer: the memo + * exists so the heartbeat belt reports a mirror that moved by accident + * (corruption, mostly) and never the plane's own push come back one tick + * later. Seeded at every connect from the same read that built `ready`. + * + * There is deliberately no `mirrorRestoreLogged` partner: SSH has no + * restore-from-memo path (no node-side writer whose deletion deserves + * repair), so there is no failing restore to say anything about. + */ + lastReportedSshEnabled?: NodeSshEnabledWire; /** * Whether this connection has already reported a mirror it could not * rewrite (see `restoreMirrorFromMemo`). diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 8ff435dca..447fde0ae 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -27,6 +27,7 @@ import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; +import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -47,7 +48,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * the one command whose wire shape is frozen, because the plane sends it to * agents whose protocol it does not share), and the five archive-transfer * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` - * and `tree_manifest` (spec 2026-10-01 §4, protocol 15). Any + * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and + * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- + * to-machine write). Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -122,6 +125,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetLogLevel(ctx, cmd); case "set_maintenance": return execSetMaintenance(ctx, cmd); + case "set_ssh_enabled": + return execSetSshEnabled(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index a681c4429..0b8c37d14 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,6 +1,7 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; +import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; import type { CommandContext, WatcherRegistration } from "./context.js"; /** @@ -145,6 +146,65 @@ export function seedMaintenanceMemo(ctx: CommandContext): NodeMaintenanceWire | return ctx.lastReportedMaintenance; } +/** + * Announce one SSH-capability mirror state to the plane and remember having + * done so (spec 2026-10-07 §4.3). + * + * The memo update is not bookkeeping: it is what keeps the next heartbeat tick + * silent about a value the plane already holds — and what keeps a + * `set_ssh_enabled` the plane just pushed from coming back as an event. + * + * @param ctx - the daemon's command context (owns the socket seam and the memo) + * @param state - the mirror's answer, sent verbatim (never re-stamped here) + */ +export function reportSshEnabled(ctx: CommandContext, state: NodeSshEnabledWire): void { + ctx.ws.send({ type: "ssh_enabled", on: state.on, changedAt: state.changedAt }); + ctx.lastReportedSshEnabled = state; +} + +/** + * Report the SSH mirror IF it has moved since this connection last spoke + * about it — the heartbeat belt, and by far the quieter one. + * + * With no node-side writer, the common steady state says nothing: an + * off-or-absent mirror has nothing to announce (the plane's own row is + * authoritative, and silence reads as refusal on both ends), and an on mirror + * only speaks up once. What this loop exists to catch is the file changing + * UNDER the connection — corruption flipping a machine from allowed to + * refused mid-session — so the plane learns it now and pushes its row (the + * repair) rather than discovering the disagreement at the next reconnect. + * + * There is no absent-restore half (the maintenance path above has one for its + * keyboard writer): hand-deleting this file is not an interface, and the next + * `ready` reconciles it in the plane's direction. + * + * @param ctx - the daemon's command context + */ +export function maybeReportSshEnabled(ctx: CommandContext): void { + const state = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + if (!state) return; + const last = ctx.lastReportedSshEnabled; + if (last?.on === state.on && last.changedAt === state.changedAt) return; + reportSshEnabled(ctx, state); +} + +/** + * Seed this connection's SSH memo from the mirror on disk. + * + * Called once per connect, immediately before `ready` — which carries the same + * value, so the first heartbeat must not repeat it — and again on every + * reconnect, because a new socket has told the plane nothing. The one read of + * the file builds both, so the frame and the memo cannot disagree. + * + * @param ctx - the daemon's command context + * @returns the value `ready` should carry, or undefined when there is nothing + * truthful to report + */ +export function seedSshEnabledMemo(ctx: CommandContext): NodeSshEnabledWire | undefined { + ctx.lastReportedSshEnabled = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + return ctx.lastReportedSshEnabled; +} + /** Stop the shared tick if one is running (idempotent). */ function stopLoop(ctx: CommandContext): void { if (ctx.watchTick !== undefined) { diff --git a/apps/node/agent/src/commands/set-ssh-enabled.ts b/apps/node/agent/src/commands/set-ssh-enabled.ts new file mode 100644 index 000000000..4e3e75553 --- /dev/null +++ b/apps/node/agent/src/commands/set-ssh-enabled.ts @@ -0,0 +1,30 @@ +import { writeSshEnabled } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * `set_ssh_enabled` (spec 2026-10-07 §4.3): the plane's half of the SSH gate. + * + * It writes the file and NOTHING else. In particular it kills no panes and + * revokes nothing: the gate is checked when an SSH act is asked for, so + * turning it off takes effect on the next one, and turning it on grants no + * pane anything that was not already reachable by other means. + * + * The plane's own `changedAt` is stored verbatim, so both copies end + * byte-identical; re-stamping would leave the next `ready` reporting a + * disagreement the link latency invented. + * + * A failing write propagates: `dispatchCommand` turns the throw into + * `{ ok: false }`, and the plane learns its push did not land rather than + * recording a mirror that does not exist. + * + * @param ctx - the per-daemon execution context (data dir + the report memo) + * @param cmd - the verified `set_ssh_enabled` command + * @returns `{ ok: true }` once the file is on disk + */ +export function execSetSshEnabled(ctx: CommandContext, cmd: Cmd<"set_ssh_enabled">): CommandResult { + // Memoized as REPORTED, not merely written: the plane is the source of this + // value — the ONLY source it will ever have — so sending it back on the next + // heartbeat tick would be an echo the plane then reconciles against itself. + ctx.lastReportedSshEnabled = writeSshEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); + return { ok: true }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 3cc3beb5d..bfd1cfc11 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -10,13 +10,20 @@ import { type NodeEvent, type NodeMaintenanceWire, type NodeRuntimeReport, + type NodeSshEnabledWire, SeqTracker, verifyCommand, } from "@internal/subshell-protocol"; import { backoffDelay } from "./backoff.js"; import type { CommandContext, CommandResult, CommandWs } from "./commands/context.js"; import { dispatchCommand } from "./commands/index.js"; -import { buildSubshellsReport, maybeReportMaintenance, seedMaintenanceMemo } from "./commands/report.js"; +import { + buildSubshellsReport, + maybeReportMaintenance, + maybeReportSshEnabled, + seedMaintenanceMemo, + seedSshEnabledMemo, +} from "./commands/report.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -334,6 +341,7 @@ function readyEvent( config: NodeConfig, runtime: NodeRuntimeReport | null, maintenance: NodeMaintenanceWire | undefined, + sshEnabled: NodeSshEnabledWire | undefined, ): Extract { return { type: "ready", @@ -380,6 +388,13 @@ function readyEvent( // every launch here, and a plane told nothing about that has no row to // reconcile and no reason to push the clean file that repairs it. ...(maintenance ? { maintenance } : {}), + // This machine's SSH-capability mirror (spec 2026-10-07 §4.3), carried + // exactly as maintenance is: OMITTED when absent (an off file or no file — + // silence means REFUSED here, and a row that says on answers it with the + // push), and an UNREADABLE file carried as the refusal it acts on under + // its own mtime, so the plane can repair the file instead of holding a + // silent disagreement until the next reconnect. + ...(sshEnabled ? { sshEnabled } : {}), }; } @@ -983,7 +998,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The mirror is read SYNCHRONOUSLY too, and by the seeder itself: the // value `ready` carries and the memo that stops the first heartbeat // repeating it are one read, so they cannot disagree. - send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx))); + // The SSH mirror rides the same synchronous read + seed as the + // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and + // the memo that stops the first heartbeat repeating it are one read. + send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1025,6 +1043,11 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // when the machine had no panes running — the commonest case for // `subshell maintenance off`, where there is nothing to die at all. maybeReportMaintenance(ctx); + // The SSH gate's belt (spec 2026-10-07 §4.3): quieter by construction + // (no writer but the plane, so the steady state announces nothing), + // and this tick is its ONLY mid-session path — there are no deaths + // for it to ride ahead of, because an SSH flip kills no panes. + maybeReportSshEnabled(ctx); }, heartbeatMs); // Periodic `inventory` push — the "every 5 min" leg of spec §7 // (P3-T8c). The timer's lifecycle mirrors the heartbeat's — armed diff --git a/apps/node/agent/src/ssh-enabled.ts b/apps/node/agent/src/ssh-enabled.ts new file mode 100644 index 000000000..84f825596 --- /dev/null +++ b/apps/node/agent/src/ssh-enabled.ts @@ -0,0 +1,174 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type NodeSshEnabledWire, parseNodeSshEnabled } from "@internal/subshell-protocol"; +import { logger } from "./log.js"; + +/** + * This node's MIRROR of the SSH capability flag (spec 2026-10-07 §4.3): one + * word meaning "this machine may take part in Subshell SSH at all". + * + * It is the maintenance flag's twin in mechanics and its inversion in default. + * Same file-permission doctrine (0600, temp+rename, its own file beside + * `maintenance.json` and for the same two reasons — `config.json` is + * snapshotted at boot and it is the node key's only home). Same fail-CLOSED + * read: here the risk is what ENABLING lets Subshell do (egress over SSH, this + * machine's keys and agent), so a setting this process cannot read is not a + * permission. Absent refuses too — that is the inversion: maintenance's + * absent meant "behave as before" and launches flowed; SSH has no before. + * + * **The plane is the sole writer.** There is no `subshell ssh-enabled` verb, + * because turning a machine's egress on from a keyboard at that machine is + * exactly the decision this gate exists to make someone make in a browser, as + * the owner, with an audit row. So the mirror has only two authors — a + * `set_ssh_enabled` push and a repair — and its only reader is the SSH gate + * (this process, fail-closed) and the `ready` report that lets the plane + * notice when the two copies disagree. + * + * Because no one stamps this file locally, nothing compares stamps across the + * two ends: reconciliation never runs newer-wins here, the plane pushes its + * row, and the node's copy converges by obeying rather than by bargaining. + */ + +/** File name inside the agent data dir. */ +const FILE = "ssh-enabled.json"; + +/** Absolute path of the SSH-capability mirror for a data dir. */ +export function sshEnabledPath(dataDir: string): string { + return join(dataDir, FILE); +} + +/** + * What the mirror says, as the three answers a caller must tell apart — the + * gate's answers, not the file's biography. + * + * `on` is the only kind that permits, and it carries the stamp because + * `ready.sshEnabled` must be buildable from what was written. A file that + * parses to `{ on: false, changedAt }` and a file that does not exist give the + * SAME answer to every reader (refuse; and the plane's row already says off, + * so there is nothing to report), and no node-side writer exists whose stamp + * anyone would later reconcile — so the off file reads as `absent` while its + * bytes stay on disk. `unreadable` refuses too, but unlike the other two it + * says so: the answer it produces may match a quiet row, and when the row says + * on it is a disagreement the plane can only repair if it hears about it. + */ +export type SshEnabledRead = + /** Nothing permits here: no file, or a file that says off. */ + | { kind: "absent" } + /** The mirror parsed and says yes: SSH may run here. */ + | { + kind: "on"; + /** The stored stamp, verbatim — `ready.sshEnabled` is built from it. */ + changedAt: string; + } + /** The file exists but is not a state: REFUSED (fail-closed), reported under the file's mtime. */ + | { + kind: "unreadable"; + /** The file's mtime, ISO — absent only when the stat failed too, leaving nothing truthful to send. */ + changedAt?: string; + }; + +/** + * The gate's own answer, spelled once: ONLY a parsed `on` permits. + * + * Exported so the classifier the spec names ("absent and unreadable refuse, + * only on:true allows") lives in one place for every reader — the SSH launch + * path and any later status verb — rather than as `kind === "on"` typed out at + * each call site and drifting into an `|| absent` someday. + */ +export function sshAllowed(read: SshEnabledRead): boolean { + return read.kind === "on"; +} + +/** + * Reads the mirror. + * + * Total — every failure answers `unreadable` (one warn line) rather than + * throwing, because every caller is on a path where a throw would cost more + * than the wrong answer: the SSH gate, a heartbeat tick, and the `ready` + * builder. + * + * The parse is the WIRE parser, not a private one: the file holds exactly what + * travels, and a second validator here would be a second opinion about what + * `{ on, changedAt }` means. + */ +export function readSshEnabled(dataDir: string): SshEnabledRead { + const file = sshEnabledPath(dataDir); + if (!existsSync(file)) return { kind: "absent" }; + try { + const state = parseNodeSshEnabled(JSON.parse(readFileSync(file, "utf8"))); + if (!state) throw new SyntaxError("not an ssh-enabled state"); + return state.on ? { kind: "on", changedAt: state.changedAt } : { kind: "absent" }; + } catch (err) { + logger.withError(err).warn(`ssh-enabled: ${file} unreadable; treating this node as SSH-DISABLED`); + // The mtime, never `now`: this answer refuses, so the plane has to hear + // about it (a row that says on would push the clean file that repairs it), + // and the one timestamp available that nobody invented is when the file + // last changed. A fresh `now` per read would defeat the report memo and + // put one frame on the wire per heartbeat, forever. A stat that fails in + // turn leaves the stamp off: the refusal stands, and there is nothing to + // send. + const changedAt = mtimeOf(file); + return changedAt ? { kind: "unreadable", changedAt } : { kind: "unreadable" }; + } +} + +/** + * A file's mtime as an ISO stamp. TOTAL — every caller is on a path where the + * answer is optional and a throw would cost the whole read. + */ +function mtimeOf(file: string): string | undefined { + try { + return statSync(file).mtime.toISOString(); + } catch { + return undefined; + } +} + +/** + * The wire value a read is worth announcing, or `undefined` when there is + * nothing truthful to say. + * + * Every reporting path goes through this so the answers cannot be classified + * differently in three places: `ready`, the heartbeat memo check and (from + * Plan 2) the SSH gate all owe the plane the same value for the same file. An + * unreadable mirror announces the REFUSAL it actually produces — the inverse + * of maintenance's unreadable, which announces the `on` IT produces. + * + * @param read - what {@link readSshEnabled} answered + * @returns the `{ on, changedAt }` to send, or `undefined` for absent (the + * plane's row needs no echo from a node agreeing off) and for an unstamped + * unreadable + */ +export function reportableSshEnabled(read: SshEnabledRead): NodeSshEnabledWire | undefined { + if (read.kind === "on") return { on: true, changedAt: read.changedAt }; + if (read.kind === "unreadable" && read.changedAt) return { on: false, changedAt: read.changedAt }; + return undefined; +} + +/** + * Persists the mirror, 0600, via temp + rename. + * + * Atomic for the maintenance reason and sharper: a gate may read this at any + * instant, and a half-written file parses as corrupt — which under the + * fail-closed rule REFUSES every SSH act for as long as the write takes, + * mid-connect rather than merely wide of a flag. + * + * The `state` is stored VERBATIM. The plane sent it; re-stamping here would + * make the two copies differ by exactly the link delay, and the next `ready` + * would report a disagreement this process invented — which the plane would + * "repair" by pushing the same value again, forever. + * + * @returns the state as stored + */ +export function writeSshEnabled(dataDir: string, state: NodeSshEnabledWire): NodeSshEnabledWire { + const file = sshEnabledPath(dataDir); + const body: NodeSshEnabledWire = { on: state.on, changedAt: state.changedAt }; + // 0700, matching the data dir `identity.ts` creates. mkdir's mode is + // umask-clamped, so it is a floor; the file's own 0600 below is set at + // creation and is the one that matters. + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, file); + return body; +} diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 6c12bc0f0..ec5c826ba 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -50,6 +50,7 @@ import { startServer } from "@/server.js"; import { loadAndApplyDebugLogging } from "@/services/logging-preference.js"; import { startInventoryRefresh } from "@/services/nodes/inventory-refresh.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; +import { reconcileSshEnabled } from "@/services/nodes/ssh-enabled.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; @@ -323,6 +324,9 @@ async function bootServer(): Promise { // not route through the manager: the hook hands the machine's own copy to // the one module that reconciles the two (spec 2026-09-14 §5.2). onMaintenance: (nodeId, reported) => reconcileMaintenance(nodeId, reported), + // The SSH mirror's sibling (spec 2026-10-07 §4.3): same door, opposite + // direction — the row is the record, so this reconcile only pushes. + onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). diff --git a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts index 88804099a..2e901edef 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts @@ -255,6 +255,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { }, onSubshellsReport: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -280,6 +281,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { setNodeLifecycleHooks({ onExit: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index 4a7e2eaaa..ff41e1bfa 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -694,6 +694,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -750,6 +751,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -813,6 +815,87 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The SSH mirror (spec 2026-10-07 §4.3) walks the same two frames into the + * same hook as maintenance — one reconciler for connect-time state and a + * mid-session report — and the tests below pin that the DIRECTION is not + * shared: what the hook does with what it hears lives in + * `ssh-enabled.test.ts`, where "never adopt" is asserted. Here it is only + * the door: both carriers, the socket's nodeId, the lenient field. + */ + describe("ssh_enabled → the lifecycle hook (spec 2026-10-07 §4.3)", () => { + /** Install a recording SSH hook and return both the log and the uninstaller. */ + function recordSshEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING sshEnabled reconciles it", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ sshEnabled: { on: true, changedAt: "2026-10-07T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-10-07T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles — silence is the fail-closed answer, not a skipped beat", async () => { + // A node with no mirror file reads REFUSED, which is precisely when the + // plane's row (say, ON from a flip during the outage) must push. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the ssh_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-10-07T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose sshEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient exactly like `maintenance`: the plane learns the drop as + // silence, and silence reconciles toward the row. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame({ sshEnabled: { on: "yes" } }))); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..fe6c66e7a --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts @@ -0,0 +1,289 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + decideSshEnabled, + pushSetSshEnabled, + reconcileSshEnabled, + setNodeSshEnabled, +} from "@/services/nodes/ssh-enabled.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; + +/** + * The SSH gate's plane half (spec 2026-10-07 §4.3): the decide table — pure, + * no DB, no socket (the `maintenance-decide.test.ts` pattern) — and the hook + * body with one real row and one scripted node (the `maintenance-apply.test.ts` + * pattern, and its seam: no RPC mocks, the push travels the real + * `sendCommand` chain and lands in the scripted wire, which is what proves the + * `set_ssh_enabled` frame parses against the shipped parser). + * + * The property this suite exists to pin is the DIRECTION: the plane is the + * sole writer, so a reconcile NEVER adopts. Every disagreement ends with the + * row's value pushed down and the row itself unmoved. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `ssh-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket — every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decideSshEnabled — the plane is authoritative, so stamps are never read", () => { + const row = (sshEnabled: 0 | 1, sshEnabledAt: string | null) => ({ sshEnabled, sshEnabledAt }); + + it("agreement is a noop — reported matches the row, either value", () => { + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight — maintenance's tie-breaker has no job when nothing writes from + // the node. + expect( + decideSshEnabled(row(1, "2026-10-07T12:00:00.000Z"), { on: true, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { + // An absent mirror reads REFUSED on the machine, so a never-written or + // off row and a silent node already answer alike; the row saying ON faces + // a machine that refuses, and the row wins — that push is the repair. + expect(decideSshEnabled(row(0, null), undefined)).toBe("noop"); + expect(decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), undefined)).toBe("push-plane"); + }); + + it("disagreement pushes the row, in both directions — and never adopts, not even a NEWER report", () => { + // maintenance would adopt a newer node stamp; there is no node stamp here + // that anyone wrote, so even a future-dated report is just bytes the row + // overwrites. + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-plane"); + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("push-plane"); + }); + + it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { + // Row never written + a node mirror saying ON (data dir carried across + // from another plane, say): the row IS off, every plane-side gate reads + // the row, so the disagreement is inert — and a push would have to mint a + // `changedAt` for a write that never happened. + expect(decideSshEnabled(row(0, null), { on: true, changedAt: "2026-10-07T09:00:00.000Z" })).toBe("noop"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setNodeSshEnabled — write, audit, push, one function", () => { + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + try { + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(1); + expect(row?.sshEnabledAt).toBe(changedAt); + await settle(); + // Exactly one audit row — the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: true }); + // The push is the row's OWN bytes — the wire frame the shipped parser + // accepted, not a re-stamped copy. + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([{ type: "set_ssh_enabled", on: true, changedAt }]); + } finally { + scripted.detach(); + } + }); + + it("an OFF flip lands too — the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + try { + await setNodeSshEnabled({ nodeId, on: false, changedAt: "2026-10-07T11:00:00.000Z", actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T11:00:00.000Z"); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node — the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect( + setNodeSshEnabled({ nodeId, on: true, changedAt: "2026-10-07T12:00:00.000Z", actorUserId: ownerId }), + ).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.sshEnabled).toBe(1); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcileSshEnabled — decide, push the row, write nothing", () => { + it("agreement pushes NOTHING — the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcileSshEnabled(nodeId, { on: true, changedAt }); + await settle(); + expect(scripted.countOf("set_ssh_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The machine reports REFUSED (its mirror corrupted, say) under an ON + // row: the answer is the row pushed down — never the report pulled up. + await reconcileSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_ssh_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ type: "set_ssh_enabled", on: true, changedAt }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.sshEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row ON + node silent → push; and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's value is + // re-armed by the push (the maintenance-decide table's same cell). + await reconcileSshEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: true, changedAt }, + { type: "set_ssh_enabled", on: true, changedAt }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a future-dated report does not outrank anything — there is nothing to outrank", async () => { + // The maintenance suite's clamp test has no SSH twin because SSH has + // nothing to clamp: an adopted stamp is the failure mode clamps guard + // against, and SSH never adopts one to begin with. + const nodeId = await mkNode(); + await nodes.setSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T10:00:00.000Z" }); + await reconcileSshEnabled(nodeId, { on: true, changedAt: "2099-01-01T00:00:00.000Z" }); + await settle(); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T10:00:00.000Z"); + expect(await auditFor(nodeId)).toHaveLength(0); // reconcile records nothing, ever + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect(reconcileSshEnabled("not-a-node", { on: true, changedAt: "2026-10-07T10:00:00.000Z" })).resolves.toBe( + undefined, + ); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetSshEnabled is a NO-OP for local — resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE — its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setNodeSshEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: true, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(1); + // Restore whatever the shared test DB had, so no later suite inherits a + // server account with SSH open (spec §4.3: local ships OFF). + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: false, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(0); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index dae255544..45aae4d33 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -1,4 +1,4 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; /** * The backend event plane for `/ws/node` (spec 2026-08-31 §3.3, phase 2). @@ -97,6 +97,21 @@ export interface NodeLifecycleHooks { * (no file there, or a malformed `ready` field the lenient parser dropped) */ onMaintenance(nodeId: string, reported: NodeMaintenanceWire | undefined): Promise | void; + /** + * The machine's own copy of its SSH capability (spec 2026-10-07 §4.3) — + * from `ready.sshEnabled` at connect, or from the `ssh_enabled` event when + * the mirror moved underneath the connection (in practice: corruption). + * + * The same hook-not-repository shape as {@link onMaintenance}, and the same + * one reconciler for both frames; what differs is the DIRECTION — the plane + * is this flag's only writer, so reconciliation never adopts, it pushes. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none (no file, a file that says off, or a malformed `ready` + * field the lenient parser dropped — all of which mean REFUSED there) + */ + onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 45da89174..0c4b31dc9 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -973,6 +973,13 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const hooks = getNodeLifecycleHooks(); if (hooks) await hooks.onMaintenance(nodeId, event.maintenance); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile maintenance`); + // The SSH mirror travels the same door (spec 2026-10-07 §4.3) with the + // simpler decision behind it: the row is the record and never adopts, + // so the hook's whole cost is one read and at most one fire-and-forget + // push of the row's own value. Awaited only because it is trivially + // cheap; everything slow lives in the push's own best-effort swallow. + if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); return; } case "heartbeat": @@ -1031,6 +1038,19 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: maintenance from ${nodeId} with no lifecycle hook`); return; } + case "ssh_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's SSH mirror moved mid-session. With no node-side writer + // there is no keyboard flip to carry — what arrives here is a file that + // changed by other means (corruption, chiefly: it flips the node to + // REFUSED silently, and this frame is how the plane learns to push the + // repair now rather than at the next reconnect). Same hook as `ready`: + // a mid-session report and a connect-time state are the same + // disagreement, and one reconciler keeps them answering alike. + if (hooks) await hooks.onSshEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: ssh_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts index 1f1451e92..129474390 100644 --- a/apps/server/api/src/services/nodes/ssh-enabled.ts +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -1,12 +1,24 @@ +import type { NodeSshEnabledWire } from "@internal/subshell-protocol"; +import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; import { audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; /** - * The node SSH capability flag (spec 4.3), written in exactly one place — the - * posture `maintenance.ts` established and for the same reason: two - * implementations of the change act is two implementations that drift, and - * the half that drifts is whichever one nobody exercised. Task 7 layers the - * node-side mirror (push + reconcile) into this same function. + * The node SSH capability flag (spec 2026-10-07 §4.3), written in exactly one + * place — the posture `maintenance.ts` established and for the same reason: + * two implementations of the change act is two implementations that drift, + * and the half that drifts is whichever one nobody exercised. + * + * **The differences from maintenance are all in one fact: the plane is the + * SOLE writer.** There is no `subshell ssh-enabled` verb, so there is no + * second stamp to race — reconciliation never compares dates, never adopts, + * and answers every disagreement the same way: push the row. That is also + * why {@link SshEnabledWrite} has no `source` field and its `actorUserId` + * cannot be null: the only caller that reaches the row is a human's request, + * and the reconcile path performs no write at all. */ export interface SshEnabledWrite { @@ -16,8 +28,8 @@ export interface SshEnabledWrite { actorUserId: string; } -/** Flip one node's SSH capability: row, audit, and (from Task 7) the best-effort - * push - all in this one function, so no caller can perform half the act. */ +/** Flip one node's SSH capability: row, audit, and the best-effort push — + * all in this one function, so no caller can perform half the act. */ export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { const { repos } = getRequestlessContext(); await repos.nodes.setSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); @@ -28,5 +40,117 @@ export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { targetId: write.nodeId, metadataJson: JSON.stringify({ on: write.on }), }); - // Task 7 appends here: void pushSetSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to — + // and a push that fails (the node is offline) costs nothing: the value + // re-lands at the node's next `ready` through {@link reconcileSshEnabled}. + pushSetSshEnabledBestEffort(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} + +/** The node-row fields {@link decideSshEnabled} reads — nothing else is relevant. */ +type SshRow = Pick; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type SshDecision = "push-plane" | "noop"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE — it reads two values and answers; the caller performs. + * + * The table, and why each row is what it is (spec §4.3 — no node writer, so + * this is maintenance's `decideMaintenance` with the stamp comparison struck + * out; there is no "node wrote newer" case to price): + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | off / never written | `noop` — silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane` — the node has no file (or a lost one); give it ours | + * | the row's value | — | `noop` — agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane` — the row is the record; the machine is told, never asked | + * | the other value | never written | `noop` — nothing to send (no stamp exists), and the disagreement is INERT: the plane gates every SSH act on the row, so a stale mirror-on permits nothing the row permits | + * + * Stamps are never compared: with no node-side writer, a differing + * `changedAt` under an agreeing `on` is just a value relayed through the + * link — pushing on it would be a command per heartbeat for a difference that + * is only latency. And NOTHING adopts: an old mirror, a corrupted mirror + * reporting the refusal it produces, a future one — none of them move the + * row. That is the direction this whole task exists to pin. + * + * @param row - the plane's node row (the two SSH fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported nothing (no file, a file that says off, or a malformed + * `ready` field the lenient parser dropped) + */ +export function decideSshEnabled(row: SshRow, reported: NodeSshEnabledWire | undefined): SshDecision { + const rowOn = row.sshEnabled === 1; + // Silence reads as OFF — the node's own fail-closed default — which is the + // only reading under which "node lost its file while the row says off" is + // the quiet agreement it is. + if ((reported?.on ?? false) === rowOn) return "noop"; + // The one disagreement with nothing to send (rows: never-written row, node + // mirror on). `decideMaintenance` had an `adopt-node` for this cell; here + // the row is the record and an unstamped push would invent a write time. + if (!row.sshEnabledAt) return "noop"; + return "push-plane"; +} + +/** + * Send one node the SSH state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no + * mirror file — its answer to the gate is the DB row itself, which is exactly + * why the server account's SSH runs check the row and nothing else. + * + * @param nodeId - the node to tell; `local` is a no-op + * @param state - the STORED value, relayed verbatim; never re-stamped here, + * or the relay would outrank the decision it is carrying + */ +export async function pushSetSshEnabled(nodeId: string, state: NodeSshEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_ssh_enabled", on: state.on, changedAt: state.changedAt }); +} + +/** + * {@link pushSetSshEnabled}, fire-and-forget with the failure logged — the + * shape `pushSetMaintenanceBestEffort` established, for the same reasons. + * + * An offline node simply misses it: the flag is already on the plane's row, + * which is what refuses every SSH act plane-side, and the node re-learns the + * value on its next `ready` through {@link reconcileSshEnabled}. There is + * nothing a caller could usefully await or retry. + */ +export function pushSetSshEnabledBestEffort(nodeId: string, state: NodeSshEnabledWire): void { + void pushSetSshEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`ssh-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row — the body of the + * `onSshEnabled` lifecycle hook, fed by `ready.sshEnabled` and by the + * `ssh_enabled` event a machine sends when its mirror moved underneath it. + * + * The whole hook is one read and at most one fire-and-forget push: there is + * nothing to stop (an SSH flip kills no panes), nothing to write (the row + * already stands), and nothing to await (a slow node must not hold the + * socket's frame queue on a flag the row is already enforcing). A row that + * disagrees with the machine pushes; a machine that disagrees with nothing + * says nothing further. Never the other direction — that rule is the point. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none + */ +export async function reconcileSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const stamp = row.sshEnabledAt; + if (decideSshEnabled(row, reported) !== "push-plane" || stamp === null) return; + // (`decideSshEnabled` only answers "push-plane" when a stamp exists; the + // null check is what shows the type what the decide already proved.) + pushSetSshEnabledBestEffort(nodeId, { on: row.sshEnabled === 1, changedAt: stamp }); } diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 6894a0f12..474a10261 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -310,7 +310,14 @@ describe("parseNodeCommandBody", () => { // 15 is the archive-transfer surface (spec 2026-10-01 §2): five commands // (archive_create, file_read, transfer_write, archive_extract, // tree_manifest) plus the window/entry constants both ends must agree on. - expect(NODE_PROTOCOL_VERSION).toBe(15); + // 16 is the node SSH capability gate (spec 2026-10-07 §4.3): one + // owner-controlled flag per machine, off by default and fail-closed — + // `set_ssh_enabled` carries the plane's value, `ready.sshEnabled` states + // the node's mirror, the `ssh_enabled` event reports a moved file. The + // bump is the load-bearing part: a pre-16 agent is wire-indistinguishable + // from a 16 agent whose mirror says no, and a capability gate cannot live + // with that ambiguity. + expect(NODE_PROTOCOL_VERSION).toBe(16); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -844,6 +851,61 @@ describe("maintenance (arrived at protocol 8)", () => { }); }); +// The maintenance frames' sibling set (spec 2026-10-07 §4.3): same three +// carriers, one writer (the plane) instead of two, and the fail-closed +// default inverted — an absent mirror refuses. +describe("ssh gate (arrived at protocol 16)", () => { + const base = { + type: "ready", + agentVersion: MIN_NODE_VERSION, + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const state = { on: true, changedAt: "2026-10-07T10:00:00.000Z" }; + + it("carries the mirror on ready, and drops a malformed one without refusing the ready", () => { + // Lenient exactly like `maintenance`: the rest of the frame brings the + // node online, and the plane's answer to silence is its own row — a row + // that says on pushes the value down, so a dropped field strands nobody. + expect(parseNodeEvent({ ...base, sshEnabled: state })).toMatchObject({ type: "ready", sshEnabled: state }); + expect(parseNodeEvent(base)).toMatchObject({ type: "ready" }); + const bad = parseNodeEvent({ ...base, sshEnabled: { on: "yes" } }); + expect(bad?.type).toBe("ready"); + expect(bad && "sshEnabled" in bad ? bad.sshEnabled : undefined).toBeUndefined(); + }); + + it("parses the standalone event strictly — it is the mid-session carrier", () => { + expect(parseNodeEvent({ type: "ssh_enabled", ...state })).toEqual({ type: "ssh_enabled", ...state }); + expect(parseNodeEvent({ type: "ssh_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "ssh_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeEvent({ type: "ssh_enabled", on: true })).toBeNull(); + expect(parseNodeEvent({ type: "ssh_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeEvent({ type: "ssh_enabled", on: "yes", changedAt: state.changedAt })).toBeNull(); + }); + + it("parses the set_ssh_enabled command and refuses a partial one", () => { + expect(parseNodeCommandBody({ type: "set_ssh_enabled", ...state })).toEqual({ + type: "set_ssh_enabled", + ...state, + }); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "set_ssh_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: true })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: 1, changedAt: state.changedAt })).toBeNull(); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 0496c65e2..8a2002a56 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -103,12 +103,14 @@ export { type NodeMaintenanceWire, type NodeRuntimeReport, type NodeServiceVerb, + type NodeSshEnabledWire, type PluginReportWire, type PresetDefinitionWire, parseNodeCommandBody, parseNodeEvent, parseNodeMaintenance, parseNodeRuntimeReport, + parseNodeSshEnabled, partPathOf, } from "./node-frames.js"; export { diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 270ea7108..855ca6c03 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -133,8 +133,22 @@ import type { JsonValue } from "./json.js"; * log-confined beside the generalized `file_read`, and `write_file`'s upload * roots stay exactly as narrow as they are while `transfer_write` arrives as * its own policy-gated sibling. + * + * **15 → 16 is the node SSH capability gate (spec 2026-10-07 §4.3).** One + * owner-controlled flag per machine saying "this machine may take part in + * Subshell SSH at all", off by default and fail-closed: `set_ssh_enabled` + * carries the plane's value to the node's mirror file, `ready.sshEnabled` + * states that mirror at connect, and the `ssh_enabled` event reports a mirror + * that moved underneath the connection. Unlike maintenance there is no + * node-side writer — the plane is the sole author and every reconcile pushes + * the row, never the other way. Additive, and breaking anyway, because the + * gate is exact-match — and breaking is the POINT here rather than a formality + * to shrug through: a pre-16 agent never sees `set_ssh_enabled` and reports no + * `sshEnabled`, which on the wire is indistinguishable from a 16 agent whose + * mirror simply says no. A capability gate cannot live with that ambiguity + * (spec §4.3), so the number is what refuses the old agent, not the frame. */ -export const NODE_PROTOCOL_VERSION = 15; +export const NODE_PROTOCOL_VERSION = 16; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -906,6 +920,28 @@ export type NodeCommandBody = /** The plane's stamp for this value; stored verbatim. */ changedAt: string; } + | { + /** + * Write this node's SSH-capability mirror file (spec 2026-10-07 §4.3). + * + * The plane's ONLY writing copy of the flag — unlike maintenance there + * is no keyboard verb to race, so this is the only frame that ever + * changes the file apart from a repair of a corrupt one. It writes the + * file and nothing else: turning SSH off kills no panes (SSH acts are + * short-lived per-command work, and turning it ON widens nothing this + * machine was already refusing mid-flight). + * + * Carries the plane's own `changedAt` rather than letting the node stamp + * its own: both copies must end byte-identical, so a later `ready` + * report reads as agreement rather than a disagreement this process + * invented. + */ + type: "set_ssh_enabled"; + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** The plane's stamp for this value; stored verbatim. */ + changedAt: string; + } | { /** * Replace this agent's own binary and restart into it (spec 2026-09-15 §5.2). @@ -1092,6 +1128,31 @@ export function parseNodeMaintenance(value: unknown): NodeMaintenanceWire | null return { on: value.on, changedAt: value.changedAt }; } +/** + * One node's SSH capability, as it travels in either direction (spec + * 2026-10-07 §4.3) — the same `{ on, changedAt }` shape as maintenance, with + * the default and the reconcile direction inverted: `on` means this machine + * MAY take part in Subshell SSH, and only the plane writes it. + * + * `changedAt` is the stamp of the write that produced `on`, carried verbatim + * in both directions so the two copies stay byte-identical and neither side + * can mistake a relay for a new decision. Nothing here compares stamps: the + * plane is authoritative and every disagreement ends with the plane's value + * pushed down. + */ +export interface NodeSshEnabledWire { + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** ISO 8601 stamp of the write that produced this value. */ + changedAt: string; +} + +/** A {@link NodeSshEnabledWire}, or null when the value is not one. */ +export function parseNodeSshEnabled(value: unknown): NodeSshEnabledWire | null { + if (!isRecord(value) || !isBool(value.on) || !isStr(value.changedAt)) return null; + return { on: value.on, changedAt: value.changedAt }; +} + /** Agent → control events, unsigned (socket-authed; spec §3.3). */ /** * One field of a plugin's preset-editor schema, as it travels. @@ -1243,6 +1304,16 @@ export type NodeEvent = * own record, which is the only way that file gets repaired. */ maintenance?: NodeMaintenanceWire; + /** + * This machine's SSH-capability mirror, as its file reads at connect + * (spec 2026-10-07 §4.3). Absent means the node has no file — which for + * SSH reads as REFUSED (fail-closed), not as agreeing with the row. + * + * Dropped rather than fatal when malformed, exactly like `maintenance`: + * the refusal is the safe direction, and the plane reconciles the + * disagreement from its own row on the very same `ready`. + */ + sshEnabled?: NodeSshEnabledWire; } | { /** @@ -1259,6 +1330,24 @@ export type NodeEvent = /** ISO 8601 stamp of the write on the machine. */ changedAt: string; } + | { + /** + * This machine's SSH mirror moved underneath the connection (spec + * 2026-10-07 §4.3) — the heartbeat belt's carrier. + * + * With no node-side writer there is no keyboard flip to report (that is + * what `set_maintenance`'s sibling event exists for); what this carries + * is a file that changed by some other means — chiefly CORRUPTION, which + * flips a machine from "allowed" to "refused" silently and would + * otherwise stay a secret until the next reconnect. The plane's answer + * to any disagreement is a push of its row, which is also the repair. + */ + type: "ssh_enabled"; + /** The refusal/allowance this machine is acting on, as its mirror reads. */ + on: boolean; + /** ISO 8601 stamp from the file (its own mtime when the file is unreadable). */ + changedAt: string; + } | { type: "inventory"; harnesses: { @@ -1507,6 +1596,10 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { const state = parseNodeMaintenance(value); return state ? { type: "set_maintenance", ...state } : null; } + case "set_ssh_enabled": { + const state = parseNodeSshEnabled(value); + return state ? { type: "set_ssh_enabled", ...state } : null; + } case "update": { // Shape only, like `set_server_url` above: WHICH url is acceptable is // the plane's to decide, and the digest is checked against the bytes @@ -1644,19 +1737,26 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { ) { return null; } - // `runtime` and `maintenance` are additive: a malformed one is dropped - // so the connection still comes up without it, rather than refused. + // `runtime`, `maintenance` and `sshEnabled` are additive: a malformed + // one is dropped so the connection still comes up without it, rather + // than refused. For `sshEnabled` the drop is also SAFE in a way the + // others are not quite: the plane answers silence with its row (a row + // that says on pushes down), so a malformed field cannot strand a + // machine on the wrong side of the gate. const { runtime: rawRuntime, maintenance: rawMaintenance, + sshEnabled: rawSshEnabled, ...rest - } = value as Record & { runtime?: unknown; maintenance?: unknown }; + } = value as Record & { runtime?: unknown; maintenance?: unknown; sshEnabled?: unknown }; const runtime = rawRuntime === undefined ? null : parseNodeRuntimeReport(rawRuntime); const maintenance = rawMaintenance === undefined ? null : parseNodeMaintenance(rawMaintenance); + const sshEnabled = rawSshEnabled === undefined ? null : parseNodeSshEnabled(rawSshEnabled); return { ...(rest as unknown as Extract), ...(runtime ? { runtime } : {}), ...(maintenance ? { maintenance } : {}), + ...(sshEnabled ? { sshEnabled } : {}), }; } case "inventory": { @@ -1675,6 +1775,12 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { const state = parseNodeMaintenance(value); return state ? { type: "maintenance", ...state } : null; } + case "ssh_enabled": { + // Strict for the same reason: this frame is the machine reporting what + // it is acting on; half of it is not an answer. + const state = parseNodeSshEnabled(value); + return state ? { type: "ssh_enabled", ...state } : null; + } case "heartbeat": return isStr(value.ts) ? { type: "heartbeat", ts: value.ts } : null; case "result": From 4f67f24a89fa92458ac186476e90ff5d0e8ab939 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:08:23 -0700 Subject: [PATCH 019/170] chore(changeset): node ssh capability gate (server + node minor) --- .changeset/node-ssh-capability-gate.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/node-ssh-capability-gate.md diff --git a/.changeset/node-ssh-capability-gate.md b/.changeset/node-ssh-capability-gate.md new file mode 100644 index 000000000..a2a31068b --- /dev/null +++ b/.changeset/node-ssh-capability-gate.md @@ -0,0 +1,6 @@ +--- +"@internal/server": minor +"@internal/node": minor +--- + +Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16 so a pre-update agent is never wire-ambiguous about honoring the gate. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet. From 0d7df38e42e02043e7510d05c81e819cdafdab93 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:16:11 -0700 Subject: [PATCH 020/170] fix(backups): advance the restore migration ceiling to 0047 and rebuild the older-snapshot fixture on a valid ledger prefix (the tip-removal fixture hardcoded 0046 as newest; adding a migration must move both the ceiling and the fixture's strip set) --- .../__tests__/backup-cli-subprocess.test.ts | 41 ++++++++++++++----- .../api/src/services/backups/database.ts | 2 +- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 9f1bb6dae..c5c3ce828 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -388,18 +388,24 @@ test("real entry: available older upgrade snapshot restores and migrates before mkdirSync(backupDir, { mode: 0o700 }); const snapshot = join(backupDir, "subshell-v1.7.0-20260101-000000.db"); const db = new Database(instance.databasePath); - const migration = db.query("SELECT * FROM kysely_migration WHERE name='0046-backup-recovery'").get() as { - name: string; - timestamp: string; - }; + // "Older" must be a valid LEDGER PREFIX: the newest registered migrations + // are stripped (rows AND their DDL) so the snapshot ends where an install of + // that era would; removing one from the middle while a later row stands + // above it is exactly the non-contiguous history kysely refuses to boot. + const migrationRows = db + .query( + "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + ) + .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; DELETE FROM kysely_migration WHERE name='0046-backup-recovery'; CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); UPDATE migration_restore_probe SET value='newer destination';", + "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; UPDATE migration_restore_probe SET value='newer destination';", ); - db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(migration.name, migration.timestamp); + for (const row of migrationRows) + db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); db.close(); const catalog = ok(await cli(["backup", "--list", "--json"], instance.configDir)); expect(catalog.backups).toHaveLength(1); @@ -407,17 +413,30 @@ test("real entry: available older upgrade snapshot restores and migrates before const restored = ok(await cli(["restore", snapshot, "--yes", "--no-start", "--json"], instance.configDir)); expect(restored).toMatchObject({ status: "pending-boot", legacyDatabaseOnly: true }); const offline = new Database(instance.databasePath, { readonly: true }); - expect(offline.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toBeNull(); + expect( + offline + .query("SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled')") + .all(), + ).toEqual([]); offline.close(); const running = await boot(instance.configDir, port); const migrated = new Database(instance.databasePath, { readonly: true }); - expect(migrated.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toMatchObject({ - name: "0046-backup-recovery", - }); + expect( + migrated + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + ) + .all(), + ).toEqual([{ name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ name: "backup_recovery", }); + expect( + migrated + .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") + .all(), + ).toEqual([{ name: "ssh_enabled" }, { name: "ssh_enabled_at" }]); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 2d0ac1be5..b897e6afb 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0046-backup-recovery"; +export const LATEST_BACKUP_MIGRATION = "0047-node-ssh-enabled"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { From bb8d705f8fffae8bb1221495690c0ec5d6a440aa Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:54:08 -0700 Subject: [PATCH 021/170] docs(ssh-anywhere): final-review wave - changeset states the protocol-16 hold consequence, ported wiring claims say 'until Plan 2' (barrel, parseNodeCommandBody arms, node-results validators, the retired command quad), Gate A values attributed to their actual freeze, spec pointers trimmed to honest anchors, route comment + import fold + test-restore audit note --- .changeset/node-ssh-capability-gate.md | 2 +- .../api/nodes/set-node-ssh-enabled.route.ts | 4 +-- .../nodes/__tests__/ssh-enabled.test.ts | 6 +++- .../api/src/services/nodes/ssh-enabled.ts | 3 +- packages/pane-runtime/src/ssh/ssh-discover.ts | 6 ++-- packages/pane-runtime/src/ssh/ssh-resolve.ts | 5 +-- packages/subshell-protocol/src/ssh-config.ts | 9 +++--- packages/subshell-protocol/src/ssh-errors.ts | 18 +++++------ packages/subshell-protocol/src/ssh-frames.ts | 16 ++++++---- packages/subshell-protocol/src/ssh-limits.ts | 32 +++++++++++-------- packages/subshell-protocol/src/ssh-results.ts | 6 ++-- 11 files changed, 61 insertions(+), 46 deletions(-) diff --git a/.changeset/node-ssh-capability-gate.md b/.changeset/node-ssh-capability-gate.md index a2a31068b..134cd7202 100644 --- a/.changeset/node-ssh-capability-gate.md +++ b/.changeset/node-ssh-capability-gate.md @@ -3,4 +3,4 @@ "@internal/node": minor --- -Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16 so a pre-update agent is never wire-ambiguous about honoring the gate. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet. +Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16: update the server before the nodes, because the link gate is exact-match and a lagging agent is held (offline for every purpose but update) until it crosses the bump on the update command. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet. diff --git a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts index b056c529d..3e23d5435 100644 --- a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts +++ b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts @@ -57,8 +57,8 @@ export const setNodeSshEnabledRoute = new Elysia() actorUserId: user.id, }); // Re-read rather than patching the gate's snapshot: the service wrote - // the row, and the view must render what is stored — including the stamp - // it chose — rather than what this handler believes it asked for. + // the row, and the view must render what is stored, not what this + // handler believes it asked for. const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); }, diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts index fe6c66e7a..e395f127d 100644 --- a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts @@ -282,7 +282,11 @@ describe("`local` has no mirror to push to", () => { await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: true, changedAt, actorUserId: ownerId }); expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(1); // Restore whatever the shared test DB had, so no later suite inherits a - // server account with SSH open (spec §4.3: local ships OFF). + // server account with SSH open (spec §4.3: local ships OFF). The restore + // goes through the service, so this test leaves two node.ssh_enabled.update + // audit rows on LOCAL_NODE_ID in THIS file's DB - harmless by design: each + // test file gets its own database, and audit-counting tests filter by + // targetId. await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: false, changedAt, actorUserId: ownerId }); expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(0); }); diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts index 129474390..d7582b336 100644 --- a/apps/server/api/src/services/nodes/ssh-enabled.ts +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -1,6 +1,5 @@ import type { NodeSshEnabledWire } from "@internal/subshell-protocol"; -import type { NodeTable } from "@/db/types/nodes.db-types.js"; -import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; import { audit } from "@/services/audit.js"; import { sendCommand } from "@/services/nodes/node-rpc.js"; diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts index ec02ece3c..42cb47a4b 100644 --- a/packages/pane-runtime/src/ssh/ssh-discover.ts +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -4,9 +4,9 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; /** - * Bounded discovery of SSH alias NAMES (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, - * section 5.2): discover aliases by bounded parsing of the account's config - * and includes; omit wildcard-only entries, detect include cycles. + * Bounded discovery of SSH alias NAMES (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): + * discover aliases by bounded parsing of the account's config and includes; + * omit wildcard-only entries, detect include cycles. * * This parser answers NAMES, never config contents, and it is deliberately a * NAME-HUNT and not an implementation of ssh_config semantics: the authoritative diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index 3810376dc..6bdf2a7df 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -38,8 +38,9 @@ import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; * such a config a saved connection, but detection happens after the eval, so * the refusal is about what gets stored, not about what already ran. The * resolve command's JSDoc on the wire names this; UI copy must render it - * before the button (configuration is executable: - * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 5.2). + * before the button. The doctrine ("configuration is executable") is this + * engine's own Gate A contract, carried verbatim; the governing design sits in + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md section 5.2. */ /** Inputs {@link resolveSshAliasConfig} needs; every one is injectable so tests never touch a developer's `~/.ssh`. */ diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts index 5ce94ed0d..1fe88695d 100644 --- a/packages/subshell-protocol/src/ssh-config.ts +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -6,10 +6,11 @@ * start local helpers, forward ports and reuse ambient sockets, so nothing * here is a passthrough: the human-facing resolution step produces this shape * once, and the NODE re-validates it with {@link - * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot - * (resolve, test, run start, terminal launch). The plane approving a snapshot - * is not the load-bearing defense - this validator refusing a forbidden - * member is, on the machine that will actually run ssh. + * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot (of the + * destination product's four, only the resolve command survives; its node-side + * dispatch is not wired on this tier). The plane approving a snapshot is not + * the load-bearing defense - this validator refusing a forbidden member is, on + * the machine that will actually run ssh. * * The eight absent-forbidden members (`proxyCommand`, `forwards`, `tunnels`, * `localCommands`, `remoteCommand`, `sendEnv`, `setEnv`, `escapes`) are typed diff --git a/packages/subshell-protocol/src/ssh-errors.ts b/packages/subshell-protocol/src/ssh-errors.ts index f23750d4c..97ee34442 100644 --- a/packages/subshell-protocol/src/ssh-errors.ts +++ b/packages/subshell-protocol/src/ssh-errors.ts @@ -9,15 +9,15 @@ * and test result envelopes carry the same codes in their `code` fields; and * the server's policy refusals and API error metadata surface them verbatim. * - * The set was FROZEN after Gate A; the retirement of the destination - * execution product (design 2026-10-05 §7) pruned the three quota/storage - * codes only ITS runtimes emitted (`quota_runs`, `quota_terminals`, - * `storage_full`), a deletion that is legal precisely because protocol 17 - * was never released. What remains is what the SURVIVING surfaces still - * answer: resolution refusals for the wizard, and the bare codes the - * session-runtime supervisor maps by equality. Adding a code is a - * coordinated change; renaming or deleting one is a wire change like any - * other. + * The set was FROZEN after Gate A and is carried verbatim from the reference + * lineage (design 2026-10-05 §7); that lineage's retirement of the destination + * execution product pruned the three quota/storage codes only ITS runtimes + * emitted (`quota_runs`, `quota_terminals`, `storage_full`), a deletion that + * was legal precisely because that lineage's protocol bump never released. + * What this product line answers with today is resolution refusals; the + * session-runtime sentences ride along because the set is carried frozen, not + * because anything on this branch maps them. Adding a code is a coordinated + * change; renaming or deleting one is a wire change like any other. * * Imports no `node:` builtin; this module is in the Metro-safe barrel. */ diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 661b5ac83..e856d4834 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -16,8 +16,10 @@ * bodies, so nothing may assume a socket, a jti, or a signature - those are * the transport's, and they live outside the body. * - * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin; - * lives in the Metro-safe barrel. + * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, so + * it COULD join the Metro-safe barrel, but does not yet: on this tier the + * grammar fixtures' type imports are its only consumer, and Plan 2 exports + * and dispatches it with the ssh RPC verbs. */ import { isRecord, isStr } from "./guards.js"; @@ -66,10 +68,12 @@ function isAliasName(value: unknown): value is string { } /** - * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` - * routes its two `type` arms here so the SSH grammar lives in ONE file - * beside the commands it narrows; the contract it upholds is the same: a - * NON-null return is safe to switch on by `type`. + * Validates and narrows any `ssh_*` command body. Plan 2 routes + * `parseNodeCommandBody`'s two `type` arms here, so the SSH grammar lives in + * ONE file beside the commands it narrows; until that wiring lands no + * `ssh_*` frame is dispatched on this branch. The contract this upholds is + * the same as the node commands' today: a NON-null return is safe to switch + * on by `type`. * * @param value - candidate payload whose `type` starts with `ssh_` * @returns the narrowed command, or null when malformed diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index b4a265558..bf8b16429 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -1,19 +1,24 @@ /** - * The SSH resource limits, frozen (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md). + * The SSH resource limits. The NUMBERS were frozen at the Gate A + * implementation and carried verbatim from the reference lineage (design + * 2026-10-05 §7); the governing design for this product line is + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, which sets the + * policy for these budgets without restating the numerals. * - * Everything here is PURE data: the frozen spec values, each named, plus - * the derived bounds the wire parsers enforce so neither end of the link can - * drift. These values are law after Gate A: the node runtime enforces them at the - * connecting machine, the plane refuses dispatch past them before a command - * is ever signed, and the surfaces render the same numbers the enforcement - * uses. Changing one is a coordinated edit, not a local convenience - - * which is why they live here rather than in the packages that act on them. + * Everything here is PURE data: named frozen values plus the derived bounds + * the wire parsers enforce, so neither end of the link can drift. They are + * law, not local convenience - changing one is a coordinated edit - which is + * why they live here rather than in the packages that act on them. * * The destination execution product's limits (run deadlines, active-run and * terminal quotas, output retention and aggregate storage, the read window - * and long-poll caps) deleted with it (design 2026-10-05 §7). What remains: - * the probe deadline, the snapshot/grammar bounds, the cancel grace the - * session supervisor borrows, and the session-runtime table. + * and long-poll caps) deleted with it (design 2026-10-05 §7). Of what + * remains: the grammar bounds are enforced by this tier's parsers and the + * probe deadline by its engines, while the cancel grace and the + * session-runtime table describe the brokered-session subsystem this product + * line deliberately does not carry (spec section 15) - they ride along + * because the set is frozen as a whole, and nothing on this branch enforces + * them. * * Imports no `node:` builtin; this module is in the Metro-safe barrel. */ @@ -24,8 +29,9 @@ /** * Connection/setup probe: a 30-second OVERALL deadline, and no automatic - * execution retry ever (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md; the no-retry rule is - * the durable-dispatch contract, not a tuning knob). + * execution retry ever (a value frozen at the Gate A implementation and + * carried verbatim; the no-retry rule is the durable-dispatch contract, not a + * tuning knob). */ export const SSH_PROBE_DEADLINE_MS = 30 * 1000; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index 2020ebd1d..120afa595 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -5,9 +5,9 @@ import type { SshErrorCode } from "./ssh-errors.js"; * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; * exported NAMES unchanged). The test/run/control envelopes retired with the - * destination product (design 2026-10-05 §7). The `parse*` validators for - * these live in `node-results.ts` beside every other result validator (the - * four-site rule in the integration maps). + * destination product (design 2026-10-05 §7). Their `parse*` validators join + * `node-results.ts` beside every other result validator when Plan 2 wires the + * ssh RPC verbs; none of them exists on this branch yet. * * Imports no `node:` builtin; lives in the Metro-safe barrel. */ From bcfd31257ee9a36a2e8ac0f62ce828de882d7aba Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:56:28 -0700 Subject: [PATCH 022/170] docs(ssh-anywhere): final-review round 2 - the stale frozen-spec banner re-tagged to the Gate A lineage, and the Match-exec disclosure pointer moved off the wire JSDoc (which does not carry it) onto the grammar header that does --- packages/pane-runtime/src/ssh/ssh-resolve.ts | 7 ++++--- packages/subshell-protocol/src/ssh-limits.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index 6bdf2a7df..692264c3d 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -37,9 +37,10 @@ import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; * resolution. The walk separately detects `Match exec` and refuses to make * such a config a saved connection, but detection happens after the eval, so * the refusal is about what gets stored, not about what already ran. The - * resolve command's JSDoc on the wire names this; UI copy must render it - * before the button. The doctrine ("configuration is executable") is this - * engine's own Gate A contract, carried verbatim; the governing design sits in + * grammar's own header names the doctrine ("Configuration here is executable, + * not passive data", ssh-config.ts); Plan 2's wire JSDoc and UI copy must + * carry the disclosure before the button. It is this engine's Gate A contract, + * carried verbatim; the governing design sits in * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md section 5.2. */ diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index bf8b16429..bad12756c 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -24,7 +24,8 @@ */ /* ------------------------------------------------------------------ */ -/* frozen spec values (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md), one export each */ +/* frozen at the Gate A implementation (design 2026-10-05 lineage); governing policy: + docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md - one export each */ /* ------------------------------------------------------------------ */ /** From 74e549083f5a66f4ab6a9f16733f5c6d9d214b22 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 02:57:56 -0700 Subject: [PATCH 023/170] docs(ssh-anywhere): final-review round 3 - the Match-exec disclosure now states the shipped order (walk refuses a seen exec before -G ever runs; the disclosure covers what the bounded walk could not see) --- packages/pane-runtime/src/ssh/ssh-resolve.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index 692264c3d..e9cd5d390 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -34,13 +34,14 @@ import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; * * **Disclosure this step owes the human:** `ssh -G` EVALUATES the account's * config, which means a trusted `Match exec` can run a LOCAL command during - * resolution. The walk separately detects `Match exec` and refuses to make - * such a config a saved connection, but detection happens after the eval, so - * the refusal is about what gets stored, not about what already ran. The - * grammar's own header names the doctrine ("Configuration here is executable, - * not passive data", ssh-config.ts); Plan 2's wire JSDoc and UI copy must - * carry the disclosure before the button. It is this engine's Gate A contract, - * carried verbatim; the governing design sits in + * resolution. The structural walk runs BEFORE the eval and refuses every + * `Match exec` it sees, so a seen one never reaches `-G` here at all. The + * disclosure covers what the walk could not see: its byte, + * file and glob-match caps mean a `Match exec` past a cap is invisible to the + * walk but not to OpenSSH, and `-G` runs it. Plan 2's wire JSDoc and UI copy + * must say so before the button. The doctrine (ssh-config.ts: "Configuration + * here is executable, not passive data") is this engine's Gate A contract, + * carried verbatim; the governing design is * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md section 5.2. */ From a8fbde617c50700e6a1e15d86b27573a080c5a22 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 19:56:24 -0700 Subject: [PATCH 024/170] docs(plans): Plan 2 - the launcher tier (M1 machine half: discovery/resolve, rendered ssh panes, gated surface, owner-only input) --- .../2026-10-07-ssh-anywhere-2-launcher.md | 479 ++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md new file mode 100644 index 000000000..636d17345 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md @@ -0,0 +1,479 @@ +# SSH Anywhere - Plan 2: The Launcher Tier Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Milestone 1's machine half: an operator whose node has `ssh_enabled` on can launch an interactive SSH-terminal pane at any sshd host from the API - alias discovery, `ssh -G` resolve, a rendered `-F` config, a scoped agent socket, and owner-only input - with a CI e2e proving it against a real sshd. + +**Architecture:** The plane composes ssh invocations from approved snapshots (the grammar ported in tier 1), ships them over the existing signed `sendCommand` transport and the existing `launch` frame, and the node owns only its own facts: which binary answers `ssh`, where its files live. Discovery/resolve are two new node commands; the pane itself is a new built-in `ssh` harness of type `terminal`, so streaming, logging, capture, and sharing are the ordinary pane path untouched. The reference lineage on `feat/ssh-support` (read with `git show feat/ssh-support:`, available in this linked worktree) carries the Gate A renderer and agent handlers this plan adapts to interactive use. + +**Tech Stack:** Bun + TypeScript, Elysia + Kysely (SQLite), tmux, OpenSSH, `@internal/pane-runtime`, `@internal/subshell-protocol`, Playwright-free e2e (REST + direct tmux probes per existing e2e specs). + +## Global Constraints + +- Work ONLY in `/home/theo/projects/wt-ssh-anywhere-2` (branch `feat/ssh-anywhere-2`). Never touch `/home/theo/projects/subshell` or `/home/theo/projects/wt-ssh-anywhere`. +- Tests run with `env -u SHELLOPTS -u BASHOPTS bun test `; check the reported file count (bun silently skips missing paths). +- No `NODE_PROTOCOL_VERSION` value changes except the one bump Task 4 specifies. +- No `await import()` anywhere; static imports only. +- No U+2014 or U+2013 in any prose or shipped string (`lint:prose` at the boundary). +- Key material, challenges, signatures, and config file CONTENT never enter logs, argv, audit rows, or notifications (spec §13). Audit metadata names ids and hosts only. +- `shellQuote` (from `@internal/subshell-ai/plugin-api`, re-exported by `@internal/pane-runtime` as `shellQuote` - import from whichever site the surrounding file already uses) on every token of the pane command string; the pane launch IS a shell string. +- Every wire-carrying snapshot is re-validated with `parseSshConnectionSnapshot` at each trust boundary (frame parse, agent receive, renderer entry) - the tier-1 grammar doc calls this the load-bearing defense, not the plane's approval. +- Package versions pinned; after any `bun add` run `bun run syncpack-fix` then `bun install`; a workspace version change to `bun.lock` needs `bun run lint:lockfile:fix`. +- Migration additions must move `LATEST_BACKUP_MIGRATION` and the older-snapshot fixture strip-set in the same commit (tier 1's boundary lesson, commit 0ebd6cee). +- Focused verification while iterating (`bunx turbo verify-types --filter=`, `bunx biome check `, the touched test files); the full `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` only at Task 10. +- Commit as theo@suteki.nu; do not push except where a task says so. + +## File Structure (map) + +``` +packages/subshell-protocol/src/ + node-frames.ts # MODIFY: ssh command arms, launch.ssh field, version bump note + node-results.ts # MODIFY: parseNodeSshAliasList, parseNodeSshResolveOutcome + __tests__/node-frames.test.ts # MODIFY: ssh arms + launch.ssh cases +packages/pane-runtime/src/ + ssh/ssh-render.ts # CREATE: interactive config + option-token builders (adapted port) + ssh/__tests__/ssh-render.test.ts # CREATE: golden renders, refusal cases + registry.ts # MODIFY: register the ssh harness + index.ts # MODIFY: named exports of the render builders +packages/plugins/ssh/ # CREATE: built-in ssh harness (type terminal) +apps/node/agent/src/ + commands/ssh-shared.ts # CREATE: sshBin ladder + connecting home + commands/ssh-aliases.ts # CREATE: the two discovery/resolve handlers (gated) + commands/index.ts # MODIFY: two dispatch arms + commands/launch.ts # MODIFY: ssh config write block + commands/report.ts # MODIFY: ssh config cleanup at exit-watch observation + __tests__/commands-ssh.test.ts # CREATE: handler matrix incl. gate refusal + __tests__/launch-ssh.test.ts # CREATE: config write modes + skip-when-absent +apps/server/api/src/ + db/migrations/0048-ssh-launch-and-saved-hosts.ts # CREATE + db/migrations/__tests__/0048-...test.ts # CREATE + db/types/ssh-saved-hosts.db-types.ts # CREATE + db/types/subshells.db-types.ts # MODIFY: ssh column + db/repositories/ssh-saved-hosts.repository.ts # CREATE + services/nodes/ssh-rpc.ts # CREATE: sendCommand wrappers + validators + services/ssh-launch.service.ts # CREATE: gate, resolve, compose, audit + services/subshell-manager.service.ts # MODIFY: createSubshell sshSnapshot param + services/nodes/local-launcher.ts # MODIFY: write ssh config pre-spawn (local) + services/nodes/remote-launcher.ts # MODIFY: ship ssh block on the frame + services/nodes/node-launcher.ts # MODIFY: LaunchPlan.ssh + api/ssh/ # CREATE: index + aliases/resolve/launch/saved-hosts routes + api/subshells.ts # MODIFY: requireInputAccess owner-only rule + services/subshells.service.ts # MODIFY: execInTerminal + resolvePaneInputAccess rule + ws/live-subshell.ts # MODIFY: input frame owner check + commands/__tests__/backup-cli-subprocess.test.ts # MODIFY: fixture strip-set (0047+0048) + services/backups/database.ts # MODIFY: LATEST_BACKUP_MIGRATION +docs/security.md # MODIFY §10: ssh.launch +.claude/rules/security-context.md # MODIFY: mirror the §10 line +e2e/tests/22-ssh-terminal.spec.ts # CREATE: the M1 proof +.changeset/ssh-launcher-tier.md # CREATE at Task 10 +``` + +## Decisions this plan has already made (do not relitigate in implementation) + +1. **No new launch pipeline.** The ssh pane is a terminal-type harness whose `preset.flags` are the ssh option tokens. `buildCommand` = `[binary, ...flags]`, exactly the terminal plugin's shape. Working dir = the existing presetless-terminal "launch node's home" default (spec 2026-10-01 machinery already in `subshells.service.ts:494`). +2. **The plane renders, the node executes.** `renderSshConfigContents` + option-token builder live in `@internal/pane-runtime` and run on the plane (which already imports pane-runtime for `LocalLauncher`); the node contributes only binary resolution (the `resolve` rule travels, the path is the node's own ladder) and file placement under its own `dataDir`. +3. **`SSH_AUTH_SOCK` rides `subshellEnv`.** The pane's command IS the ssh process, so the pane env is the ssh env; adding the snapshot's agent socket to `subshellEnv` for ssh launches only is the scoped exception spec §5.4 names. Non-ssh panes never carry it (`curatedEnv` untouched). Password/keyboard-interactive auth is NOT disabled: an interactive pane may answer a password prompt like any terminal (the tier-1 BatchMode/key-only posture belonged to supervised runs). +4. **Config file lifecycle:** written by whoever spawns (agent `execLaunch` block mirroring the MCP-file block; `LocalLauncher.launch` for local), at `/ssh//config` (0600 in a 0700 dir). Cleaned at pane death by the exit path: the agent's exit watcher unlinks the derived path when the pane's meta names harness `ssh`; delete-time cleanup rides the existing `remove_paths`/`subshellArtifacts` seam (paths under `dataDir` are policy-legal there - verified `basics.ts:260` permits any `pathAllowed` name, not only `.log`). +5. **Input authority:** `subshells.ssh` (nullable JSON snapshot column) is the kind fact. Input (REST input, exec, MCP-via-REST, WS live frames) refuses when `row.ssh !== null` unless the acting principal is the row's OWNER account. Note the two resolutions: a bearer subshell key on a REST route resolves as its owner (boost/shares off), so an ssh pane's own key would otherwise slip through the owner comparison - the rule therefore also refuses `actor === "subshell-key"` explicitly: an ssh pane must not type into itself. +6. **Protocol bump 16 -> 17** (Task 4): a tier-1 agent (protocol 16, no ssh arms) that ignores the launch frame's `ssh` member would spawn a bare `ssh` pane with no `-F` config - the ambiguity §4.3's gate doctrine refuses to live with. Lagging agents are HELD (only `update`) until crossed; server-first sequencing ships in the changeset, same sentence class as tier 1's. +7. **Restarts:** an ssh pane refuses `POST /:id/restart` with a named 409 (reconnect from the launcher instead). Auto-restart rows (`restartOnExit`) are forced off at create for ssh panes. +8. **Audit:** one new event `ssh.launch`, metadata `{ nodeId, destination, subshellId }`. Saved-host CRUD is unaudited (the prompts precedent). +9. **Discovery/resolve/gate on the node:** every ssh node command first consults the tier-1 fail-closed mirror (`readSshEnabled`/`sshAllowed` in `apps/node/agent/src/ssh-enabled.ts`); a machine answers `ssh disabled on this node` regardless of what the plane believes. +10. **MCP:** ssh panes get their subshell token (row infra + exit hook) but NO MCP registration file and no MCP dialect - the remote shell hosts no local process that could use it. + +--- + +### Task 1: Protocol - ssh command arms, result validators, launch `ssh` field + +**Files:** +- Modify: `packages/subshell-protocol/src/node-frames.ts` (union near `:650`, parser near the `case "remove_paths"` arm at `:1538`) +- Modify: `packages/subshell-protocol/src/node-results.ts` (append the two validators; the file already imports `parseSshConnectionSnapshot`, `isSshErrorCode`, `SSH_MAX_DISCOVERED_ALIASES`, and the ssh result types) +- Modify: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` (or the file's existing per-command-case test home - locate by grepping `case "remove_paths"` in `__tests__/`) +- Test: `packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts` (CREATE) + +**Interfaces:** +- Consumes: `SshNodeCommandBody` + `parseSshNodeCommandBody` + `SSH_COMMAND_TYPES` from `./ssh-frames.js` (already on this branch, previously unwired - this task IS the wiring), `SshConnectionSnapshotWire` + `parseSshConnectionSnapshot` from `./ssh-config.js`, `NodeSshAliasListResult` + `NodeSshResolveOutcomeWire` from `./ssh-results.js`, fixture helpers `makeAliasList` / `makeResolveOk` / `makeResolveRefused` from `./__tests__/fixtures/ssh-fixtures.ts` (already present). +- Produces: `parseNodeCommandBody` answers `ssh_discover_aliases` / `ssh_resolve_config`; the `launch` arm accepts optional `ssh?: { configPath: string; fileContent: string }` (fileContent ≤ `SSH_CONFIG_FILE_MAX_BYTES`, a new const in `ssh-limits.ts` = 65536); `parseNodeSshAliasList` / `parseNodeSshResolveOutcome` exported from `node-results.js` and through the package barrel `src/index.ts`. + +- [ ] **Step 1: Read the reference verbatim** + +```bash +git show feat/ssh-support:packages/subshell-protocol/src/node-results.ts | sed -n '548,589p' +git show feat/ssh-support:packages/subshell-protocol/src/node-frames.ts | sed -n '1681,1685p' +``` + +The first block is the two parsers to port (drop nothing; their JSDoc is load-bearing). The second is the delegation arm shape. + +- [ ] **Step 2: Write the failing tests** + +Create `packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; + +describe("parseNodeSshAliasList", () => { + test("narrows a well-formed answer", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ + aliases: ["box-a", "box-b"], + includeCycle: false, + truncated: false, + }); + }); + test("refuses a past-cap list whole (the truncated flag depends on the cap)", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: Array.from({ length: 501 }, (_, i) => `h${i}`) }))).toBeNull(); + }); + test("refuses non-boolean flags and non-string members", () => { + expect(parseNodeSshAliasList({ aliases: ["x"], includeCycle: "no", truncated: false })).toBeNull(); + expect(parseNodeSshAliasList({ aliases: [1], includeCycle: false, truncated: false })).toBeNull(); + }); +}); + +describe("parseNodeSshResolveOutcome", () => { + test("accepted arm runs the FULL snapshot validator (no unvalidated snapshot enters the plane)", () => { + const ok = makeResolveOk(); + expect(parseNodeSshResolveOutcome(ok)).not.toBeNull(); + const tampered = structuredClone(ok); + (tampered as { snapshot: { host: string } }).snapshot.host = "-oProxyCommand=evil"; + expect(parseNodeSshResolveOutcome(tampered)).toBeNull(); + }); + test("connectingAccount is optional text, never another type", () => { + expect(parseNodeSshResolveOutcome(makeResolveOk())?.accepted === true).toBe(true); + const withAccount = structuredClone(makeResolveOk()) as { connectingAccount?: unknown }; + withAccount.connectingAccount = 7; + expect(parseNodeSshResolveOutcome(withAccount)).toBeNull(); + }); + test("refused arm names a known code and string settings", () => { + expect(parseNodeSshResolveOutcome(makeResolveRefused())).toEqual({ + accepted: false, + code: "unsupported_setting", + settings: ["ProxyCommand"], + }); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); + }); +}); +``` + +Extend the existing `parseNodeCommandBody` test home with cases (match that file's local style - it tests one JSON object per case): + +```ts +test("ssh discovery/resolve arms delegate to the ssh grammar", () => { + expect(parseNodeCommandBody({ type: "ssh_discover_aliases" })).toEqual({ type: "ssh_discover_aliases" }); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "box-a" })).toEqual({ + type: "ssh_resolve_config", + alias: "box-a", + }); + // alias hygiene lives in ssh-frames' parser (port-verified): option-like and whitespace are refused there + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "-x" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); +}); + +test("launch accepts an ssh block and refuses malformed ones", () => { + // Reuse this file's existing well-formed launch fixture; add the ssh member to a clone: + // { ...launchFixture, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } } -> parses, + // configPath "relative" -> null; fileContent past SSH_CONFIG_FILE_MAX_BYTES -> null; ssh: "x" -> null. +}); +``` + +(For the launch cases, copy this test file's existing minimal-valid `launch` object verbatim as the base clone; do not hand-roll a new one.) + +- [ ] **Step 3: Run to verify failure** + +`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts` - FAIL (exports missing). Confirm the file COUNTS as 1 file. + +- [ ] **Step 4: Implement** + +In `ssh-limits.ts`, append: + +```ts +/** Largest ssh_config the wire carries in one `launch.ssh.fileContent` member (a rendered snapshot config is hundreds of bytes; this bounds a hostile plane). */ +export const SSH_CONFIG_FILE_MAX_BYTES = 65_536; +``` + +In `node-frames.ts`: add to the `NodeCommandBody` union beside the other arms: + +```ts + | { type: "ssh_discover_aliases" } + | { type: "ssh_resolve_config"; alias: string } +``` + +In `parseNodeCommandBody`, beside `case "remove_paths":`: + +```ts + case "ssh_discover_aliases": + case "ssh_resolve_config": + // Delegation, not a second parser: the SSH grammar (both arms, one + // file) lives in ssh-frames.ts beside the commands it narrows. + return parseSshNodeCommandBody(value); +``` + +Import `parseSshNodeCommandBody` from `./ssh-frames.js`. In the `launch` arm's validator add (beside the `mcp` member's handling): + +```ts + if ("ssh" in value) { + const ssh = value.ssh as unknown; + if ( + typeof ssh !== "object" || + ssh === null || + typeof (ssh as { configPath?: unknown }).configPath !== "string" || + !(ssh as { configPath: string }).configPath.startsWith("/") || + (ssh as { configPath: string }).configPath.length > SSH_PATH_MAX_CHARS || + typeof (ssh as { fileContent?: unknown }).fileContent !== "string" || + (ssh as { fileContent: string }).fileContent.length > SSH_CONFIG_FILE_MAX_BYTES + ) { + return null; + } + } +``` + +and carry `...( "ssh" in value ? { ssh: value.ssh as { configPath: string; fileContent: string } } : {} )` into the returned narrowed object. Add `ssh?: { configPath: string; fileContent: string }` to the TS type of the launch arm with a doc comment naming the 0600 + node-side-path-policy contract. + +In `node-results.ts`, port the two validators from the reference verbatim (Step 1), fixing only the import sites (this branch's `ssh-errors.ts` exports `isSshErrorCode` already) and the section banner: `/* ssh discovery/resolution (the tier-1 wire types, dispatched from here on) */`. Export both from `src/index.ts`. + +- [ ] **Step 5: Run to verify pass** + +`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/` - all green, count the files. Then `bunx turbo verify-types --filter=@internal/subshell-protocol`. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(protocol): ssh command arms delegate to the tier-1 grammar, ssh launch block, result validators join node-results" +``` + +--- + +### Task 2: pane-runtime - the interactive renderer + +**Files:** +- Create: `packages/pane-runtime/src/ssh/ssh-render.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts` +- Modify: `packages/pane-runtime/src/index.ts` (named exports - the ssh barrel pattern from tier 1's Task 2; this module imports NO `node:` builtins except NONE after dropping `sshChildEnv` - keep it pure so it stays Metro-safe) + +**Interfaces:** +- Consumes: `parseSshConnectionSnapshot`, `SshConnectionSnapshotWire`, `SshHopWire`, `SSH_PATH_MAX_CHARS` from `@internal/subshell-protocol`; `shellQuote` from `../plugin-api` re-export (`../shell.ts` or wherever the ssh dir's siblings import it from - `ssh-discover.ts` shows the local spelling). +- Produces (consumed by Tasks 5-7): `renderSshConfigContents(snapshot): string`, `sshOptionTokens(snapshot): string[]` (the PLACEHOLDER-free option argv: everything after the binary), `sshDestinationToken(snapshot): string` (the `-- host` tail as one argv entry `host`), and `buildSshConfigPath(dataDir: string, subshellId: string): string` = `${dataDir}/ssh/${subshellId}/config` (rejects a non-absolute dataDir / an id failing `/^[a-zA-Z0-9_-]{1,64}$/` by THROWING - it is an impossible-state guard at composition sites, matching the codebase's path-composition doctrine). + +- [ ] **Step 1: Read the reference in full** + +```bash +git show feat/ssh-support:packages/pane-runtime/src/ssh/ssh-render.ts +git show feat/ssh-support:packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +``` + +- [ ] **Step 2: Write the failing tests** (`ssh-render.test.ts`) + +Adapt the reference tests to the interactive policy. Required cases (write full assertions in the reference's style; the three policy changes below are the ONLY deviations): + +```ts +test("Host * carries the interactive-terminal policy, not BatchMode", () => { + const out = renderSshConfigContents(baseSnapshot()); + expect(out).toContain(" StrictHostKeyChecking accept-new"); + expect(out).not.toContain("BatchMode"); + expect(out).not.toContain("PasswordAuthentication no"); + expect(out).not.toContain("UserKnownHostsFile /dev/null"); // default known_hosts is the M1 authority (spec §9) + expect(out).toContain(" ForwardAgent no"); + expect(out).toContain(" ControlPath none"); + expect(out).toContain(" EscapeChar none"); + expect(out).toContain(" RemoteCommand none"); + expect(out).toContain(" CanonicalizeHostname no"); +}); +test("known-hosts refs render when the snapshot names them", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/theo/.ssh/known_hosts"] })); + expect(out).toContain(" UserKnownHostsFile /home/theo/.ssh/known_hosts"); +}); +test("identity/cert refs render quoted only when they need quoting", () => { + /* port the reference's configPathValue quoting cases verbatim */ +}); +test("sshOptionTokens is destination-scoped only", () => { + const tokens = sshOptionTokens(baseSnapshot({ port: 2222, user: "root", proxyJumps: [{ host: "j1", user: null, port: 22 }] })); + expect(tokens).toEqual(["-F", "/data/ssh/s1/config", "-p", "2222", "-l", "root", "-o", "ProxyJump=j1"]); +}); +test("refuses an unrenderable snapshot at the last station before argv", () => { + expect(() => sshOptionTokens({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never)).toThrow(/not renderable/); +}); +``` + +- [ ] **Step 3: Run to verify failure** - FAIL (module absent). + +- [ ] **Step 4: Implement `ssh-render.ts`** + +Port the reference file with these exact deltas (everything else - the header, `assertRenderable`, `configPathValue`, the doc discipline - carries verbatim): + +1. `MANDATORY_POLICY`: remove `["BatchMode","yes"]`, `["PasswordAuthentication","no"]`, `["KbdInteractiveAuthentication","no"]`, `["HostbasedAuthentication","no"]`; change `StrictHostKeyChecking` to `"accept-new"`; drop the reference's GSSAPI comment but KEEP `["GSSAPIAuthentication","no"]`. Header comment for the array: "the M1 interactive-terminal policy (spec 2026-10-07 §5.2/§9): every hop stays confined (forwarding, control sockets, local commands, the escape menu all die here because ssh spawns ProxyJump children that re-read THIS file), while the session itself stays a terminal: auth methods are the server's to offer and a changed host key is OpenSSH's own hard block." +2. `knownHostsLines`: `if (files.length === 0) return [];` with the comment "absent renders nothing: ssh's own default `~/.ssh/known_hosts` is the M1 trust store (§9) - the tier's product wrote /dev/null because its BatchMode posture made silence fail closed; here silence IS the policy." +3. Split the reference's `buildSshInvocation`: `sshOptionTokens(snapshot)` returns everything after the binary EXCEPT the trailing `["--", host]` (`-p` always, `-l` only when set, HostKeyAlias/ProxyJump only when set) and `sshDestinationToken(snapshot)` returns `snapshot.host`. Both call `assertRenderable`. `renderSshConfigContents` carries verbatim (minus delta 1/2). +4. Do NOT port `sshChildEnv`, `remoteCommandLine`, `-tt`/`forceTty` handling, or the `SshInvocationInput` type (this tier's pane has no remote command and allocates its PTY through tmux, so `ssh` auto-requests a tty; `-F`/option tokens ride the argv, the config path is composed by the caller into `-F`). +5. `buildSshConfigPath` as specified in Produces, with the doc: "the derived per-pane config path is composed here so plane and node agree byte-for-byte from the two facts each already holds (dataDir, subshellId) - no path ever travels the wire." + +Add to `packages/pane-runtime/src/index.ts` the three/four named exports beside the tier-1 ssh exports. + +- [ ] **Step 5: Run to verify pass** - `env -u SHELLOPTS -u BASHOPTS bun test packages/pane-runtime/src/ssh/` green; `bunx turbo verify-types --filter=@internal/pane-runtime`; then `bunx turbo build --filter=@internal/pane-runtime` (the Metro trap rule: this module adds no `node:` import - a grep proves it). + +- [ ] **Step 6: Commit** - `git commit -am "feat(pane-runtime): interactive ssh renderer - every-hop confinement, terminal auth left to the server"` + +--- + +### Task 3: The built-in `ssh` harness plugin + +**Files:** +- Create: `packages/plugins/ssh/package.json`, `packages/plugins/ssh/src/index.ts`, `packages/plugins/ssh/src/__tests__/ssh.test.ts`, `packages/plugins/ssh/icon.svg`, `packages/plugins/ssh/tsconfig.json`, `packages/plugins/ssh/turbo.json` (all copied structure from `packages/plugins/terminal`, renamed) +- Modify: `packages/pane-runtime/src/registry.ts` (import + built-in list, beside the terminal entry at `:61`) +- Modify: `packages/pane-runtime/src/generated/embedded-plugins.ts` (the embedded-bytes list the registry falls back to - copy how `terminal` is carried; if the file is generated by a script, run the script) +- Modify: root `bun.lock` + any workspace glob is already covered by `packages/plugins/*` (verify, else add) +- Test: registry lists `ssh` among built-ins (extend the registry's existing built-in census test - grep `terminal` in `packages/pane-runtime/src/__tests__/` to find it) + +**Interfaces:** +- Consumes: `@subshell-ai/plugin-api` types exactly as the terminal plugin does. +- Produces: harness `id: "ssh"`, `type: "terminal"`, `detect: { binaryName: "ssh", envOverride: "SUBSHELL_SSH_PATH", knownPaths: ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"] }`, `capabilities: []`, `buildCommand({binary, preset, extraFlags}) => [binary, ...preset.flags, ...(extraFlags ?? [])]`. + +- [ ] **Step 1: `cat packages/plugins/terminal/package.json packages/plugins/terminal/src/index.ts packages/plugins/terminal/turbo.json packages/plugins/terminal/tsconfig.json`; create the ssh package as a faithful rename** (id/name `"ssh"`/`"SSH"`; description "An interactive SSH session to any sshd host."; the detect block exactly as in Interfaces above - `envOverride` `SUBSHELL_SSH_PATH` matches `apps/node/agent`'s operator seam, ported in Task 4's `ssh-shared.ts`; keep the "why this plugin declares a detect block at all" doc-comment adapted: ssh IS a binary the same way). +- [ ] **Step 2: The failing census test** - extend the built-ins test to assert `"ssh"` is among `allHarnesses()` ids and `getHarness("ssh").type === "terminal"`; run, watch it fail. +- [ ] **Step 3: Wire registry.ts + embedded-plugins**, re-run green. +- [ ] **Step 4: `bun install` (workspace), `bunx turbo build`, verify-types for pane-runtime; run the plugins-ssh test file.** Confirm `bun run lint:lockfile` clean (workspace version field exception - run `bun run lint:lockfile:fix` if bun recorded the new workspace). +- [ ] **Step 5: Commit** - `"feat(plugins): built-in ssh harness - the terminal launch, pointed at ssh"` + +--- + +### Task 4: Agent - discovery/resolve handlers behind the SSH gate + protocol bump + +**Files:** +- Create: `apps/node/agent/src/commands/ssh-shared.ts`, `apps/node/agent/src/commands/ssh-aliases.ts` +- Modify: `apps/node/agent/src/commands/index.ts` (dispatch arms), `apps/node/agent/src/commands/context.ts` (the `Cmd` map gains the two types - find its definition by grep `export type Cmd`) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NODE_PROTOCOL_VERSION` 16 to 17 + the changelog comment at `:100-150` per its own history rule) +- Test: Create `apps/node/agent/src/__tests__/commands-ssh.test.ts` + +**Interfaces:** +- Consumes: tier-1 gate (`readSshEnabled`, `sshAllowed` from `../ssh-enabled.js`), pane-runtime `discoverSshAliases` / `resolveSshAliasConfig` / `defaultSshConfigPath`, protocol `parseNodeSshAliasList` / `parseNodeSshResolveOutcome` (Task 1), `findBinary` from pane-runtime. +- Produces: `execSshDiscoverAliases(ctx): Promise`, `execSshResolveConfig(ctx, cmd): Promise`; both refuse `{ok:false, error:"ssh disabled on this node"}` unless the local mirror says ON (`sshAllowed(readSshEnabled(ctx.config.dataDir))`). + +- [ ] **Step 1: Read the reference handlers**: `git show feat/ssh-support:apps/node/agent/src/commands/ssh-shared.ts` and `...ssh-aliases.ts` in full (both are short; `ssh-shared.ts` was read during planning - `resolveSshBin` via `findBinary("ssh", "SUBSHELL_SSH_PATH", ["/usr/bin/ssh","/bin/ssh","/usr/local/bin/ssh","/opt/homebrew/bin/ssh"])`, `connectingHomeDir` = `process.env.HOME || homedir()`). +- [ ] **Step 2: Write the failing test file** covering, in the dispatch-test harness style of the existing `__tests__/commands-*.test.ts` (copy one's context-construction helper usage): gate file absent -> both handlers refuse by name; gate ON + fixture HOME containing a `.ssh/config` with two Host blocks -> discovery answers the sorted names through the validator; resolve with an injected fake `sshBin`... the resolve path spawns `ssh -G` - for the unit test drive ONLY discovery, and for resolve assert the refusal ordering (gate check precedes any spawn) plus the malformed-alias wire refusal at parse (Task 1 covered the parser; here: dispatch reaches the handler). The REAL `ssh -G` behavior against a fixture config is covered by the tier-1 resolver tests - do not re-test it here; the e2e (Task 9) exercises the composed path. +- [ ] **Step 3: Fail, then implement** both files, adapted: the reference's per-handler shape carries, plus the gate check FIRST (new, tier-2's §4.3 requirement) and the disclosure note: reference says disclosure belongs to the human-facing surface - re-point that comment at the tier-2 launch route (Task 7) which is where the disclosure rides this tier. +- [ ] **Step 4: Dispatch arms** in `commands/index.ts` + `Cmd` map entries; bump `NODE_PROTOCOL_VERSION = 17` with the history entry: `**16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve commands. A lagging agent is HELD (update-only) until crossed, per the capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md §4.3; the desktop update path is the crossing.` Check every test that pins the version number (grep the worktree for `NODE_PROTOCOL_VERSION` asserts, e.g. the e2e `ready` frame builders and node-frames tests) and update each with a comment naming the bump - a test that hardcodes 16 now fails loudly, which is the point. +- [ ] **Step 5: Green run**: `env -u SHELLOPTS -u BASHOPTS bun test apps/node/agent/src/__tests__/commands-ssh.test.ts` + every test file touched in step 4. +- [ ] **Step 6: Commit** - `"feat(agent): gated ssh discovery/resolve; protocol 17 for the ssh launch grammar"` + +--- + +### Task 5: Launch path - config write, cleanup, both launchers, `LaunchPlan.ssh` + +**Files:** +- Modify: `apps/server/api/src/services/nodes/node-launcher.ts` (`LaunchPlan.ssh?: { configPath: string; fileContent: string }` with doc) +- Modify: `apps/server/api/src/services/nodes/local-launcher.ts` (`launch(plan)`: when `plan.ssh`, before spawn: ensure `/ssh/` 0700 + write config 0600 with `enforceMode` (import from pane-runtime - `fs-mode.ts`), then compose the command string from `buildCommand` output + `shellQuote` per token (the terminal path's existing composition - follow exactly how `LocalLauncher.launch` builds its pane command and reuse it), env gets `SSH_AUTH_SOCK` when the launch service placed it in `subshellEnv` (no new field); local cleanup at subshell delete rides the existing artifact-removal call in the manager's delete path (read `subshell-manager.service.ts` delete/removeArtifacts region - add the config path to the artifact list it already composes: find `subshellArtifacts`).) +- Modify: `apps/server/api/src/services/nodes/remote-launcher.ts` (`launch` composes the frame's `ssh: {configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent}` member) +- Modify: `apps/node/agent/src/commands/launch.ts` (after the maintenance gate and allowlist check (the check runs on `cmd.cwd` - unchanged: the ssh launch's cwd is the node home via the presetless-terminal default, subject to the operator's allowlist like any terminal pane, decision 1), and AFTER the placeholder substitution + BEFORE meta record (step order note in the file header is contractual - amend the header list too): a config-write block mirroring the MCP block's posture but with the DERIVED path: `const sshPath = buildSshConfigPath(ctx.config.dataDir, cmd.subshellId)`; refuse unless `cmd.ssh.configPath === sshPath` (byte-equality: the plane may name nothing else); `pathAllowed` against `[ctx.config.dataDir]`; mkdir 0700 + write 0600 + `enforceMode` both.) +- Modify: `apps/node/agent/src/commands/report.ts` (`startExitWatcher`'s observation site: when the pane's meta `harnessId === "ssh"`, after the death is recorded, best-effort `rm -rf /ssh/` - log-only failure, NEVER refuse; find the observation code by reading report.ts around `runExitWatchTick`) +- Test: `apps/node/agent/src/__tests__/launch-ssh.test.ts`; `apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts` (or the launcher's existing test home). + +**Interfaces:** +- Consumes: Task 1's launch frame `ssh` member; Task 2's `buildSshConfigPath`, `shellQuote`; existing `assembleHarnessCommand`/command-string composition in both launchers. +- Produces: `LaunchPlan.ssh`; agent-written 0600 config at the byte-derived path; death cleanup both sides. + +- [ ] **Step 1: Read the four sites in full** before editing: `local-launcher.ts` (whole file), `remote-launcher.ts` `launch`, `commands/launch.ts` (in context), `report.ts` exit-watcher region. Record their real line numbers in your report - this plan's line cites came from planning-time reads and the files move under you. +- [ ] **Step 2: Failing tests.** Agent side: `execLaunch` with a well-formed ssh block -> file exists with mode 0600 and parent 0700, content byte-equal; `configPath` mismatch -> refusal + no file; block absent -> no dir created. Local side: `launch(plan with ssh)` writes under a temp dataDir the same derived path (LocalLauncher's own fs helpers), and the delete-path artifact list includes it (assert the list function's output). Exit cleanup: agent watcher unit - fake pane meta harnessId "ssh" + a real temp file -> gone after the tick; harnessId "claude-code" -> file untouched. +- [ ] **Step 3: Implement per Files above.** Amend `commands/launch.ts`'s step-order docblock (it enumerates the contract - the new step enters at the position it lands). +- [ ] **Step 4: Green focused run** + `verify-types` for `@internal/server` and `@internal/node`; commit `"feat(launch): ssh config written by the spawning machine at the byte-derived path, removed with the pane"`. + +--- + +### Task 6: DB - migration 0048 (`ssh_saved_hosts` + `subshells.ssh`), backup ceiling + +**Files:** +- Create: `apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts`, `apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts`, `apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts` +- Modify: `apps/server/api/src/db/types/subshells.db-types.ts`, `apps/server/api/src/db/index.ts` (Kysely Db type), the migration list import file (find how 0047 registers), `apps/server/api/src/services/backups/database.ts` (`LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"`), `apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts` (fixture strip-set now drops 0047 + 0048 rows/DDL from the ledger prefix - copy the pattern commit 0ebd6cee established; the older-snapshot fixture must remain a VALID contiguous prefix). +- Test-first per the tier-1 Task 4 pattern (the hand-built 0031-style test: fresh Kysely, run migrations, assert table + column exist, down() reverses). + +**Schema (final):** + +```ts +/** One saved or recently-used SSH destination for one owner (spec 2026-10-07 §7). Keyed + * by the RESOLVED canonical destination so an edited alias can never silently re-point + * a saved row. `saved_at` is what the human gave it; recency is `last_connect_at`, which + * every launch refreshes whether or not the row was ever saved. */ +export interface SshSavedHostTable { + id: string; // uuid + ownerUserId: string; // FK users, cascade + /** canonical `user@host:port` (user part = the resolved snapshot's effective user, or "" default spelled as the connecting account? NO: `host:port` with an empty user prefix when the snapshot's user is null) */ + destination: string; + /** display-only alias (the config token typed or discovered); never used as a key */ + alias: string | null; + /** the connecting machine used for the most recent launch to this destination */ + nodeId: string; + savedAt: string | null; + lastConnectAt: string; +} +// unique index (ownerUserId, destination); index (ownerUserId, lastConnectAt desc) +``` + +`subshells.ssh`: `string | null` - JSON-encoded `SshConnectionSnapshotWire` (the row's own approval, kept so the pane's kind and its re-render inputs outlive the request; never serialized to clients except the launch's own answer). Column comment: "an SSH-terminal pane carries its approved snapshot here; the value's presence is the owner-only-input rule's trigger (spec §5.4)." + +- [ ] Step 1 write the migration test (failing), Step 2 implement migration + types + registration, Step 3 advance the ceiling + rewrite the fixture strip-set (assert in the test's comment WHY: the ledger the fixture builds must stay contiguous through 0047), Step 4 green focused run (`env -u SHELLOPTS -u BASHOPTS bun test apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts`), Step 5 commit `"feat(db): ssh_saved_hosts + the subshells.ssh snapshot column (migration 0048)"`. + +--- + +### Task 7: Plane - the ssh service surface and `/api/ssh` routes + +**Files:** +- Create: `apps/server/api/src/services/nodes/ssh-rpc.ts`, `apps/server/api/src/services/ssh-launch.service.ts`, `apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts`, `apps/server/api/src/api/ssh/index.ts`, `api/ssh/aliases.route.ts`, `api/ssh/resolve.route.ts`, `api/ssh/launch.route.ts`, `api/ssh/saved-hosts.route.ts` +- Modify: `apps/server/api/src/api/index.ts` (mount `.use(sshRoutes)` on the EXISTING mounted app chain - do not create a new top-level Elysia instance: TS2589 on `App`, memory `elysia-app-type-depth-ceiling`), `apps/server/api/src/services/index.ts` (service registry, mirror ssh-enabled's absence there by following `nodes.service` wiring), `apps/server/api/src/api/models.ts` if response models are centralized (match the prompts routes' local-schema posture - co-located `t` schemas with descriptions are the rule). +- Test: `apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts` (cookie-auth harness like the nodes routes' tests; `sendCommand` injected/faked through the existing seam used by `set-node-ssh-enabled` tests). +- Docs: `docs/security.md` §10 + `.claude/rules/security-context.md`: add `ssh.launch` to the nodes-family line. + +**Route grammar (final; all cookie-actor, all after the existing auth guard):** + +``` +GET /api/ssh/aliases?node= -> 200 {aliases, includeCycle, truncated} | 403 {code: "ssh_gate_off", message names the machine's owner remedy} | 404 invisible | 502-ish mapped agent refusal +POST /api/ssh/resolve {node, alias} -> 200 NodeSshResolveOutcomeWire (refusal rides IN THE DATA per tier-1 grammar) +POST /api/ssh/launch {node, destination: string, name?: string} + -> 201 {subshell: SubshellView} ; refuses: 403 ssh_gate_off / 409 node_protocol_held / 422 {outcome: refusal} / 400 alias-unsafe +GET /api/ssh/saved-hosts -> {saved: [...], recent: [...], defaultNodeId: string|null} +PUT /api/ssh/saved-hosts {node, destination, alias?} -> upsert-mark-saved, returns the row +DELETE /api/ssh/saved-hosts/:id -> 204 (owner rows only; foreign 404) +PATCH /api/ssh/preferences {defaultNodeId: string|null} +``` + +**Service (`ssh-launch.service.ts`) responsibilities, in order, each pinned by test:** +1. Gate: `nodeCanSsh({kind, access, isAdmin, serverAccountEnabled, sshEnabled})` for the ACTING cookie user (its owner/admin-on-local answer straight from `loadNodeAccess` + the row); off -> the named refusal constant + copy (the predicate answers yes/no; this route names the cause, §14). +2. Held check: a held agent node -> 409 naming the update remedy. +3. `destination` is validated at the wire like an alias (1..253, no whitespace/control, no leading `-`); discovery answers and manual tokens take the SAME path: `sendCommand(node, {type:"ssh_resolve_config", alias: destination})`. +4. On `accepted:true`: the outcome's snapshot re-validated (the RPC wrapper already ran `parseNodeSshResolveOutcome`), then compose: `configPath = buildSshConfigPath(targetDataDir, newId)`; `fileContent = renderSshConfigContents(snapshot)`; `flags = sshOptionTokens(snapshot)` plus `-F configPath` FIRST; launch via `createSubshell({ userId, harnessId: "ssh", presetId: null, workingDir: `, name, nodeId })` with the two new plumbing params `ssh: { configPath, fileContent, snapshot }` and `presetFlags: flags`, both added to `createSubshell` in this task (see "manager plumbing" below). subshellEnv gains `SSH_AUTH_SOCK: snapshot.authAgentSocket` when non-null; `restartOnExit` forced off; row `ssh` = `JSON.stringify(snapshot)`. +5. Upsert the saved-host row (recency) and audit `ssh.launch` `{nodeId, destination, subshellId}` - AFTER a successful launch, never before (a refusal writes nothing to that trail beyond the pane-create trail the manager already handles... actually: audIT the launch even when it later fails to spawn? No: audit on success only, matching `subshell.create`'s own posture - name this in the code comment). + +**Manager plumbing (`subshell-manager.service.ts createSubshell`)**: extend the input object with `ssh?: { configPath: string; fileContent: string; snapshot: SshConnectionSnapshotWire }` and `presetFlags?: string[]`. Behavior: when `presetFlags` present, the launch preset is `{...EMPTY_PRESET, flags: presetFlags}` (fresh object - EMPTY_PRESET is a shared constant, never mutate); when `ssh` present, pass `ssh` into the `LaunchPlan` (both launcher impls), pass `SSH_AUTH_SOCK` merge into `subshellEnv` AT THE CALL SITE (the ssh service computes the merged env and passes it through createSubshell's existing env surface - if createSubshell does not accept env additions, this is the one place it gains a `extraPaneEnv?: Record` param; READ the function first and take the minimal honest plumbing), and write `ssh: JSON.stringify(...)` into the row insert. Prompt-typing: ssh launches never carry a prompt (route refuses `prompt` on launch bodies). + +- [ ] Step 1: the route/service tests (matrix above, faked `sendCommand` + faked node facts; assert gate order: gate-before-RPC (a node with the row off never receives a command); held-before-resolve; refusal-in-the-data 200 shape for resolve vs 422 for launch; saved-hosts list never carries another owner's rows; delete foreign -> 404; default node id set/clear.) +- [ ] Step 2: fail; implement repository (`touch(destination, nodeId, alias?)` upsert-refresh, `markSaved`, `listSaved/listRecent(limit 20)`, `remove`), rpc wrappers (`sshDiscover(sshNodeId)` / `sshResolve(...)` -> validated plain objects, throwing a named `SshRpcError` on a frame error), service, routes, mount; docs/security lines. +- [ ] Step 3: green focused + `bunx turbo build` (routes visible to treaty consumers) + `verify-types --filter=@internal/server`. +- [ ] Step 4: commit `"feat(api): the ssh launcher surface - gated discovery, resolve, launch, saved hosts"` + +--- + +### Task 8: Owner-only input for ssh panes (REST + MCP + WS) + +**Files:** +- Modify: `apps/server/api/src/api/subshells.ts` `requireInputAccess` (add after the share check: `if (row.ssh !== null && (actor === "subshell-key" || row.userId !== auth.user.id)) throw new HTTPResponse(...403 {code: SUBSHELL_SHARE_VIEW_ONLY-ish or a new code SSH_OWNER_INPUT_ONLY}...)` - reuse the error shape pattern at :237 with a new code name; ALSO the input route's response map near `:327` lists response types - add the new one (this is the input route gaining a key; watch the treaty consumers, rebuild). +- Modify: `apps/server/api/src/services/subshells.service.ts` `execInTerminal` (~`:958`) and `resolvePaneInputAccess` (~`:1708`) - the SAME rule; put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (route, exec, WS data) - "one question, one place." +- Modify: `apps/server/api/src/ws/live-subshell.ts` input branch (~`:370`): compute from the same predicate (the WS resolves a cookie identity; live is human-only already, `bearerActor` false). +- Test: extend `apps/server/api/src/api/subshells/__tests__/subshell-input-live.test.ts` and the exec test home with: ssh row + edit grantee -> 403 (code named), ssh row + owner -> 200, ordinary row + edit grantee -> unchanged 200, ssh row + the pane's OWN key -> 403 (decision 5). + +- [ ] Steps 1-4: failing tests -> implement -> green -> commit `"feat(api): ssh panes take input from their owner alone (spec §5.4)"` + +--- + +### Task 9: e2e - the Milestone 1 proof + +**File:** Create `e2e/tests/22-ssh-terminal.spec.ts`. Read first: `e2e/tests/17-nodes-e2e.spec.ts` (backend boot + admin), `e2e/fixtures/sshd.ts` (exports - it already spawns a loopback sshd + keys + a config alias; reuse, do not rebuild), one existing spec's cleanup posture. + +**Scenario (all local node, no agent link):** boot the e2e backend with `HOME=` and `SSH_AUTH_SOCK=` in its env ONLY within this spec (start `ssh-agent` + `ssh-add` via Bun.spawn at spec start; write `/.ssh/config` naming the fixture sshd under alias `e2e-dest`; the sshd fixture's own key trust lands in `/.ssh/known_hosts` - check the fixture's exports for what it already provisions, and extend the FIXTURE (not the spec) if it does not, so the next spec inherits a truthful helper). Steps: register admin; `PUT /api/nodes/local/ssh-enabled {on:true}`; `GET /api/ssh/aliases?node=local` contains `e2e-dest`; `POST /api/ssh/launch {node:"local", destination:"e2e-dest"}` -> 201; poll the pane's log through the existing capture/read route for the sshd fixture's shell banner/marker (run a command by TYPING owner input: `echo SSH-PANE-OK`); assert the log contains it. Then: second user with `edit` grant -> input 403 code SSH_OWNER_INPUT_ONLY; owner terminate; assert the config dir is gone (`/ssh/` absent) via fs. + +- [ ] Step 1: write the spec; Step 2: run `env -u SHELLOPTS -u BASHOPTS bun run test:e2e 22-ssh-terminal` (e2e scripts live in package.json - find the invocation, run ONLY this spec file); iterate until green WITH the sshd binaries present (this host has them; if `sshd` cannot start unprivileged the fixture already handles skip-loud - do not paper a skip over a failure you could fix); Step 3: commit `"test(e2e): ssh pane end to end - gated launch, owner input typed over the wire, sharee refused, files cleaned"`. + +--- + +### Task 10: Boundary - full verification, changeset, PR + +- [ ] Full trio + prose + tests (verification.md): `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` - fix everything; expect the tier-1-known pre-existing `e2e-cross-subshell` environmental failure ONLY (re-verify it still fails at base `c5a04be6` before blaming it, per the ledger's provenance rule). +- [ ] `.changeset/ssh-launcher-tier.md`: `@internal/server`, `@internal/node`, `@internal/subshell-protocol`, `@internal/pane-runtime` minor. Deployment sentence: server before nodes; a pre-17 agent is held (offline for everything but update) until it crosses on the update command; the SSH gate itself still defaults off everywhere - nothing changes for anyone until a node is enabled. +- [ ] `bun run lint:lockfile` clean; `git push -u origin feat/ssh-anywhere-2`; `gh pr create --base feat/ssh-anywhere-1` naming the pre-existing failure with its provenance. Do NOT merge, do NOT auto-merge (memory: gh auto-merge merges immediately). +- [ ] Whole-branch review loop (requesting-code-review; package via `git diff feat/ssh-anywhere-1...HEAD` redirected to a file; quote-verify every finding before acting; rounds until zero MAJOR+MINOR). From 4eeea5ce40c7d4bfd87f66951b1831a97b3296a5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:01:23 -0700 Subject: [PATCH 025/170] feat(protocol): ssh command arms delegate to the tier-1 grammar, ssh launch block, result validators join node-results --- .../src/__tests__/node-frames.test.ts | 36 ++++++++++++++ .../src/__tests__/node-results-ssh.test.ts | 46 +++++++++++++++++ packages/subshell-protocol/src/index.ts | 11 +++++ packages/subshell-protocol/src/node-frames.ts | 38 +++++++++++++- .../subshell-protocol/src/node-results.ts | 49 ++++++++++++++++++- packages/subshell-protocol/src/ssh-frames.ts | 13 +++-- packages/subshell-protocol/src/ssh-limits.ts | 3 ++ packages/subshell-protocol/src/ssh-results.ts | 6 +-- 8 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 474a10261..7459f3798 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,6 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; +import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -906,6 +907,41 @@ describe("ssh gate (arrived at protocol 16)", () => { }); }); +describe("ssh command arms and the launch ssh block", () => { + it("ssh discovery/resolve arms delegate to the ssh grammar", () => { + expect(parseNodeCommandBody({ type: "ssh_discover_aliases" })).toEqual({ type: "ssh_discover_aliases" }); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "box-a" })).toEqual({ + type: "ssh_resolve_config", + alias: "box-a", + }); + // alias hygiene lives in ssh-frames' parser (port-verified): option-like and whitespace are refused there + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "-x" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); + }); + + it("launch accepts an ssh block and refuses malformed ones", () => { + const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; + expect(parseNodeCommandBody(good)).toMatchObject({ + type: "launch", + ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" }, + }); + expect(parseNodeCommandBody({ ...launchCmd, ssh: { configPath: "relative", fileContent: "Host *\n" } })).toBeNull(); + expect( + parseNodeCommandBody({ + ...launchCmd, + ssh: { configPath: "/d/ssh/s1/config", fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES + 1) }, + }), + ).toBeNull(); + expect( + parseNodeCommandBody({ + ...launchCmd, + ssh: { configPath: "/d/ssh/s1/config", fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES) }, + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...launchCmd, ssh: "x" })).toBeNull(); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts new file mode 100644 index 000000000..b4a013a1f --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; + +describe("parseNodeSshAliasList", () => { + test("narrows a well-formed answer", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ + aliases: ["box-a", "box-b"], + includeCycle: false, + truncated: false, + }); + }); + test("refuses a past-cap list whole (the truncated flag depends on the cap)", () => { + expect( + parseNodeSshAliasList(makeAliasList({ aliases: Array.from({ length: 501 }, (_, i) => `h${i}`) })), + ).toBeNull(); + }); + test("refuses non-boolean flags and non-string members", () => { + expect(parseNodeSshAliasList({ aliases: ["x"], includeCycle: "no", truncated: false })).toBeNull(); + expect(parseNodeSshAliasList({ aliases: [1], includeCycle: false, truncated: false })).toBeNull(); + }); +}); + +describe("parseNodeSshResolveOutcome", () => { + test("accepted arm runs the FULL snapshot validator (no unvalidated snapshot enters the plane)", () => { + const ok = makeResolveOk(); + expect(parseNodeSshResolveOutcome(ok)).not.toBeNull(); + const tampered = structuredClone(ok); + (tampered as { snapshot: { host: string } }).snapshot.host = "-oProxyCommand=evil"; + expect(parseNodeSshResolveOutcome(tampered)).toBeNull(); + }); + test("connectingAccount is optional text, never another type", () => { + expect(parseNodeSshResolveOutcome(makeResolveOk())?.accepted === true).toBe(true); + const withAccount = structuredClone(makeResolveOk()) as { connectingAccount?: unknown }; + withAccount.connectingAccount = 7; + expect(parseNodeSshResolveOutcome(withAccount)).toBeNull(); + }); + test("refused arm names a known code and string settings", () => { + expect(parseNodeSshResolveOutcome(makeResolveRefused())).toEqual({ + accepted: false, + code: "unsupported_setting", + settings: ["ProxyCommand"], + }); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 8a2002a56..31bf6ab96 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -144,6 +144,8 @@ export { parseNodePathExistsResult, parseNodeProbeEntries, parseNodePromptDeliver, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, parseNodeStatDirResult, parseNodeTreeManifestPage, parseNodeWriteFileResult, @@ -241,9 +243,18 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + parseSshNodeCommandBody, + SSH_COMMAND_TYPES, + type SshDiscoverAliasesCommand, + type SshNodeCommandBody, + type SshResolveConfigCommand, +} from "./ssh-frames.js"; +export { + SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, } from "./ssh-limits.js"; export type { diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 855ca6c03..02a2cb8df 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1,5 +1,7 @@ import { BASE64_RE, isBool, isInt, isNum, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import type { JsonValue } from "./json.js"; +import { parseSshNodeCommandBody, type SshNodeCommandBody } from "./ssh-frames.js"; +import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -672,6 +674,16 @@ export type NodeCommandBody = * node-supplied: it is self-knowledge, not plugin knowledge. */ mcp?: { path: string; fileContent: string; args?: string[]; env?: Record }; + /** + * The ssh pane's rendered config: the content the plane composed from + * the approved snapshot, plus the absolute node-side path the agent + * writes it to. The file-placement contract is the `mcp` block's: 0600 + * in a 0700 dir, derived from the node's own `dataDir`, written before + * spawn, and the exit watch deletes the derived path beside the pane's + * other artifacts. The pane's argv names `-F` at this path; present + * only for ssh launches, absent for every harness pane. + */ + ssh?: { configPath: string; fileContent: string }; /** Resume pin for harnesses that support it */ harnessSession?: HarnessSessionWire; /** tmux subshell name (the subshell id) */ @@ -1103,7 +1115,12 @@ export type NodeCommandBody = * sizing round trips is the caller that knows the frame cap. */ maxBytes: number; - }; + } + // The SSH discovery/resolve reads: two commands folded in as one union + // member so the SSH grammar lives in `ssh-frames.ts` beside the commands it + // narrows; their answers are validated by the two ssh result parsers + // appended to `node-results.ts` in the same tier. + | SshNodeCommandBody; /** * One node's maintenance state, as it travels in either direction. @@ -1447,6 +1464,20 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { if ("args" in m && !isStrArray(m.args)) return null; if ("env" in m && !isStringMap(m.env)) return null; } + if ("ssh" in value) { + const ssh = value.ssh as unknown; + if ( + typeof ssh !== "object" || + ssh === null || + typeof (ssh as { configPath?: unknown }).configPath !== "string" || + !(ssh as { configPath: string }).configPath.startsWith("/") || + (ssh as { configPath: string }).configPath.length > SSH_PATH_MAX_CHARS || + typeof (ssh as { fileContent?: unknown }).fileContent !== "string" || + (ssh as { fileContent: string }).fileContent.length > SSH_CONFIG_FILE_MAX_BYTES + ) { + return null; + } + } if ("harnessSession" in value) { const h = value.harnessSession; if (!isRecord(h) || !isStr(h.id) || (h.mode !== "start" && h.mode !== "resume")) return null; @@ -1698,6 +1729,11 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { return "cursor" in value ? { type: "tree_manifest", root: value.root, cursor: value.cursor as string, maxBytes: value.maxBytes } : { type: "tree_manifest", root: value.root, maxBytes: value.maxBytes as number }; + case "ssh_discover_aliases": + case "ssh_resolve_config": + // Delegation, not a second parser: the SSH grammar (both arms, one + // file) lives in ssh-frames.ts beside the commands it narrows. + return parseSshNodeCommandBody(value); default: return null; } diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 2d68968c7..de2d1ea29 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -19,8 +19,12 @@ * validators stand on live in `guards.ts` (shared with `node-frames.ts`). */ -import { BASE64_RE, isBool, isInt, isRecord, isStr, isStringMap } from "./guards.js"; +import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; +import { parseSshConnectionSnapshot } from "./ssh-config.js"; +import { isSshErrorCode } from "./ssh-errors.js"; +import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; +import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -537,3 +541,46 @@ export function parseNodeAgentLogSlice(data: unknown): NodeAgentLogSlice | null if (nextByte < 0 || size < 0) return null; return { text, nextByte, size, truncated }; } + +/* ------------------------------------------------------------------ */ +/* ssh discovery/resolution (the tier-1 wire types, dispatched from */ +/* here on) */ +/* ------------------------------------------------------------------ */ + +/** + * Validates and narrows an `ssh_discover_aliases` command's `result{data}`. + * The list cap is part of the shape (same reasoning as the transfer page + * cap): an answer past {@link SSH_MAX_DISCOVERED_ALIASES} is malformed here, + * because the `truncated` flag is what the cap's existence depends on. + * @param data - the `data` member of a successful result frame + * @returns the narrowed list, or null when malformed + */ +export function parseNodeSshAliasList(data: unknown): NodeSshAliasListResult | null { + if (!isRecord(data) || !isBool(data.includeCycle) || !isBool(data.truncated)) return null; + if (!isStrArray(data.aliases) || (data.aliases as string[]).length > SSH_MAX_DISCOVERED_ALIASES) return null; + return { aliases: [...(data.aliases as string[])], includeCycle: data.includeCycle, truncated: data.truncated }; +} + +/** + * Validates and narrows an `ssh_resolve_config` command's `result{data}`. + * The accepted arm runs the FULL snapshot validator: a resolve answer is the + * one moment an unvalidated snapshot could enter the system, so this is where + * the plane's copy gets its grammar checked before it is stored. + * @param data - the `data` member of a successful result frame + * @returns the narrowed outcome, or null when malformed + */ +export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcomeWire | null { + if (!isRecord(data) || !isBool(data.accepted)) return null; + if (data.accepted) { + const snapshot = parseSshConnectionSnapshot(data.snapshot); + if (!snapshot) return null; + if ("connectingAccount" in data && !isStr(data.connectingAccount)) return null; + return { + accepted: true, + snapshot, + ...("connectingAccount" in data ? { connectingAccount: data.connectingAccount as string } : {}), + }; + } + if (!isSshErrorCode(data.code) || !isStrArray(data.settings)) return null; + return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; +} diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index e856d4834..e71c91a73 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -17,9 +17,9 @@ * the transport's, and they live outside the body. * * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, so - * it COULD join the Metro-safe barrel, but does not yet: on this tier the - * grammar fixtures' type imports are its only consumer, and Plan 2 exports - * and dispatches it with the ssh RPC verbs. + * it rides the Metro-safe barrel with the rest of the grammar. The wiring + * landed with the launcher tier: `parseNodeCommandBody` dispatches these two + * arms here, and `node-results.ts` validates their answers. */ import { isRecord, isStr } from "./guards.js"; @@ -68,10 +68,9 @@ function isAliasName(value: unknown): value is string { } /** - * Validates and narrows any `ssh_*` command body. Plan 2 routes - * `parseNodeCommandBody`'s two `type` arms here, so the SSH grammar lives in - * ONE file beside the commands it narrows; until that wiring lands no - * `ssh_*` frame is dispatched on this branch. The contract this upholds is + * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` + * routes its two `type` arms here, so the SSH grammar lives in ONE file + * beside the commands it narrows. The contract this upholds is * the same as the node commands' today: a NON-null return is safe to switch * on by `type`. * diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index bad12756c..20803a982 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -68,6 +68,9 @@ export const SSH_NAME_MAX_CHARS = 253; /** Longest absolute POSIX path the wire carries (Linux's PATH_MAX is 4096; a path needing more is not ours to route). */ export const SSH_PATH_MAX_CHARS = 4094; +/** Largest ssh_config the wire carries in one `launch.ssh.fileContent` member (a rendered snapshot config is hundreds of bytes; this bounds a hostile plane). */ +export const SSH_CONFIG_FILE_MAX_BYTES = 65_536; + /** * Grace period a SIGTERM gets before the group is SIGKILLed. Born with the * structured runs, kept because the session supervisor kills the brokered diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index 120afa595..0a246172f 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -5,9 +5,9 @@ import type { SshErrorCode } from "./ssh-errors.js"; * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; * exported NAMES unchanged). The test/run/control envelopes retired with the - * destination product (design 2026-10-05 §7). Their `parse*` validators join - * `node-results.ts` beside every other result validator when Plan 2 wires the - * ssh RPC verbs; none of them exists on this branch yet. + * destination product (design 2026-10-05 §7). Their `parse*` validators + * (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`) live in + * `node-results.ts` beside every other result validator. * * Imports no `node:` builtin; lives in the Metro-safe barrel. */ From 4e40b914478cf385f42821e1d67a77e0d6543321 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:06:29 -0700 Subject: [PATCH 026/170] feat(pane-runtime): interactive ssh renderer - every-hop confinement, terminal auth left to the server --- packages/pane-runtime/src/index.ts | 6 + .../src/ssh/__tests__/ssh-render.test.ts | 244 ++++++++++++++++++ packages/pane-runtime/src/ssh/ssh-render.ts | 212 +++++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-render.ts diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 919629169..59034617f 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -149,6 +149,12 @@ export { type SshWalkBudget, walkSshConfig, } from "./ssh/ssh-discover.js"; +export { + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "./ssh/ssh-render.js"; export { parseProxyHop, resolveSshAliasConfig, diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts new file mode 100644 index 000000000..be8634104 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildSshConfigPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens } from "../ssh-render.js"; +import { makeSnapshot } from "./helpers.js"; + +/** + * The renderer is where the M1 every-hop policy becomes bytes (spec + * 2026-10-07 §5.2/§9). The tests below are the policy's regression net: every + * retained confinement clause ("host-key trust with accept-new, no agent/X11 + * forwarding, no forwards, no local commands, no escapes, no multiplexing, no + * imported RemoteCommand/SendEnv/SetEnv") must appear in the rendered + * artifact, the option argv must carry ONLY destination-scoped facts, and the + * interactive posture must show: no BatchMode, no refused auth methods, no + * /dev/null trust stand-in. + */ + +/** The base snapshot: a grammatically valid approved snapshot; override any field. */ +const baseSnapshot = makeSnapshot; + +describe("renderSshConfigContents", () => { + const config = renderSshConfigContents(baseSnapshot()); + + it("Host * carries the interactive-terminal policy, not BatchMode", () => { + const out = renderSshConfigContents(baseSnapshot()); + expect(out).toContain(" StrictHostKeyChecking accept-new"); + expect(out).not.toContain("BatchMode"); + expect(out).not.toContain("PasswordAuthentication no"); + expect(out).not.toContain("UserKnownHostsFile /dev/null"); // default known_hosts is the M1 authority (spec §9) + expect(out).toContain(" ForwardAgent no"); + expect(out).toContain(" ControlPath none"); + expect(out).toContain(" EscapeChar none"); + expect(out).toContain(" RemoteCommand none"); + expect(out).toContain(" CanonicalizeHostname no"); + }); + + it("carries the retained every-hop policy under Host * (which the jump children read too)", () => { + expect(config).toContain("Host *"); + const required: [string, string][] = [ + ["StrictHostKeyChecking", "accept-new"], + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ForwardX11Trusted", "no"], + ["ClearAllForwardings", "yes"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + ["PermitLocalCommand", "no"], + ["RemoteCommand", "none"], + ["EscapeChar", "none"], + ["ControlMaster", "no"], + ["ControlPath", "none"], + ["GSSAPIAuthentication", "no"], + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], + ]; + for (const [key, value] of required) { + expect(config).toContain(` ${key} ${value}`); + } + // The interactive session offers every auth method the SERVER offers: the + // tier's refusals must not come back (BatchMode's comment says why). + expect(config).not.toContain("KbdInteractiveAuthentication no"); + expect(config).not.toContain("HostbasedAuthentication no"); + // The removed Kerberos* alias must never come back: recent OpenSSH no + // longer resolves it (10.2p1 warns on every launch and continues), and + // GSSAPIAuthentication above is the spelling that actually disables that + // auth path. + expect(config).not.toContain("KerberosAuthentication"); + }); + + it("known-hosts refs render when the snapshot names them", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/theo/.ssh/known_hosts"] })); + expect(out).toContain(" UserKnownHostsFile /home/theo/.ssh/known_hosts"); + }); + + it("renders no UserKnownHostsFile at all when the snapshot names none (silence IS the policy, §9)", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: [] })); + expect(out).not.toContain("UserKnownHostsFile"); + }); + + it("renders identity/certificate refs under Host * so EVERY hop authenticates from approved material", () => { + const rendered = renderSshConfigContents( + baseSnapshot({ identityFiles: ["/home/deploy/.ssh/id_ed25519"], certificateFiles: ["/home/deploy/.ssh/id.pub"] }), + ); + expect(rendered).toContain(" IdentityFile /home/deploy/.ssh/id_ed25519"); + expect(rendered).toContain(" CertificateFile /home/deploy/.ssh/id.pub"); + }); + + it("identity/cert refs render quoted only when they need quoting", () => { + const rendered = renderSshConfigContents( + baseSnapshot({ + identityFiles: ["/home/deploy/my keys/id_ed25519"], + certificateFiles: ['/opt/we"ird/cert.pub'], + knownHostsFiles: ["/home/deploy/my hosts/known_hosts", "/plain/path"], + }), + ); + // OpenSSH's config tokenizer splits values on whitespace and honors + // double quotes with backslash escapes — that IS the quoting accepted. + expect(rendered).toContain(' IdentityFile "/home/deploy/my keys/id_ed25519"'); + expect(rendered).toContain(' CertificateFile "/opt/we\\"ird/cert.pub"'); + expect(rendered).toContain(' UserKnownHostsFile "/home/deploy/my hosts/known_hosts"'); + expect(rendered).toContain(" UserKnownHostsFile /plain/path"); // no churn for bytes that need none + }); + + it("ships its header with no em dash (the voice rule covers generated strings)", () => { + expect(config).toContain("# Subshell managed SSH config - GENERATED, do not edit."); + expect(config).not.toContain("—"); + }); + + it("never renders ProxyCommand or any forbidden concept", () => { + for (const word of ["ProxyCommand", "LocalForward", "RemoteForward", "DynamicForward", "SendEnv", "SetEnv"]) { + // The ONLY occurrence of the policy line words is the refusal line we DO render + // (RemoteCommand none etc.); a command VALUE must never appear. + const occurrences = config.split(word).length - 1; + if (word === "RemoteCommand") { + expect(occurrences).toBe(1); // the `RemoteCommand none` policy line itself + } else { + expect(occurrences).toBe(0); + } + } + expect(config).toContain("RemoteCommand none"); + }); + + it("refuses to render a hand-built snapshot carrying a forbidden member", () => { + const smuggled = { ...baseSnapshot(), proxyCommand: "nc attacker 4444" } as unknown as ReturnType< + typeof baseSnapshot + >; + expect(() => renderSshConfigContents(smuggled)).toThrow("not renderable"); + }); +}); + +describe("sshOptionTokens", () => { + it("sshOptionTokens is destination-scoped only", () => { + const tokens = sshOptionTokens( + baseSnapshot({ port: 2222, user: "root", proxyJumps: [{ host: "j1", user: null, port: 22 }] }), + "/data/ssh/s1/config", + ); + expect(tokens).toEqual(["-F", "/data/ssh/s1/config", "-p", "2222", "-l", "root", "-o", "ProxyJump=j1"]); + }); + + it("points -F at the rendered file and passes ONLY destination-scoped options", () => { + const tokens = sshOptionTokens( + baseSnapshot({ hostKeyAlias: "app02-web", port: 2222 }), + "/var/lib/subshell/ssh/runs/r1/config", + ); + expect(tokens.slice(0, 2)).toEqual(["-F", "/var/lib/subshell/ssh/runs/r1/config"]); + expect(tokens).toContain("-p"); + expect(tokens[tokens.indexOf("-p") + 1]).toBe("2222"); + expect(tokens).toContain("-l"); + expect(tokens[tokens.indexOf("-l") + 1]).toBe("deploy"); + expect(tokens).toContain("HostKeyAlias=app02-web"); + // policy options are NOT command-line options: they live in the -F file + // (the jump children read the file, never the parent's argv). + expect(tokens.every((a) => !a.startsWith("StrictHostKeyChecking") && !a.startsWith("ForwardAgent"))).toBe(true); + // the pane's PTY comes from tmux, so ssh auto-requests a remote tty: no -tt rides. + expect(tokens).not.toContain("-tt"); + // the `--` terminator and the host are NOT option tokens (sshDestinationToken carries the tail). + expect(tokens).not.toContain("--"); + expect(tokens).not.toContain("app-02.example.com"); + }); + + it("omits -l when the snapshot names no user, and HostKeyAlias when it names none", () => { + const tokens = sshOptionTokens(baseSnapshot({ user: null, hostKeyAlias: null }), "/x/config"); + expect(tokens).not.toContain("-l"); + expect(tokens.every((a) => !a.startsWith("HostKeyAlias="))).toBe(true); + expect(tokens[0]).toBe("-F"); + expect(tokens[2]).toBe("-p"); // -p is ALWAYS explicit: the port is a resolved fact, never ssh's guess + }); + + it("normalizes the whole ProxyJump chain into the approved hop list", () => { + const tokens = sshOptionTokens( + baseSnapshot({ + proxyJumps: [ + { host: "jump.example.com", user: "ops", port: 22 }, + { host: "[2001:db8::1]", user: null, port: 2222 }, + ], + }), + "/x/config", + ); + expect(tokens[tokens.indexOf("ProxyJump=ops@jump.example.com,[2001:db8::1]:2222") - 1]).toBe("-o"); + expect(tokens).not.toContain("-W"); // the chain is ssh's mechanism, ours is the approved list + }); + + it("refuses an option-like or empty config path", () => { + expect(() => sshOptionTokens(baseSnapshot(), "-o evil")).toThrow(/absolute/); + expect(() => sshOptionTokens(baseSnapshot(), "")).toThrow(/absolute/); + }); + + it("refuses an unrenderable snapshot at the last station before argv", () => { + expect(() => + sshOptionTokens({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never, "/data/ssh/s1/config"), + ).toThrow(/not renderable/); + expect(() => + sshOptionTokens( + { ...baseSnapshot(), proxyJumps: [{ host: "-jump", user: null, port: 22 }] } as never, + "/data/ssh/s1/config", + ), + ).toThrow(/not renderable/); + }); +}); + +describe("sshDestinationToken", () => { + it("is the bare host, undecorated (the `--` tail is one argv entry)", () => { + expect(sshDestinationToken(baseSnapshot())).toBe("app-02.example.com"); + expect(sshDestinationToken(baseSnapshot({ host: "[2001:db8::1]", user: "root", port: 2222 }))).toBe( + "[2001:db8::1]", + ); + }); + + it("refuses an unrenderable snapshot", () => { + expect(() => sshDestinationToken({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never)).toThrow( + /not renderable/, + ); + }); +}); + +describe("buildSshConfigPath", () => { + it("composes the derived path from the two facts each side already holds", () => { + expect(buildSshConfigPath("/data", "s1")).toBe("/data/ssh/s1/config"); + expect(buildSshConfigPath("/var/lib/subshell", "00000000-0000-4000-8000-000000000001")).toBe( + "/var/lib/subshell/ssh/00000000-0000-4000-8000-000000000001/config", + ); + }); + + it("throws on a relative or empty dataDir (impossible-state guard, never a composed path)", () => { + expect(() => buildSshConfigPath("data", "s1")).toThrow(/absolute/); + expect(() => buildSshConfigPath("", "s1")).toThrow(/absolute/); + }); + + it("throws on an id outside the grammar", () => { + expect(() => buildSshConfigPath("/d", "")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "s 1")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "../escape")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "a".repeat(65))).toThrow(/subshellId/); + expect(buildSshConfigPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/config`); + }); +}); + +describe("Metro purity", () => { + it("ssh-render.ts imports no node: builtin (the pane-runtime barrel rule)", () => { + const source = readFileSync(join(import.meta.dir, "..", "ssh-render.ts"), "utf8"); + expect(source).not.toMatch(/from\s+"node:/); + expect(source).not.toMatch(/require\(\s*"node:/); + }); +}); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts new file mode 100644 index 000000000..cbadd52e9 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -0,0 +1,212 @@ +import { + parseSshConnectionSnapshot, + SSH_PATH_MAX_CHARS, + type SshConnectionSnapshotWire, + type SshHopWire, +} from "@internal/subshell-protocol"; + +/** + * The runtime renderer: an approved snapshot in, option argv + own config file + * out (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md §5.2: render a + * per-connection config from the resulting snapshot; the built `ssh` runs + * against the rendered snapshot config via `-F` only, never the connecting + * machine's live `~/.ssh/config`). + * + * Two artifacts, one policy: + * + * - **The config file** (`renderSshConfigContents`) carries the mandatory + * policy under `Host *`. This is NOT belt-and-suspenders over the command + * line: ssh spawns ProxyJump children (`ssh -W …`) that do NOT inherit the + * parent's `-o` options, and the jump child re-reads the `-F` file. A + * policy written only on the command line would restrict the last hop and + * nothing above it. The `Host *` shape is what makes the policy + * EVERY-HOP. + * - **The argv** (`sshOptionTokens` + `sshDestinationToken`) carries only the + * DESTINATION-scoped facts (`-F`, port, user, HostKeyAlias, ProxyJump chain) + * — options the jump children must NOT inherit — plus `-F` so the user + * config the whole chain reads is ours. + * + * Config keyword precedence is the second reason this split works: within one + * ssh invocation the command line wins over the user file, which wins over + * the system file, PER KEYWORD (first-obtained wins). Every key the policy + * names appears in our file, so `/etc/ssh/ssh_config` cannot reintroduce a + * forwarding directive, a control socket, or canonicalization for this + * connection's hops. Keywords the policy does not name and a system file + * could set remain root's to decide — inside the OS trust boundary, per + * docs/security.md §0-1, and resolution already ran `-G` WITH that system + * file, so what it refused, the snapshot never carries. + * + * The snapshot is re-validated on entry: the renderer's refusal set is + * "anything the grammar cannot express", and the grammar is the protocol's, + * so renderer and wire can never disagree about what is renderable. + */ + +/** + * Refuse to render anything the frozen grammar rejects (a hand-built object + * never reaches the child). The check IS `parseSshConnectionSnapshot`: the + * same rebuild the wire performs, so renderer and contract can never + * disagree about what is renderable, and grammar growth on the protocol side + * lands here automatically. The option-like leading dashes ride on top: the + * grammar's own doc names them ssh-argv material, and the renderer is the + * last station before argv. + */ +function assertRenderable(snapshot: SshConnectionSnapshotWire): void { + if (parseSshConnectionSnapshot(snapshot) === null) throw new Error("snapshot not renderable"); + if (snapshot.host.startsWith("-") || snapshot.proxyJumps.some((h) => h.host.startsWith("-"))) { + throw new Error("snapshot not renderable"); + } +} + +/** + * The mandatory every-hop policy, as `Host *` lines. This is the M1 + * interactive-terminal policy (spec 2026-10-07 §5.2/§9): every hop stays + * confined (forwarding, control sockets, local commands, the escape menu all + * die here because ssh spawns ProxyJump children that re-read THIS file), + * while the session itself stays a terminal: auth methods are the server's to + * offer and a changed host key is OpenSSH's own hard block. Each entry is + * that sentence made concrete; the comments name which. + */ +const MANDATORY_POLICY: readonly [string, string][] = [ + // first connect records the key in the machine's own known_hosts; a CHANGED + // key is refused by OpenSSH itself (spec §9: accept-new is the M1 posture) + ["StrictHostKeyChecking", "accept-new"], + // no agent or X11 forwarding (the §2 "no agent/X11 forwarding" sentence) + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ForwardX11Trusted", "no"], + // no local/remote/dynamic forwards or tunnels, whatever any config said + ["ClearAllForwardings", "yes"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + // no local commands on any branch (§2 "no local commands") + ["PermitLocalCommand", "no"], + // the command contract owns the remote side; no imported remote command / env (§2 "Do not import RemoteCommand, SendEnv, SetEnv") + ["RemoteCommand", "none"], + // no escape menu — `~` is a local shell (§2 "no SSH escape commands") + ["EscapeChar", "none"], + // no ambient control sockets or multiplexing (§2's explicit clause) + ["ControlMaster", "no"], + ["ControlPath", "none"], + ["GSSAPIAuthentication", "no"], + // trust is host-key trust only: DNS- and fetch-based checks are not + // host-key trust, and canonicalization could send the connection to a host + // the reviewer never approved + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], +]; + +/** A destination or hop as ssh argv syntax: `user@host`, `host:port`, brackets for IPv6 kept. */ +function hopToken(hop: SshHopWire): string { + const who = hop.user !== null ? `${hop.user}@` : ""; + const port = hop.port !== 22 ? `:${hop.port}` : ""; + return `${who}${hop.host}${port}`; +} + +/** + * A file-valued ssh_config token as OpenSSH's own tokenizer accepts it: the + * config parser splits values on WHITESPACE (control chars are already + * refused by the snapshot grammar), so an absolute path carrying a space — + * legal POSIX, legal `isAbsPosixPath` — would otherwise misparse into two + * tokens (a truncated identity file plus garbage). Double quotes with + * backslash escapes are the quoting OpenSSH's strdelim honors for + * file-valued keywords; the escape is applied before the wrap so the value + * is byte-exact after the parser's unquote. Plain paths stay unquoted: the + * rendered file is byte-stable in the snapshot (a dedup record's config is + * re-read by digest), and quoting every path would churn bytes for no gain. + */ +function configPathValue(path: string): string { + if (!/[\s"\\]/.test(path)) return path; + return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** One config line for each known-hosts ref the snapshot names; absent names render nothing. */ +function knownHostsLines(files: string[]): string[] { + // absent renders nothing: ssh's own default `~/.ssh/known_hosts` is the M1 + // trust store (§9) - the tier's product wrote /dev/null because its + // BatchMode posture made silence fail closed; here silence IS the policy. + if (files.length === 0) return []; + return files.map((f) => ` UserKnownHostsFile ${configPathValue(f)}`); +} + +/** + * Render the runtime ssh_config FILE for one snapshot: the policy above, + * every hop included, plus the snapshot's trust refs and identity refs under + * the same `Host *` so jump children authenticate from the same approved + * material (`SshHopWire`'s doc: "authentication for the whole chain comes + * from the snapshot's own refs"). Contents are deterministic in the snapshot + * — the same approval always renders the same file, which is what makes the + * run's recorded config byte-checkable. + */ +export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): string { + assertRenderable(snapshot); + const lines = [ + "# Subshell managed SSH config - GENERATED, do not edit.", + "# Mandatory runtime policy for the connection and every ProxyJump hop (§2).", + "Host *", + ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${value}`), + ...knownHostsLines(snapshot.knownHostsFiles), + ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), + ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), + "", + ]; + return lines.join("\n"); +} + +/** + * Build ssh's option argv for one snapshot: EVERYTHING after the binary + * EXCEPT the trailing `-- host` tail ({@link sshDestinationToken} carries + * that one entry). `-F ` first — the caller composed the path + * ({@link buildSshConfigPath}) and owns the write — then `-p` ALWAYS (the + * port is a resolved fact, never ssh's guess), `-l` only when the snapshot + * names a user, and `HostKeyAlias`/`ProxyJump` only when set. No `-tt`: this + * pane allocates its PTY through tmux and carries no remote command, so + * OpenSSH auto-requests the remote tty. + * + * EVERY element is a string this function composed from snapshot-scoped + * values; the destination never comes from a config string, never from `~`, + * never from PATH (the binary itself is an absolute argv[0] the caller + * resolved). Option-like destinations are impossible by grammar (the snapshot + * validator refuses a leading `-`) plus re-checked here + * ({@link assertRenderable}); the `--` terminator is the last word to getopt + * anyway. Policy options deliberately DO NOT ride here: they live in the + * `-F` file the jump children re-read. + */ +export function sshOptionTokens(snapshot: SshConnectionSnapshotWire, configPath: string): string[] { + assertRenderable(snapshot); + if (configPath.length === 0 || !configPath.startsWith("/") || configPath.length > SSH_PATH_MAX_CHARS) { + throw new Error("config path must be absolute POSIX"); + } + const argv = ["-F", configPath]; + argv.push("-p", String(snapshot.port)); + if (snapshot.user !== null) argv.push("-l", snapshot.user); + if (snapshot.hostKeyAlias !== null) argv.push("-o", `HostKeyAlias=${snapshot.hostKeyAlias}`); + if (snapshot.proxyJumps.length > 0) { + argv.push("-o", `ProxyJump=${snapshot.proxyJumps.map(hopToken).join(",")}`); + } + return argv; +} + +/** + * The destination as the single argv entry that follows the `--` terminator: + * ssh's bare host, never a `user@`/`:port` decoration (user and port ride as + * their own option tokens). Refuses the same way {@link sshOptionTokens} + * does: the two halves of the argv share one renderability verdict. + */ +export function sshDestinationToken(snapshot: SshConnectionSnapshotWire): string { + assertRenderable(snapshot); + return snapshot.host; +} + +/** + * The derived per-pane config path is composed here so plane and node agree + * byte-for-byte from the two facts each already holds (dataDir, subshellId) - + * no path ever travels the wire. The refusals are impossible-state guards at + * composition sites (matching the codebase's path-composition doctrine): a + * relative dataDir or an id outside `/^[a-zA-Z0-9_-]{1,64}$/` THROWS rather + * than composing a path that escapes the directory. + */ +export function buildSshConfigPath(dataDir: string, subshellId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh config path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) throw new Error("ssh config path: invalid subshellId"); + return `${dataDir}/ssh/${subshellId}/config`; +} From d6b25d97313ce105c11f5e1f2c3f826ce3cb64dd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:13:11 -0700 Subject: [PATCH 027/170] feat(plugins): built-in ssh harness - the terminal launch, pointed at ssh --- .../__tests__/installed-plugin-launch.test.ts | 9 +- .../nodes/__tests__/argv-parity.test.ts | 14 +- .../nodes/__tests__/local-plugins.test.ts | 1 + bun.lock | 15 ++ packages/pane-runtime/package.json | 1 + .../__tests__/built-in-api-versions.test.ts | 2 +- .../src/__tests__/built-in-icons.test.ts | 4 +- .../src/__tests__/embedded-plugins.test.ts | 1 + .../src/__tests__/installed-overlay.test.ts | 2 +- .../src/__tests__/plugins-seed.test.ts | 7 +- .../src/__tests__/registry.test.ts | 16 +- packages/pane-runtime/src/registry.ts | 5 + packages/plugins/ssh/.hash-runnerrc.json | 7 + packages/plugins/ssh/LICENSE | 203 ++++++++++++++++++ packages/plugins/ssh/icon.svg | 1 + packages/plugins/ssh/package.json | 60 ++++++ .../plugins/ssh/src/__tests__/ssh.test.ts | 79 +++++++ packages/plugins/ssh/src/index.ts | 66 ++++++ packages/plugins/ssh/src/manifest.ts | 15 ++ packages/plugins/ssh/tsconfig.json | 7 + packages/plugins/ssh/tsdown.config.ts | 17 ++ 21 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 packages/plugins/ssh/.hash-runnerrc.json create mode 100644 packages/plugins/ssh/LICENSE create mode 100644 packages/plugins/ssh/icon.svg create mode 100644 packages/plugins/ssh/package.json create mode 100644 packages/plugins/ssh/src/__tests__/ssh.test.ts create mode 100644 packages/plugins/ssh/src/index.ts create mode 100644 packages/plugins/ssh/src/manifest.ts create mode 100644 packages/plugins/ssh/tsconfig.json create mode 100644 packages/plugins/ssh/tsdown.config.ts diff --git a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts index 4648ce50a..e4d6d1734 100644 --- a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts +++ b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts @@ -121,7 +121,7 @@ afterAll(async () => { }); describe("an installed plugin resolves on the plane's side of a launch", () => { - it("detectSpecs ships acme's lookup rule to nodes, beside the six built-ins", () => { + it("detectSpecs ships acme's lookup rule to nodes, beside the seven built-ins", () => { const specs = detectSpecs(); expect(specs.find((s) => s.id === "acme")).toEqual({ id: "acme", @@ -130,9 +130,10 @@ describe("an installed plugin resolves on the plane's side of a launch", () => { knownPaths: [], }); // The built-ins did not move (the shadow/merge rule costs nothing), and - // the sixth is here too — terminal's SHELL rule reaching every node is - // what makes a clean machine launchable there. - for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]) { + // terminal's and ssh's rules are here too: terminal's SHELL rule is what + // makes a clean machine launchable there, and ssh's is the ladder the + // ssh launcher's resolved binary comes from. + for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]) { expect(specs.some((s) => s.id === id)).toBe(true); } }); diff --git a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts index 1d3ba57d8..a0c55a9b4 100644 --- a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts @@ -73,11 +73,15 @@ const SETTINGS_FIXTURES: Record> = { // that the matrix holds for inputs the plugin ignores, and a shell ignores // all of them. terminal: {}, + // SSH is the terminal launch pointed at ssh; like a shell it stores no + // settings keys, and the ssh option tokens ride `preset.flags`. Its + // "settings set" row is the empty object for the same reason terminal's is. + ssh: {}, }; /** * Derived, never hand-listed: a hand-synced list is itself a bypass, because - * a sixth built-in added to pane-runtime would simply not appear and the + * a new built-in added to pane-runtime would simply not appear and the * gate would silently shrink. What the registry ANSWERS is what the matrix * runs; the completeness test below fails loudly if a built-in failed to * CONSTRUCT (that case removes it from this list), and the per-id fixture @@ -257,11 +261,13 @@ for (const id of BUILTIN_IDS) { } /** - * Sanity: every built-in under test really is one of the six, and the + * Sanity: every built-in under test really is one of the seven, and the * registry is what production reads (`getHarness`, the same accessor * `subshell-manager` resolves launches through) — no copies. */ -test("argv parity: the matrix covers all six built-ins through the production registry", () => { +test("argv parity: the matrix covers all seven built-ins through the production registry", () => { const registryIds = BUILTIN_IDS.map((id) => getHarness(id)?.id); - expect(new Set(registryIds)).toEqual(new Set(["claude-code", "codex", "opencode", "hermes", "pi", "terminal"])); + expect(new Set(registryIds)).toEqual( + new Set(["claude-code", "codex", "opencode", "hermes", "pi", "ssh", "terminal"]), + ); }); diff --git a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts index 2f8055d65..50db36aa0 100644 --- a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts @@ -57,6 +57,7 @@ describe("the control-plane host's plugins", () => { "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ]); diff --git a/bun.lock b/bun.lock index 587fef3a9..92cdf474a 100644 --- a/bun.lock +++ b/bun.lock @@ -470,6 +470,7 @@ "@subshell-ai/plugin-netbird": "workspace:*", "@subshell-ai/plugin-opencode": "workspace:*", "@subshell-ai/plugin-pi": "workspace:*", + "@subshell-ai/plugin-ssh": "workspace:*", "@subshell-ai/plugin-tailscale": "workspace:*", "@subshell-ai/plugin-terminal": "workspace:*", }, @@ -588,6 +589,18 @@ "typescript": "7.0.2", }, }, + "packages/plugins/ssh": { + "name": "@subshell-ai/plugin-ssh", + "version": "1.0.0", + "devDependencies": { + "@internal/tsconfig": "workspace:*", + "@subshell-ai/plugin-api": "workspace:*", + "@types/bun": "1.3.14", + "hash-runner": "4.0.0", + "tsdown": "0.22.14", + "typescript": "7.0.2", + }, + }, "packages/plugins/tailscale": { "name": "@subshell-ai/plugin-tailscale", "version": "1.0.1", @@ -1500,6 +1513,8 @@ "@subshell-ai/plugin-pi": ["@subshell-ai/plugin-pi@workspace:packages/plugins/pi"], + "@subshell-ai/plugin-ssh": ["@subshell-ai/plugin-ssh@workspace:packages/plugins/ssh"], + "@subshell-ai/plugin-tailscale": ["@subshell-ai/plugin-tailscale@workspace:packages/plugins/tailscale"], "@subshell-ai/plugin-terminal": ["@subshell-ai/plugin-terminal@workspace:packages/plugins/terminal"], diff --git a/packages/pane-runtime/package.json b/packages/pane-runtime/package.json index dce553ba0..138772168 100644 --- a/packages/pane-runtime/package.json +++ b/packages/pane-runtime/package.json @@ -35,6 +35,7 @@ "@subshell-ai/plugin-netbird": "workspace:*", "@subshell-ai/plugin-opencode": "workspace:*", "@subshell-ai/plugin-pi": "workspace:*", + "@subshell-ai/plugin-ssh": "workspace:*", "@subshell-ai/plugin-tailscale": "workspace:*", "@subshell-ai/plugin-terminal": "workspace:*" }, diff --git a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts index f98cf587f..5cc7e73fd 100644 --- a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts @@ -51,7 +51,7 @@ describe("built-in plugin manifests", () => { // built-in whose `subshell` block went missing — fails loudly here rather // than passing vacuously over a shorter list. Another built-in updates // this number on purpose. - expect(manifests.length).toBe(10); + expect(manifests.length).toBe(11); for (const m of manifests) { expect(m.apiVersion, `${m.name} declares a stale subshell.apiVersion`).toBe(PLUGIN_API_VERSION); } diff --git a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts index 875400ba7..922b9dd3a 100644 --- a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts @@ -89,9 +89,9 @@ describe("iconsLeftOutOfTarball", () => { describe("built-in plugin icon manifests", () => { it("pins the number of plugins declaring an icon, so a silent disappearance is loud", () => { - // Eight of the ten built-ins ship a mark (headscale and cloudflare-tunnel + // Nine of the eleven built-ins ship a mark (headscale and cloudflare-tunnel // declare none). A new icon-bearing built-in updates this number on purpose. - expect(readBuiltInManifests().filter((m) => m.icon).length).toBe(8); + expect(readBuiltInManifests().filter((m) => m.icon).length).toBe(9); }); it("declares no icon that is absent from the npm `files` list", () => { diff --git a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts index f47428a3b..74095a0aa 100644 --- a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts +++ b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts @@ -23,6 +23,7 @@ const BUILT_INS = [ "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ] as const; diff --git a/packages/pane-runtime/src/__tests__/installed-overlay.test.ts b/packages/pane-runtime/src/__tests__/installed-overlay.test.ts index 88153062b..b4d586493 100644 --- a/packages/pane-runtime/src/__tests__/installed-overlay.test.ts +++ b/packages/pane-runtime/src/__tests__/installed-overlay.test.ts @@ -30,7 +30,7 @@ import type { PresetDefinition } from "../types.js"; * built-in-only without any code in it saying so). */ -const BUILT_IN_IDS = ["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]; +const BUILT_IN_IDS = ["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]; /** Minimal complete preset for `buildCommand` calls in these tests. */ const PRESET: PresetDefinition = { name: "p", env: {}, flags: [], settings: null, configIsolation: false }; diff --git a/packages/pane-runtime/src/__tests__/plugins-seed.test.ts b/packages/pane-runtime/src/__tests__/plugins-seed.test.ts index df44bfd7c..36b251e2c 100644 --- a/packages/pane-runtime/src/__tests__/plugins-seed.test.ts +++ b/packages/pane-runtime/src/__tests__/plugins-seed.test.ts @@ -33,10 +33,11 @@ describe("seedBuiltIns", () => { "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ]); - expect((await listInstalled(dir)).length).toBe(10); + expect((await listInstalled(dir)).length).toBe(11); }); it("does NOTHING once a seed has COMPLETED, even with the directory emptied", async () => { @@ -244,8 +245,8 @@ describe("prepareInstalledPlugins", () => { const dir = join(parent, "fresh-home"); mkdirSync(dir); await expect(prepareInstalledPlugins(dir)).resolves.toBeUndefined(); - // Six harnesses + four network plugins (cloudflare-tunnel, headscale, + // Seven harnesses + four network plugins (cloudflare-tunnel, headscale, // netbird, tailscale). - expect(await listInstalled(dir)).toHaveLength(10); + expect(await listInstalled(dir)).toHaveLength(11); }); }); diff --git a/packages/pane-runtime/src/__tests__/registry.test.ts b/packages/pane-runtime/src/__tests__/registry.test.ts index 20367d4ff..43613ed8a 100644 --- a/packages/pane-runtime/src/__tests__/registry.test.ts +++ b/packages/pane-runtime/src/__tests__/registry.test.ts @@ -21,7 +21,7 @@ describe("the built-in registry", () => { allHarnesses() .map((h) => h.id) .sort(), - ).toEqual(["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]); + ).toEqual(["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]); }); it("ships with nothing broken", () => { @@ -55,6 +55,20 @@ describe("the built-in registry", () => { expect(getHarness("hermes")?.name).toBe("Hermes Agent"); }); + it("ships ssh as a terminal-type harness carrying the ssh detect rule", () => { + // The ssh pane is the terminal launch pointed at ssh (plan 2 decision 1): + // TYPE is what keeps it out of every agent-only path, and the detect rule + // travels to nodes as DATA (the `launch.ssh` rung resolves the node's own + // binary through exactly this spec). + const ssh = getHarness("ssh"); + expect(ssh?.type).toBe("terminal"); + expect(ssh?.detectSpec).toEqual({ + binaryName: "ssh", + envOverride: "SUBSHELL_SSH_PATH", + knownPaths: ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"], + }); + }); + it("every built-in's declared capabilities match what it implements", () => { // The loader refuses a third-party plugin that gets this wrong, so a // built-in that got it wrong would be held to a lower standard than the diff --git a/packages/pane-runtime/src/registry.ts b/packages/pane-runtime/src/registry.ts index 7bace8b16..4a8630a7b 100644 --- a/packages/pane-runtime/src/registry.ts +++ b/packages/pane-runtime/src/registry.ts @@ -14,6 +14,7 @@ import hermesFactory, { manifest as hermesManifest } from "@subshell-ai/plugin-h import netbirdFactory, { manifest as netbirdManifest } from "@subshell-ai/plugin-netbird"; import opencodeFactory, { manifest as opencodeManifest } from "@subshell-ai/plugin-opencode"; import piFactory, { manifest as piManifest } from "@subshell-ai/plugin-pi"; +import sshFactory, { manifest as sshManifest } from "@subshell-ai/plugin-ssh"; import tailscaleFactory, { manifest as tailscaleManifest } from "@subshell-ai/plugin-tailscale"; import terminalFactory, { manifest as terminalManifest } from "@subshell-ai/plugin-terminal"; import { adaptPlugin } from "./plugin-adapter.js"; @@ -59,6 +60,10 @@ const BUILT_INS: BuiltIn[] = [ { manifest: piManifest, factory: piFactory }, { manifest: codexManifest, factory: codexFactory }, { manifest: terminalManifest, factory: terminalFactory }, + // The ssh pane is the terminal launch pointed at ssh: same TYPE, same + // `[binary, ...flags]` shape, the connection described entirely by the + // preset's option tokens (plan 2 decision 1). + { manifest: sshManifest, factory: sshFactory }, // The NETWORK built-ins (spec 2026-09-15, phases 2–3 of 2026-09-16) go in // the same list because the store is one store; `build()` sorts them into // the network half by manifest type. Name order among themselves, which is diff --git a/packages/plugins/ssh/.hash-runnerrc.json b/packages/plugins/ssh/.hash-runnerrc.json new file mode 100644 index 000000000..25618ba4c --- /dev/null +++ b/packages/plugins/ssh/.hash-runnerrc.json @@ -0,0 +1,7 @@ +{ + "inputs": { + "includes": ["src/**", "package.json"] + }, + "execOnChange": "bun run build", + "hashFile": ".hashes.json" +} diff --git a/packages/plugins/ssh/LICENSE b/packages/plugins/ssh/LICENSE new file mode 100644 index 000000000..2382e4e9e --- /dev/null +++ b/packages/plugins/ssh/LICENSE @@ -0,0 +1,203 @@ +Copyright 2026 Disaresta, LLC + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/ssh/icon.svg b/packages/plugins/ssh/icon.svg new file mode 100644 index 000000000..b8776ee80 --- /dev/null +++ b/packages/plugins/ssh/icon.svg @@ -0,0 +1 @@ +SSH diff --git a/packages/plugins/ssh/package.json b/packages/plugins/ssh/package.json new file mode 100644 index 000000000..5894f782d --- /dev/null +++ b/packages/plugins/ssh/package.json @@ -0,0 +1,60 @@ +{ + "name": "@subshell-ai/plugin-ssh", + "description": "Interactive SSH plugin for Subshell", + "version": "1.0.0", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "dist/index.d.ts", + "homepage": "https://subshell.sh", + "repository": { + "type": "git", + "url": "git+https://github.com/subshell-ai/subshell.git", + "directory": "packages/plugins/ssh" + }, + "bugs": { + "url": "https://github.com/subshell-ai/subshell/issues" + }, + "scripts": { + "build": "tsdown", + "build:dev": "hash-runner", + "clean": "rm -rf .turbo node_modules dist .hashes.json", + "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", + "lint:check": "biome check --no-errors-on-unmatched src", + "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", + "verify-types": "tsc --project tsconfig.json --noEmit" + }, + "devDependencies": { + "@internal/tsconfig": "workspace:*", + "@subshell-ai/plugin-api": "workspace:*", + "@types/bun": "1.3.14", + "hash-runner": "4.0.0", + "tsdown": "0.22.14", + "typescript": "7.0.2" + }, + "files": ["dist", "icon.svg"], + "publishConfig": { + "access": "public" + }, + "subshell": { + "apiVersion": 2, + "id": "ssh", + "type": "terminal", + "name": "SSH", + "description": "An interactive SSH session to any sshd host.", + "icon": "icon.svg", + "entry": "dist/index.js", + "detect": { + "binaryName": "ssh", + "envOverride": "SUBSHELL_SSH_PATH", + "knownPaths": ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"] + } + } +} diff --git a/packages/plugins/ssh/src/__tests__/ssh.test.ts b/packages/plugins/ssh/src/__tests__/ssh.test.ts new file mode 100644 index 000000000..3d96c298f --- /dev/null +++ b/packages/plugins/ssh/src/__tests__/ssh.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import type { BuildCommandInput, PresetDefinition } from "@subshell-ai/plugin-api"; +import createPlugin, { manifest } from "../index.js"; + +const BLANK: PresetDefinition = { + name: "Default", + description: null, + env: {}, + flags: [], + settings: null, + configIsolation: false, +}; + +function input(over: Partial = {}): BuildCommandInput { + return { binary: "/usr/bin/ssh", cwd: "/tmp/work", preset: BLANK, subshellName: "", ...over }; +} + +const plugin = () => createPlugin({} as never); + +describe("ssh manifest", () => { + it("is a terminal-type plugin", () => { + expect(manifest.id).toBe("ssh"); + expect(manifest.type).toBe("terminal"); + }); + + it("declares the ssh binary and the operator's pin seam", () => { + // `SUBSHELL_SSH_PATH` is the same override the agent's tier-1 ssh-shared + // ladder reads, so one operator setting names one binary everywhere. + expect(manifest.detect?.binaryName).toBe("ssh"); + expect(manifest.detect?.envOverride).toBe("SUBSHELL_SSH_PATH"); + }); + + it("names the stock install locations as ABSOLUTE paths", () => { + // Rung 3 of binary-lookup.ts uses a leading-`/` entry verbatim (it is a + // LOCATION, not a HOME-relative hint), so these are the actual system + // locations: Linux, the macOS base install, and Homebrew. + expect(manifest.detect?.knownPaths).toEqual([ + "/usr/bin/ssh", + "/bin/ssh", + "/usr/local/bin/ssh", + "/opt/homebrew/bin/ssh", + ]); + }); +}); + +describe("ssh plugin", () => { + it("declares no capabilities", () => { + // The remote shell hosts no local process that could speak MCP, resume a + // conversation, or edit settings. Capabilities are validated at load, so + // claiming one it does not implement would be a launch-time failure. + expect(plugin().capabilities()).toEqual([]); + }); + + it("runs the resolved binary with the preset flags", () => { + const cmd = plugin().buildCommand( + input({ preset: { ...BLANK, flags: ["-F", "/var/lib/subshell/ssh/sess/config", "example.com"] } }), + ); + expect(cmd).toEqual(["/usr/bin/ssh", "-F", "/var/lib/subshell/ssh/sess/config", "example.com"]); + }); + + it("appends preset flags, then extra flags, in that order", () => { + const cmd = plugin().buildCommand( + input({ preset: { ...BLANK, flags: ["-J", "bastion"] }, extraFlags: ["-A", "host.example"] }), + ); + expect(cmd).toEqual(["/usr/bin/ssh", "-J", "bastion", "-A", "host.example"]); + }); + + it("runs bare when nothing is configured", () => { + expect(plugin().buildCommand(input())).toEqual(["/usr/bin/ssh"]); + }); + + it("ignores the subshell name, having no flag for it", () => { + expect(plugin().buildCommand(input({ subshellName: "prod box" }))).toEqual(["/usr/bin/ssh"]); + }); + + it("accepts a blank preset", () => { + expect(plugin().validatePreset(BLANK).valid).toBe(true); + }); +}); diff --git a/packages/plugins/ssh/src/index.ts b/packages/plugins/ssh/src/index.ts new file mode 100644 index 000000000..2324685d5 --- /dev/null +++ b/packages/plugins/ssh/src/index.ts @@ -0,0 +1,66 @@ +import { + type BuildCommandInput, + type HarnessPluginFactory, + type PluginCapability, + type PluginHost, + type PresetDefinition, + type PresetValidationResult, + type SubshellPlugin, + validateGenericPreset, +} from "@subshell-ai/plugin-api"; + +/** + * Built-in: an interactive SSH session. + * + * Launch shape: the resolved ssh binary, plus the preset's option tokens. + * The pane's command IS the ssh process, so tmux supplies the PTY and the + * ssh env is the pane env; the connection itself is fully described by the + * option tokens (`-F` a rendered config, `-p`, `-l`, jump flags), which is + * why `buildCommand` is the terminal plugin's line verbatim. The pane is of + * TYPE `terminal` for the same reason: it is a plain interactive process + * with no agent protocol beside it. + * + * **Why this plugin declares a `detect` block at all.** The terminal plugin's + * answer was indirect (a shell happens to be a binary); here it is direct: + * `ssh` IS a binary the same way every agent harness is one, and a null + * resolved path makes a plugin both invisible in inventories and unlaunchable + * (`subshell-manager.service.ts` refuses to launch one). The rule travels to + * every node as data (the `launch.ssh` rung re-resolves it against the + * node's own machine); `SUBSHELL_SSH_PATH` is the operator's pin, the same + * seam the tier-1 `ssh-shared.ts` ladder reads. The absolute `knownPaths` are + * used verbatim by rung 3 of the lookup (a leading `/` is a location, not a + * HOME-relative hint), which is where a stock OpenSSH install lives on Linux, + * macOS and Homebrew. + * + * Identity, detection and install guidance live in this package's + * package.json `subshell` block, NOT here: the host reads them without + * importing or executing a line of this file. + * + * `host` carries what this module cannot import. See `@subshell-ai/plugin-api`. + */ +const createPlugin: HarnessPluginFactory = (_host: PluginHost): SubshellPlugin => ({ + // Deliberately empty. An ssh session has no MCP dialect to speak (the + // remote shell hosts no local process that could use it), no conversation + // to resume, no attention signal to parse and no settings to edit. The + // host validates this list at load, so naming a capability here that the + // plugin does not implement produces a launch that fails rather than a + // feature. + capabilities: (): PluginCapability[] => [], + + buildCommand(input: BuildCommandInput): string[] { + const { binary, preset, extraFlags } = input; + // No name flag: ssh has no session-title notion, so the pane title is + // whatever the remote shell sets and the reconcile sweep adopts it like + // it adopts any terminal's. + // Each stored flag is one complete argv token, as in every other plugin; + // `shellQuote` at every launch site wraps them, as it does everywhere. + return [binary, ...preset.flags, ...(extraFlags ?? [])]; + }, + + validatePreset(preset: PresetDefinition): PresetValidationResult { + return validateGenericPreset(preset); + }, +}); + +export default createPlugin; +export { manifest } from "./manifest.js"; diff --git a/packages/plugins/ssh/src/manifest.ts b/packages/plugins/ssh/src/manifest.ts new file mode 100644 index 000000000..880cbd2df --- /dev/null +++ b/packages/plugins/ssh/src/manifest.ts @@ -0,0 +1,15 @@ +import { parseManifest, type SubshellManifest } from "@subshell-ai/plugin-api"; +import pkg from "../package.json" with { type: "json" }; + +/** + * This package's own `subshell` block, read from its package.json so the + * manifest has ONE source of truth. + * + * A host that installs this plugin at runtime reads package.json off disk; a + * host that ships it built in imports this. Both must describe the same + * plugin, and the only way to guarantee that is for them to be the same bytes. + */ +const parsed = parseManifest(pkg); +if ("error" in parsed) throw new Error(`@subshell-ai/plugin-ssh has an invalid manifest: ${parsed.error}`); + +export const manifest: SubshellManifest = parsed; diff --git a/packages/plugins/ssh/tsconfig.json b/packages/plugins/ssh/tsconfig.json new file mode 100644 index 000000000..df0c1ed5a --- /dev/null +++ b/packages/plugins/ssh/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@internal/tsconfig/tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/packages/plugins/ssh/tsdown.config.ts b/packages/plugins/ssh/tsdown.config.ts new file mode 100644 index 000000000..69bd13a9a --- /dev/null +++ b/packages/plugins/ssh/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: ["esm"], + sourcemap: false, + target: ["es2024"], + nodeProtocol: true, + fixedExtension: false, + dts: true, + // A plugin is loaded from disk by a compiled binary and CANNOT resolve a + // bare specifier, so nothing of ours may survive as an import in the output. + // plugin-api is types plus pure helpers, which is exactly what makes it safe + // to inline here. + noExternal: ["@subshell-ai/plugin-api"], +}); From 03529a8ca4f92b03e919e89a80493bfef0f2142a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:18:34 -0700 Subject: [PATCH 028/170] feat(agent): gated ssh discovery/resolve; protocol 17 for the ssh launch grammar --- .../agent/src/__tests__/commands-ssh.test.ts | 163 ++++++++++++++++++ apps/node/agent/src/commands/index.ts | 9 +- apps/node/agent/src/commands/ssh-aliases.ts | 71 ++++++++ apps/node/agent/src/commands/ssh-shared.ts | 44 +++++ .../src/__tests__/node-frames.test.ts | 6 +- packages/subshell-protocol/src/node-frames.ts | 10 +- 6 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh.test.ts create mode 100644 apps/node/agent/src/commands/ssh-aliases.ts create mode 100644 apps/node/agent/src/commands/ssh-shared.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts new file mode 100644 index 000000000..97a34263b --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type JsonValue, NODE_PROTOCOL_VERSION, type NodeCommandBody } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The two SSH read arms of the launcher tier (spec 2026-10-07 §4.3/§7), driven + * through `dispatchCommand` exactly as the daemon drives them (posture copied + * from commands-maintenance.test.ts: real temp dirs, the local mirror written + * with its own writer, every refusal asserted by `ok:false` + exact message). + * + * What THIS file owns that nothing else does: + * - the GATE ordering: a machine whose local mirror is not ON answers + * `ssh disabled on this node` BEFORE any binary lookup or spawn. The proof + * is a working recording shim that never runs (its log file is never even + * created) and a broken ladder whose message never surfaces. + * - discovery's names-only answer from a fixture HOME, through the frozen + * wire validator on the way out. + * - routing: a well-formed `ssh_resolve_config` reaches the HANDLER (it + * answers with the handler's own failure class, not dispatch's + * `unsupported`). Malformed aliases die at the frame parser before this + * point — the tier-1 parser tests own that refusal. + * + * The real `ssh -G` behavior against a fixture config is the tier-1 resolver + * suite's; the composed launch path is the e2e's. Do not re-test either here. + */ + +const STAMP = "2026-10-07T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; + +let base: string; +let savedSshPath: string | undefined; +let savedHome: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; + savedHome = process.env.HOME; +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + rmSync(base, { recursive: true, force: true }); +}); + +/** Recording shim (mirrors the runtime test helper's shape, baked constants). */ +function writeShim(dir: string, opts: { dashG?: string } = {}): { bin: string; log: string } { + mkdirSync(dir, { recursive: true }); + const log = join(dir, "shim.log"); + const lines = ["#!/bin/sh", `LOG='${log}'`, `printf 'ARGS:%s\\n' "$*" >> "$LOG"`]; + if (opts.dashG !== undefined) { + lines.push(`for a in "$@"; do if [ "$a" = "-G" ]; then cat <<'GEOF'`); + lines.push(opts.dashG); + lines.push("GEOF\nexit 0\nfi; done"); + } + lines.push("exit 0"); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${lines.join("\n")}\n`, { mode: 0o755 }); + return { bin, log }; +} + +/** A context whose only meaningful fact is the data dir its gate mirror lives in. */ +function makeCtx(dataDir: string): CommandContext { + return { config: { dataDir } } as unknown as CommandContext; +} + +/** Fresh data dir; `on: true` writes the mirror through its real writer. */ +function dataDir(tag: string, gate: "absent" | "on" | "unreadable"): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gate === "on") writeSshEnabled(dir, { on: true, changedAt: STAMP }); + if (gate === "unreadable") writeFileSync(join(dir, "ssh-enabled.json"), "not json at all"); + return dir; +} + +/** A fixture HOME whose account config names two hosts. */ +function homeWithConfig(tag: string): string { + const home = join(base, `${tag}-home`); + mkdirSync(join(home, ".ssh"), { recursive: true }); + writeFileSync(join(home, ".ssh", "config"), "Host beta\n User x\nHost alpha\n HostName a.example\n"); + return home; +} + +describe("ssh gate: discovery and resolve refuse before anything runs", () => { + it("an absent mirror refuses BOTH arms by the exact name, whatever the ladder says", async () => { + const shim = writeShim(join(base, "shim-gate"), { + dashG: "host resolved.example\nhostname resolved.example\nuser deploy\nport 22\n", + }); + process.env.SUBSHELL_SSH_PATH = shim.bin; + const dir = dataDir("gate-absent", "absent"); + + const discovery = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies NodeCommandBody); + expect(discovery).toEqual({ ok: false, error: GATE_REFUSAL }); + + const resolve = await dispatchCommand(makeCtx(dir), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(resolve).toEqual({ ok: false, error: GATE_REFUSAL }); + // The gate check PRECEDES the ladder and the spawn: a perfectly good shim + // was never executed, and an answer computed after lookup would have said + // something else entirely. + expect(existsSync(shim.log)).toBe(false); + }); + + it("a broken ladder does not speak first: gate-off answers with the gate's own words", async () => { + process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary"; + const res = await dispatchCommand(makeCtx(dataDir("gate-vs-ladder", "absent")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + + it("an unreadable mirror fails CLOSED (refuses) with the same words", async () => { + const res = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), { + type: "ssh_discover_aliases", + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); +}); + +describe("ssh_discover_aliases / ssh_resolve_config arms (gate ON)", () => { + it("discovery answers the sorted NAMES through the validator from the account config", async () => { + const dir = dataDir("disc", "on"); + process.env.HOME = homeWithConfig("disc"); + const res = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies NodeCommandBody); + expect(res.ok).toBe(true); + const data = (res as { data?: JsonValue }).data as { + aliases: string[]; + includeCycle: boolean; + truncated: boolean; + }; + expect(data.aliases).toEqual(["alpha", "beta"]); + expect(data.includeCycle).toBe(false); + expect(data.truncated).toBe(false); + }); + + it("a well-formed resolve command REACHES the handler: its own failure class answers, not dispatch's `unsupported`", async () => { + // Gate ON + a missing binary: only the handler body can produce the + // binary-missing class. "unsupported" here would mean the dispatch arm is + // missing; the gate's words here would mean it ran before the ladder. + process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary"; + const res = await dispatchCommand(makeCtx(dataDir("reach", "on")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); + +/** The bump this tier owns: literal-16 pins everywhere must now read 17. */ +it("NODE_PROTOCOL_VERSION is 17 (the ssh launch block + the two read arms)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(17); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 447fde0ae..55342ee8c 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -28,6 +28,7 @@ import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; +import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -50,7 +51,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- - * to-machine write). Any + * to-machine write), and the two SSH read arms `ssh_discover_aliases` and + * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the + * local gate mirror before any lookup or spawn). Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -127,6 +130,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetMaintenance(ctx, cmd); case "set_ssh_enabled": return execSetSshEnabled(ctx, cmd); + case "ssh_discover_aliases": + return await execSshDiscoverAliases(ctx); + case "ssh_resolve_config": + return await execSshResolveConfig(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-aliases.ts b/apps/node/agent/src/commands/ssh-aliases.ts new file mode 100644 index 000000000..2b24da470 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-aliases.ts @@ -0,0 +1,71 @@ +import { defaultSshConfigPath, discoverSshAliases, resolveSshAliasConfig } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshAliasList, parseNodeSshResolveOutcome } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; +import { connectingHomeDir, resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two human-config SSH executors that touch no connection state (spec + * 2026-10-07 §5/§7): + * + * - `ssh_discover_aliases` — bounded config parse answering NAMES only + * (§2: "Discovery returns names, not config file contents"). The answer is + * run through the frozen wire validator on the way out. + * - `ssh_resolve_config` — `ssh -G` evaluation of one alias through + * pane-runtime's `resolveSshAliasConfig`, plus this machine's connecting + * account name for the review card ("review resolved destination AND + * connecting OS account"). A refusal is a SUCCESSFUL answer (the outcome + * rides `accepted:false` in the data) so the human reads WHICH setting + * blocked; only a missing ssh binary is a command failure. + * + * EVERY arm consults this machine's ssh-enabled mirror FIRST (§4.3's gate + * doctrine): a machine whose local copy is not ON answers + * {@link SSH_GATE_REFUSAL} before any binary lookup, any config read, or any + * spawn — regardless of what the plane believes about it. The mirror is + * fail-closed, so absent and unreadable refuse exactly like off. + * + * The disclosure §2 demands before resolution runs — that a trusted + * `Match exec` can execute locally during `ssh -G` — belongs to the + * human-facing surface that dispatches it: in this tier, the plane's launch + * route (`/api/ssh/launch`, the SSH-launch API task) is where the disclosure + * rides, never the node. + */ + +/** Does this machine permit SSH right now? (the one question every arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_discover_aliases`. */ +export async function execSshDiscoverAliases(ctx: CommandContext): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const home = connectingHomeDir(); + const found = discoverSshAliases({ homeDir: home, configPath: defaultSshConfigPath(home) }); + const validated = parseNodeSshAliasList({ + aliases: found.aliases, + includeCycle: found.includeCycle, + truncated: found.truncated, + }); + if (validated === null) return { ok: false, error: "malformed alias discovery" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} + +/** Execute `ssh_resolve_config` for one alias. */ +export async function execSshResolveConfig( + ctx: CommandContext, + cmd: Cmd<"ssh_resolve_config">, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const sshBin = await resolveSshBin(); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + const home = connectingHomeDir(); + const outcome = await resolveSshAliasConfig(cmd.alias, { + sshBin, + homeDir: home, + configPath: defaultSshConfigPath(home), + }); + const validated = parseNodeSshResolveOutcome(outcome); + if (validated === null) return { ok: false, error: "malformed resolve outcome" }; + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts new file mode 100644 index 000000000..f124741c0 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -0,0 +1,44 @@ +import { homedir } from "node:os"; +import { findBinary } from "@internal/pane-runtime"; + +/** + * Shared plumbing for the SSH command arms (spec 2026-10-07 §5). + * + * The one question every SSH executor asks first is WHERE the ssh binary is, + * and every answer must come from the node's own lookup ladder at this + * instant — never a path shipped over the wire (§2's inversion posture: the + * plane approves the destination, the machine owns the argv construction + * including which binary executes it). `SUBSHELL_SSH_PATH` is the operator + * override seam (the `CLAUDE_PATH` posture), and the knownPaths list is the + * union of OpenSSH install locations on the supported platforms (Linux/macOS) + * — PATH first through the ladder, these as the fallbacks it names. + * + * The gate comes BEFORE any of this: every SSH arm consults the local + * ssh-enabled mirror first (spec §4.3), so nothing here runs on a machine + * that has not been switched on. {@link sshGateRefusal} states the one + * refusal every arm shares. + */ + +/** env-override seam for the ssh binary, spelled like the harness overrides. */ +export const SSH_BINARY_ENV = "SUBSHELL_SSH_PATH"; + +/** The known install locations the ladder falls back to. */ +const SSH_KNOWN_PATHS = ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"]; + +/** + * The gate's own words, spelled once: a machine whose local mirror is not ON + * refuses EVERY SSH command with exactly this, before any binary lookup or + * spawn. The plane's row is not consulted here — the mirror is the fact this + * process acts on, and it fails closed (absent and unreadable both refuse). + */ +export const SSH_GATE_REFUSAL = "ssh disabled on this node"; + +/** Resolve the ssh binary for this machine, or null. */ +export async function resolveSshBin(): Promise { + return await findBinary("ssh", SSH_BINARY_ENV, SSH_KNOWN_PATHS); +} + +/** The connecting account's home, the same way the daemon itself sees it. */ +export function connectingHomeDir(): string { + return process.env.HOME || homedir(); +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 7459f3798..6e3f3e1b0 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -318,7 +318,11 @@ describe("parseNodeCommandBody", () => { // bump is the load-bearing part: a pre-16 agent is wire-indistinguishable // from a 16 agent whose mirror says no, and a capability gate cannot live // with that ambiguity. - expect(NODE_PROTOCOL_VERSION).toBe(16); + // 17 is the ssh launcher tier (spec 2026-10-07 §4.3/§5): the launch frame's + // `ssh` block and the `ssh_discover_aliases` / `ssh_resolve_config` arms. + // A lagging agent ignores the launch block and spawns a bare `ssh` with no + // `-F` config, so it is HELD (update-only) until crossed, not approximated. + expect(NODE_PROTOCOL_VERSION).toBe(17); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 02a2cb8df..f1d2c2031 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -149,8 +149,16 @@ import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; * `sshEnabled`, which on the wire is indistinguishable from a 16 agent whose * mirror simply says no. A capability gate cannot live with that ambiguity * (spec §4.3), so the number is what refuses the old agent, not the frame. + * + * **16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve + * commands. A lagging agent is HELD (update-only) until crossed, per the + * capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md + * §4.3; the desktop update path is the crossing. The ambiguity the bump + * refuses: a tier-1 agent that ignores the launch frame's `ssh` member would + * spawn a bare `ssh` pane with no `-F` config (§4.3's gate doctrine refuses to + * live with that). */ -export const NODE_PROTOCOL_VERSION = 16; +export const NODE_PROTOCOL_VERSION = 17; /** * The FIRST protocol whose agents verify the publisher signature on an From 0cada7c0505bae77eca4f5676d74fa9ab61d476f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:20:10 -0700 Subject: [PATCH 029/170] fix(agent): reviewer round 1 - the shared refusal names the real constant, and the unreadable-mirror refusal is pinned on both ssh arms --- apps/node/agent/src/__tests__/commands-ssh.test.ts | 6 ++++++ apps/node/agent/src/commands/ssh-shared.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts index 97a34263b..ecd061c2e 100644 --- a/apps/node/agent/src/__tests__/commands-ssh.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -124,6 +124,12 @@ describe("ssh gate: discovery and resolve refuse before anything runs", () => { type: "ssh_discover_aliases", } satisfies NodeCommandBody); expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + // The same classifier gates the resolve arm; pin the pairing, not just one side. + const res2 = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res2).toEqual({ ok: false, error: GATE_REFUSAL }); }); }); diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts index f124741c0..d7dd1b1b2 100644 --- a/apps/node/agent/src/commands/ssh-shared.ts +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -15,8 +15,8 @@ import { findBinary } from "@internal/pane-runtime"; * * The gate comes BEFORE any of this: every SSH arm consults the local * ssh-enabled mirror first (spec §4.3), so nothing here runs on a machine - * that has not been switched on. {@link sshGateRefusal} states the one - * refusal every arm shares. + * that has not been switched on. `SSH_GATE_REFUSAL` (ssh-aliases.ts) states + * the one refusal every arm shares. */ /** env-override seam for the ssh binary, spelled like the harness overrides. */ From 8e09818ea3feef438ff87213310701a32b596a04 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:31:23 -0700 Subject: [PATCH 030/170] feat(launch): ssh config written by the spawning machine at the byte-derived path, removed with the pane --- .../agent/src/__tests__/launch-ssh.test.ts | 363 ++++++++++++++++++ apps/node/agent/src/commands/launch.ts | 34 +- apps/node/agent/src/commands/report.ts | 30 +- .../__tests__/local-launcher-ssh.test.ts | 166 ++++++++ .../nodes/__tests__/remote-launcher.test.ts | 47 +++ .../api/src/services/nodes/local-launcher.ts | 62 ++- .../api/src/services/nodes/node-launcher.ts | 19 +- .../api/src/services/nodes/remote-launcher.ts | 42 +- .../api/src/services/nodes/subshell-paths.ts | 15 + .../src/services/subshell-manager.service.ts | 28 +- 10 files changed, 783 insertions(+), 23 deletions(-) create mode 100644 apps/node/agent/src/__tests__/launch-ssh.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts diff --git a/apps/node/agent/src/__tests__/launch-ssh.test.ts b/apps/node/agent/src/__tests__/launch-ssh.test.ts new file mode 100644 index 000000000..509838483 --- /dev/null +++ b/apps/node/agent/src/__tests__/launch-ssh.test.ts @@ -0,0 +1,363 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, type NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { runExitWatchTick, startExitWatcher, stopWatcher } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the ssh pane's rendered config, landed + * by the SPAWNING machine at the byte-derived path, and swept when the pane + * dies. + * + * Two halves, one contract: + * - `execLaunch` writes `cmd.ssh` BEFORE meta/pane, at + * `buildSshConfigPath(dataDir, id)` — the plane names nothing: a frame whose + * `configPath` is not byte-equal to this machine's own derivation is + * refused. Modes mirror the MCP block exactly (dir 0700, file 0600, + * `enforceMode` on both). + * - the exit watcher (report.ts) removes the per-pane ssh DIR when — and only + * when — the dead pane's recorded meta names harness `ssh`, best-effort and + * inside the same relaunch-ownership guard that protects the tails. + * + * Same fake-tmux discipline as commands-launch.test.ts: a plain-object double + * records ordered calls, unstubbed methods throw. + */ + +const S1 = "11111111-1111-4111-8111-111111111111"; +const FIXED_NOW = 1_700_000_000_000; +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; + +let base: string; +let claudePathOriginal: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-launch-ssh-"))); +}); + +beforeEach(() => { + claudePathOriginal = process.env.CLAUDE_PATH; + process.env.CLAUDE_PATH = "/bin/sh"; +}); + +afterAll(() => { + if (claudePathOriginal === undefined) delete process.env.CLAUDE_PATH; + else process.env.CLAUDE_PATH = claudePathOriginal; + rmSync(base, { recursive: true, force: true }); +}); + +async function freshDataDir(tag: string): Promise { + const dir = join(base, tag); + mkdirSync(dir, { recursive: true }); + return dir; +} + +type ProbeAnswer = { ok: true; names: string[] } | { ok: false; detail: string }; + +interface Spec { + newSubshell?: (socket: string, id: string, cwd: string, cmd: string, exitHook?: string) => void; + pipePane?: (socket: string, id: string, out: string) => void; + resizeWindow?: (socket: string, id: string, cols: number, rows: number) => void; + listSubshellsChecked?: (socket: string) => ProbeAnswer | Promise; + paneExitCode?: (socket: string, id: string) => number | null; + killSubshell?: (socket: string, id: string) => void; +} + +/** A one-shot promise plus its resolver (the gated-forget fixture drives these). */ +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** + * Models a tick stuck INSIDE `await ctx.meta.forget(...)` (the relaunch + * interleaving from commands-launch.test.ts, kept here because the ssh sweep + * must respect the SAME ownership guard). + */ +class GateHeldMetaStore extends SubshellMetaStore { + readonly forgetEntered = deferred(); + readonly forgetGate = deferred(); + + override async forget(id: string): Promise { + await super.forget(id); + this.forgetEntered.resolve(); + await this.forgetGate.promise; + } +} + +/** Build a CommandContext over a scripted double on `dataDir`; unstubbed calls throw. */ +function makeCtx( + dataDir: string, + spec: Spec, + events: NodeEvent[], + meta?: SubshellMetaStore, +): { ctx: CommandContext; calls: Array<{ method: string; args: unknown[] }> } { + const calls: Array<{ method: string; args: unknown[] }> = []; + const method = (name: keyof Spec) => { + const impl = spec[name]; + return (...args: unknown[]) => { + calls.push({ method: name, args }); + if (!impl) throw new Error(`fake tmux: unstubbed call ${String(name)}(${JSON.stringify(args)})`); + return (impl as (...a: unknown[]) => unknown)(...args); + }; + }; + const raw = { + newSubshell: method("newSubshell"), + pipePane: method("pipePane"), + resizeWindow: method("resizeWindow"), + listSubshellsChecked: (socket: string): ProbeAnswer | Promise => { + calls.push({ method: "listSubshellsChecked", args: [socket] }); + const scripted = spec.listSubshellsChecked; + if (scripted) return scripted(socket); + throw new Error("fake tmux: unstubbed listSubshellsChecked"); + }, + paneExitCode: method("paneExitCode"), + killSubshell: method("killSubshell"), + }; + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const ctx: CommandContext = { + config, + tmux: raw as unknown as CommandContext["tmux"], + meta: meta ?? new SubshellMetaStore(dataDir), + nowMs: () => FIXED_NOW, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; + return { ctx, calls }; +} + +type LaunchCmd = Extract; + +/** An ssh-harness launch frame: placeholder argv resolved via CLAUDE_PATH like every launch fixture here. */ +function sshLaunchCmd(over: Partial = {}): LaunchCmd { + return { + type: "launch", + subshellId: S1, + socket: "subshell-ssh-test", + cwd: base, + harnessId: "ssh", + preset: { name: "p", env: {}, flags: [], settings: null, configIsolation: false }, + subshellEnv: { SUBSHELL_API_KEY: "k" }, + subshellName: "s1", + argv: [HARNESS_BINARY_PLACEHOLDER], + resolve: { binaryName: "claude", envOverride: "CLAUDE_PATH" }, + ...over, + }; +} + +function spawnAndPipeStub() { + return { newSubshell: () => {}, pipePane: () => {} }; +} + +/* ------------------------------------------------------------------ */ +/* execLaunch: the config write (derived path, byte-equality, modes) */ +/* ------------------------------------------------------------------ */ + +describe("execLaunch ssh config write (spec 2026-10-07 decision 4)", () => { + it("well-formed block: file at the derived path, 0600 in a 0700 dir, content byte-equal", async () => { + const dataDir = await freshDataDir("write"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const configPath = buildSshConfigPath(dataDir, S1); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + // The write precedes nothing observable in tmux ordering terms, but the + // pane still spawns exactly once and the file survives the launch. + expect(calls.map((c) => c.method)).toEqual(["newSubshell", "pipePane"]); + stopWatcher(ctx, S1); + }); + + it("the write is visible at newSubshell time (config BEFORE spawn)", async () => { + const dataDir = await freshDataDir("write-before-spawn"); + const configPath = buildSshConfigPath(dataDir, S1); + let seenAtSpawn = false; + const { ctx } = makeCtx( + dataDir, + { + ...spawnAndPipeStub(), + newSubshell: () => { + seenAtSpawn = existsSync(configPath); + }, + }, + [], + ); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(seenAtSpawn).toBe(true); + stopWatcher(ctx, S1); + }); + + it("configPath not byte-equal to the derivation: refused, no file, no dir, no pane, no meta", async () => { + const dataDir = await freshDataDir("mismatch"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + // A plane naming anywhere else — a sibling path in the SAME derived shape + // included — is refused: the path is this machine's derivation alone. + const wrong = buildSshConfigPath(dataDir, `${S1}-elsewhere`); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath: wrong, fileContent: SSH_CONTENT } })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); // refused before the spawn + expect(existsSync(join(dataDir, "ssh"))).toBe(false); // not even a dir + expect(await ctx.meta.get(S1)).toBeUndefined(); + }); + + it("absolute-path config outside dataDir: refused by the same gate (byte-equality is the first door)", async () => { + const dataDir = await freshDataDir("outside"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand( + ctx, + sshLaunchCmd({ ssh: { configPath: "/etc/ssh/subshell-config", fileContent: SSH_CONTENT } }), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + }); + + it("no ssh block on the frame: no dir is created for an ordinary pane", async () => { + const dataDir = await freshDataDir("absent"); + const { ctx } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand(ctx, sshLaunchCmd()); + expect(result).toEqual({ ok: true }); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + stopWatcher(ctx, S1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Exit watcher: the death sweep, gated on meta harnessId === "ssh" */ +/* ------------------------------------------------------------------ */ + +/** Seed meta with an explicit harnessId (the commands-launch twin hardcodes claude-code). */ +async function recordMetaAs( + store: SubshellMetaStore, + subshellId: string, + socket: string, + harnessId: string, +): Promise { + await store.record({ + subshellId, + cwd: base, + socket, + harnessId, + name: "m", + startedAt: "2026-10-07T00:00:00.000Z", + }); +} + +/** Create a realistic per-pane ssh dir + config under dataDir. */ +function seedSshConfig(dataDir: string, subshellId: string): { dir: string; file: string } { + const file = buildSshConfigPath(dataDir, subshellId); + const dir = file.slice(0, file.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(file, SSH_CONTENT, { mode: 0o600 }); + return { dir, file }; +} + +function deadSocketSpec(): Spec { + return { listSubshellsChecked: () => ({ ok: true, names: [] }), paneExitCode: () => 0, killSubshell: () => {} }; +} + +describe("exit watcher ssh config sweep (report.ts, decision 4)", () => { + it("ssh pane dies: the per-pane ssh dir is gone, best-effort, and the death still reports", async () => { + const dataDir = await freshDataDir("sweep-ssh"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "ssh"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); // the timer never fires; the tick runs manually + + await runExitWatchTick(ctx); + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + expect(existsSync(file)).toBe(false); + expect(existsSync(dir)).toBe(false); // the DIR goes, not just the file + expect(await ctx.meta.get(S1)).toBeUndefined(); + expect(ctx.watchTick).toBeUndefined(); + }); + + it("non-ssh pane dies: a same-named dir on disk is left untouched", async () => { + const dataDir = await freshDataDir("sweep-claude"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "claude-code"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); // the death still reports + expect(existsSync(file)).toBe(true); + expect(existsSync(dir)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("pane with NO meta record: nothing is removed (the kind fact is the gate, absence never asserts)", async () => { + const dataDir = await freshDataDir("sweep-nometa"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); + expect(existsSync(file)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("relaunch DURING the forget: the stale tick never sweeps the live relaunch's config", async () => { + const dataDir = await freshDataDir("sweep-relaunch"); + const events: NodeEvent[] = []; + const gated = new GateHeldMetaStore(dataDir); + const { ctx } = makeCtx(dataDir, { ...deadSocketSpec(), ...spawnAndPipeStub() }, events, gated); + await recordMetaAs(gated, S1, "s-sock", "ssh"); + const configPath = buildSshConfigPath(dataDir, S1); + const dir = configPath.slice(0, configPath.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, "OLD", { mode: 0o600 }); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + const tick = runExitWatchTick(ctx); + try { + await gated.forgetEntered.promise; // the tick is suspended inside `await ctx.meta.forget(S1)` + // Relaunch through the REAL launch path: it re-records meta and rewrites + // the config while the old tick is still in flight. + const relaunch = await dispatchCommand( + ctx, + sshLaunchCmd({ socket: "s-sock-2", ssh: { configPath, fileContent: SSH_CONTENT } }), + ); + expect(relaunch).toEqual({ ok: true }); + } finally { + gated.forgetGate.resolve(); + } + await tick; + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + // The relaunched pane is live and its config is INTACT: the sweep ran + // inside the ownership guard, not after the forget blindly. + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(ctx.watchers.has(S1)).toBe(true); + stopWatcher(ctx, S1); + }); +}); diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index 3f2a3c664..e299e5331 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { assembleHarnessCommand, + buildSshConfigPath, enforceMode, exitHookFor, findBinary, @@ -37,8 +38,9 @@ import { reportMaintenance, startExitWatcher } from "./report.js"; * Start one harness subshell on this node. * * Step order is contractual (interfaces, brief §Task 4): id gate → allowlist - * gate → argv + binary resolution → MCP file (policy-gated) → meta record → - * pane argv assembly → `newSubshell` (rollback on throw) → log attach + * gate → argv + binary resolution → MCP file (policy-gated) → ssh config + * (byte-derived path, policy-gated; spec 2026-10-07 decision 4) → meta record + * → pane argv assembly → `newSubshell` (rollback on throw) → log attach * (strict by default; `bestEffortLog` downgrades the whole attach region) → * optional resize (cosmetic) → exit watcher. A strict log-attach throw * PROPAGATES (the dispatcher answers `{ok:false}`) exactly like @@ -131,6 +133,34 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await enforceMode(cmd.mcp.path, 0o600); // Task-2 re-tightening: umask cannot leak bits here } + // (3b) SSH pane config (spec 2026-10-07 decision 4). The MCP block above is + // the posture this mirrors — file-placement is a NODE control, not plugin + // knowledge — with one sharper rule: the MCP path is the plane's to name, + // the ssh path is not. This machine derives it + // (`buildSshConfigPath(dataDir, id)`), and refuses unless the frame's + // `configPath` is BYTE-equal to that derivation. The plane may name nothing + // else: `buildSshConfigPath` composes from the two facts each side already + // holds (dataDir, id), so the file always lands where this machine's own + // exit sweep and the plane's delete-side `subshellArtifacts` will both look + // for it — and a plane that could point the write anywhere would turn a + // signed launch into an arbitrary-file write. + if (cmd.ssh) { + const derivedPath = buildSshConfigPath(ctx.config.dataDir, cmd.subshellId); + if (cmd.ssh.configPath !== derivedPath) { + // A bare message, no path interpolation: the byte-equality failure is + // the whole fact, and the paths here are this machine's own layout. + return { ok: false, error: "ssh config path refused: not the derived path" }; + } + if (!(await pathAllowed(derivedPath, await realpathRoots([ctx.config.dataDir])))) { + return { ok: false, error: "ssh config path refused" }; + } + const dir = dirname(derivedPath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(derivedPath, cmd.ssh.fileContent, { mode: 0o600 }); + await enforceMode(derivedPath, 0o600); // umask cannot leak bits past this + } + // (4) Record meta FIRST: path policy + socket lookup must see the cwd even // while later steps are in flight (and the rollback below has something to // roll back). `record` creates /subshells with 0700. diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index 0b8c37d14..af53109d5 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,3 +1,6 @@ +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; @@ -233,7 +236,9 @@ export function stopWatcher(ctx: CommandContext, subshellId: string): void { * it — or stops ANSWERING for {@link NODE_EXIT_UNREACHABLE_TICKS} consecutive * ticks (design §1: one blip never reports a live pane dead) — the shared tick * emits exactly one `exit` event and cleans up (unregister, - * forget the meta record, drop the subshell's tail pumps). Re-registering for + * forget the meta record, drop the subshell's tail pumps, and — for a pane + * whose meta named the ssh harness — best-effort remove its rendered ssh + * config dir, spec 2026-10-07 decision 4). Re-registering for * the same id REPLACES the old entry with a fresh registration token — a * relaunch on a restarted row rotates its socket, and the tick that * snapshotted the old pane sees the token mismatch and never reports the live @@ -374,6 +379,13 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // to report. (On the unreachable path the socket is not answering, so // this reads null — the same shape a dead server always produced.) const exitCode = (await ctx.tmux.paneExitCode(socket, subshellId)) ?? null; + // Read the meta's KIND here, before the forget below erases the record: + // the ssh sweep at the end of this sequence fires only for a pane whose + // record named the ssh harness (spec 2026-10-07 decision 4). Absence is + // not a kind — an ssh pane that lost its meta file keeps its config (a + // leftover file is swept by the delete path; wiping an unknown pane's + // directory is not). + const paneMeta = await ctx.meta.get(subshellId); ctx.watchers.delete(subshellId); // stop-first: at most one event per registration // LOAD-BEARING ORDER (spec 2026-09-14 §4.3): the flag goes out BEFORE the // death it explains. The plane serialises frames per socket, so a @@ -410,6 +422,22 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // Already gone is the outcome we wanted; anything else costs an idle // process, never correctness. } + // Sweep the ssh config (spec 2026-10-07 decision 4). The pane was its only + // reader; leaving it means a signed launch leaves a file forever. Gated on + // the meta kind read BEFORE the forget (only the ssh harness ever owned + // one), and on the SAME relaunch re-check that guards the tails and the + // reap: a relaunched pane owns the path now. The whole per-pane ssh DIR + // goes, not just the file — the launch may have added host keys beside the + // config. Best-effort by contract: a throwing rm costs a log line and + // NEVER refuses the death report or unwinds the sequence. + if (paneMeta?.harnessId === "ssh") { + const sshDir = dirname(buildSshConfigPath(ctx.config.dataDir, subshellId)); + try { + await rm(sshDir, { recursive: true, force: true }); + } catch (err) { + log(`ssh config sweep failed for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`); + } + } } } diff --git a/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts new file mode 100644 index 000000000..b9aeec01f --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts @@ -0,0 +1,166 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath, getHarness, TmuxRunner, tmuxSocketFor } from "@internal/pane-runtime"; +import { TRUE_BINARY } from "@/__tests__/helpers/true-binary.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { LocalLauncher } from "../local-launcher.js"; +import type { LaunchPlan } from "../node-launcher.js"; +import { subshellLogPath, subshellSshConfigPath } from "../subshell-paths.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the LOCAL twin of the agent's ssh + * config write. `LocalLauncher.launch` materializes the rendered config under + * the SERVER's own dataDir, at the same derived path the ssh service composed, + * BEFORE the pane spawns — and `subshellArtifacts` names it for the delete + * sweep only when the caller says the pane was an ssh pane. + * + * Scripted tmux (no real spawn): the write is pure fs, and the spawn-side + * contract is exactly "the file was there when tmux was dialed". + */ + +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; +const pid = process.pid; + +/** Counts the spawn and records whether the config existed at spawn time. */ +class ScriptedTmux extends TmuxRunner { + spawns = 0; + pipes = 0; + configAtSpawn: boolean | null = null; + readonly #watchPath: () => string; + constructor(watchPath: () => string) { + super(); + this.#watchPath = watchPath; + } + override newSubshell(): void { + this.spawns++; + this.configAtSpawn = existsSync(this.#watchPath()); + } + override pipePane(): void { + this.pipes++; + } +} + +const sshHarness = getHarness("ssh"); +if (!sshHarness) throw new Error("ssh harness plugin missing from the registry"); + +// An arrow const (not a hoisted function declaration): the module-level +// narrowing of `sshHarness` survives into it (the pattern +// local-launcher.test.ts uses for the pi harness). +const sshPlan = (id: string, over: Partial = {}): LaunchPlan => ({ + id, + socket: tmuxSocketFor(id), + harness: sshHarness, + binary: TRUE_BINARY, + cwd: tmpdir(), + preset: { + name: "p", + description: null, + env: {}, + flags: ["-F", buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "--"], + settings: null, + configIsolation: false, + restartOnExit: false, + }, + subshellName: "s", + subshellEnv: {}, + ...over, +}); + +function cleanup(id: string): void { + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); + void Bun.file(subshellLogPath(id)) + .unlink() + .catch(() => {}); +} + +describe("LocalLauncher ssh config write (spec 2026-10-07 decision 4)", () => { + it("launch(plan with ssh): config at the derived path, 0600 in a 0700 dir, content byte-equal, written BEFORE the spawn", async () => { + const id = `launcher-ssh-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const scripted = new ScriptedTmux(() => configPath); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(scripted.spawns).toBe(1); + expect(scripted.configAtSpawn).toBe(true); // the file existed when tmux was dialed + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + } finally { + cleanup(id); + } + }); + + it("the derived path is the single composition: subshellSshConfigPath == buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)", () => { + const id = "11111111-2222-4333-8444-555555555555"; + expect(subshellSshConfigPath(id)).toBe(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + }); + + it("configPath mismatch: launch throws, nothing written, nothing spawned", async () => { + const id = `launcher-ssh-mismatch-${pid}`; + const scripted = new ScriptedTmux(() => buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await expect( + launcher.launch( + sshPlan(id, { + ssh: { + configPath: buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, `${id}-elsewhere`), + fileContent: SSH_CONTENT, + }, + }), + ), + ).rejects.toThrow(/ssh config path/i); + expect(scripted.spawns).toBe(0); // refused before the spawn + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("no ssh member: the launch creates no ssh dir for an ordinary pane", async () => { + const id = `launcher-ssh-absent-${pid}`; + const scripted = new ScriptedTmux(() => ""); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id)); + expect(scripted.spawns).toBe(1); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("subshellArtifacts: the ssh config path rides the list for ssh panes only", () => { + const id = `launcher-ssh-artifacts-${pid}`; + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => "") }); + expect(launcher.subshellArtifacts(id)).toEqual([subshellLogPath(id)]); + expect(launcher.subshellArtifacts(id, true)).toEqual([ + subshellLogPath(id), + buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), + ]); + }); + + it("delete round-trip: launch writes it, removeArtifacts(subshellArtifacts(id, true)) unlinks it", async () => { + const id = `launcher-ssh-delete-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => configPath) }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(existsSync(configPath)).toBe(true); + await launcher.removeArtifacts(launcher.subshellArtifacts(id, true)); + expect(existsSync(configPath)).toBe(false); + } finally { + cleanup(id); + } + }); +}); + +afterAll(() => { + // Defensive: the per-case cleanups own the paths; this sweeps any leftover + // from a mid-test throw so the shared test dataDir stays tidy. + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh"), { recursive: true, force: true }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index ed438d787..4b9a256d2 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -352,6 +352,28 @@ describe("launch", () => { expect(cmd.bestEffortLog).toBe(true); }); + it("an ssh plan ships the frame's ssh member verbatim; a plain plan carries no ssh key at all", async () => { + // spec 2026-10-07 decision 4: the launcher is a CARRIER. The path inside + // the member was composed by the launch service from this node's + // `ready.dataDir`; the agent re-derives it and refuses a byte-mismatch, + // so nothing here recomputes or rewrites it. + const h = makeHarness(); + const ssh = { configPath: `/home/u/.subshell/ssh/${SUB}/config`, fileContent: "Host *\n" }; + await h.launcher.launch({ ...planBase(), id: SUB, ssh }); + const cmd = h.calls[0]?.cmd as Extract; + expect(cmd.ssh).toEqual(ssh); + // The member is protocol-17 wire shape: it must survive the JSON round + // trip through the frame parser the node runs before dispatch. + expect(parseNodeCommandBody(JSON.parse(JSON.stringify(cmd)))).not.toBeNull(); + + // The mcp lesson, applied to ssh: an absent block stays ABSENT on the + // object (a conditional spread), never an explicit-undefined key. + const h2 = makeHarness(); + await h2.launcher.launch(planBase()); + const plain = h2.calls[0]?.cmd as Extract; + expect("ssh" in plain).toBe(false); + }); + it("a remote launch carries an argv whose binary slot is the placeholder", async () => { // The REAL pi plugin, not the stub: argv is the plugin's dialect, and the // resolve rule must be the pi manifest's detect block passed straight @@ -986,6 +1008,31 @@ describe("removeArtifacts", () => { }); }); +describe("subshellArtifacts sshPane (spec 2026-10-07 decision 4)", () => { + const NODE_SUB = "11111111-2222-4333-8444-555555555555"; + const triple = [ + `/home/u/.subshell/subshells/${NODE_SUB}.log`, + `/home/u/.subshell/mcp/${NODE_SUB}.json`, + `/home/u/.subshell/subshells/${NODE_SUB}.meta.json`, + ]; + + it("names the derived ssh config path only when the caller says the pane was an ssh pane", () => { + const h = makeHarness(); + expect(h.launcher.subshellArtifacts(NODE_SUB)).toEqual(triple); + // The path template is the SAME pane-runtime composition the launch-side + // configPath used; on the node it is composed from `ready.dataDir`. + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([ + ...triple, + `/home/u/.subshell/ssh/${NODE_SUB}/config`, + ]); + }); + + it("offline stays [] even for an ssh pane (no facts, no layout to name paths from)", () => { + const h = makeHarness(null); + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([]); + }); +}); + describe("offline short-circuit (no facts ⇒ no send)", () => { it("facts-dependent members fail before the wire; never-throws members stay soft", async () => { const h = makeHarness(null); diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 4df5513db..26a340eee 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -1,8 +1,10 @@ -import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch } from "node:fs"; +import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname } from "node:path"; import { stripAnsi } from "@internal/backend-errors"; import { buildHarnessCommand, + enforceMode, exitHookFor, type HarnessPlugin, paneEnvFor, @@ -13,7 +15,7 @@ import { probePaneLogLaunch } from "@/services/mcp-resolve.js"; import { logger } from "@/utils/logger.js"; import { readLogTailFrom, TAIL_POLL_MS } from "./log-tail.js"; import type { LaunchPlan, NodeLauncher } from "./node-launcher.js"; -import { subshellLogDir, subshellLogPath } from "./subshell-paths.js"; +import { subshellLogDir, subshellLogPath, subshellSshConfigPath } from "./subshell-paths.js"; /** Today's exact tmux/fs behavior behind the launcher seam (spec §6.3). */ export class LocalLauncher implements NodeLauncher { @@ -52,6 +54,16 @@ export class LocalLauncher implements NodeLauncher { * logged (debug) and swallowed, exactly like the pre-seam revive did. */ async launch(plan: LaunchPlan): Promise { + // SSH pane config (spec 2026-10-07 decision 4): whoever spawns the pane + // writes the file, and for a local row that is THIS process — under the + // SERVER's own dataDir, before the pane exists. The path is DERIVED, not + // trusted: a plan whose `configPath` is not byte-equal to + // `subshellSshConfigPath(id)` is a caller bug and throws before anything + // is composed or spawned (the same posture the agent's launch block + // refuses with). `SSH_AUTH_SOCK` needs no handling here: the pane's + // command IS the ssh process, so the service's merged `plan.subshellEnv` + // rides `buildHarnessCommand`'s env layers unchanged. + if (plan.ssh) await this.#writeSshConfig(plan.id, plan.ssh); const cmd = buildHarnessCommand( plan.harness, plan.binary, @@ -107,6 +119,31 @@ export class LocalLauncher implements NodeLauncher { } } + /** + * Materialize one ssh pane's rendered config on the SERVER's own disk: + * 0700 dir, 0600 file, {@link enforceMode} on both (mkdir's/writeFile's + * `mode` is clamped by the umask and applies only to segments actually + * created — the same re-tightening `ensureLogDirMode` and the agent's MCP + * block exist for). The path is this machine's OWN derivation; a plan + * naming anything else throws before any byte is written. + */ + async #writeSshConfig(id: string, ssh: { configPath: string; fileContent: string }): Promise { + const sshPath = subshellSshConfigPath(id); + if (ssh.configPath !== sshPath) { + // Byte-equality, same posture as the agent's launch block: the plane + // may name nothing else, and a mismatch here is a composition bug in + // the caller, not a user-reachable state. + throw new Error( + `ssh config path mismatch for ${id}: plan names ${JSON.stringify(ssh.configPath)}, the derived path is ${JSON.stringify(sshPath)}`, + ); + } + const dir = dirname(sshPath); + mkdirSync(dir, { recursive: true }); + await enforceMode(dir, 0o700); + writeFileSync(sshPath, ssh.fileContent, { mode: 0o600 }); + await enforceMode(sshPath, 0o600); + } + /** * Ensure the pane log's parent dir exists before pipe-pane: tmux runs * `cat >> ` once, and a missing parent dir makes it die silently — @@ -368,12 +405,23 @@ export class LocalLauncher implements NodeLauncher { } /** - * The local machine owns exactly one per-subshell file: the pipe-pane replay - * log (the MCP config under `subshellMcpConfigPath` is a control-plane file — - * `deleteSubshell` unlinks it directly for local and remote rows alike). + * The local machine owns the pipe-pane replay log, and — for an ssh pane + * only — the rendered config `launch` wrote under the server's dataDir + * (spec 2026-10-07 decision 4). The MCP config under `subshellMcpConfigPath` + * is a control-plane file — `deleteSubshell` unlinks it directly for local + * and remote rows alike. + * + * `sshPane` is the ROW's kind fact: only the caller that holds the row can + * say whether this pane owns a config to sweep. Every non-ssh caller gets + * the pre-ssh list unchanged. A natural death removes the whole per-pane + * ssh dir on a NODE (the agent's exit sweep); locally the death paths the + * control plane runs do not, so the delete sweep unlinks the config here + * and the empty dir is the residue `subshellArtifacts` deliberately does + * not name (removeArtifacts unlinks FILES; deleting a directory would be a + * new power on a path list that can also carry caller-supplied entries). */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + return sshPane ? [this.logPath(id), subshellSshConfigPath(id)] : [this.logPath(id)]; } /** Best-effort unlink of artifact paths (absent files are not errors). */ diff --git a/apps/server/api/src/services/nodes/node-launcher.ts b/apps/server/api/src/services/nodes/node-launcher.ts index 978aa7fa6..6eaf41d60 100644 --- a/apps/server/api/src/services/nodes/node-launcher.ts +++ b/apps/server/api/src/services/nodes/node-launcher.ts @@ -20,6 +20,17 @@ export interface LaunchPlan { subshellEnv: Record; /** MCP registration (dialect computed control-side) */ mcp?: McpRegistration; + /** + * The ssh pane's rendered config (spec 2026-10-07 decision 4): content the + * ssh service composed from the approved snapshot, plus the path + * `buildSshConfigPath(, id)` — derived, never chosen. + * `RemoteLauncher` ships the member verbatim on the launch frame; the agent + * re-derives the path from its OWN dataDir and refuses a byte-mismatch. + * `LocalLauncher` re-derives from the server's own dataDir, throws on a + * mismatch (a caller bug, the same posture), and writes the file (0600 in a + * 0700 dir) before spawning. Absent on every non-ssh pane. + */ + ssh?: { configPath: string; fileContent: string }; /** * Absolute path ON THE TARGET machine where `RemoteLauncher` ships * `mcp.fileContent` — composed by the caller from the node's `ready.dataDir` @@ -189,8 +200,14 @@ export interface NodeLauncher { * The node-side files a subshell owns — what delete removes. `[]` when the * machine cannot answer: an agent with no live `ready` facts has no readable * layout to name paths from (its artifacts age out with the node, §5.6). + * + * `sshPane` is the row's kind fact (spec 2026-10-07 decision 4): the + * rendered ssh config lives at the machine-derived path + * `/ssh//config`, and ONLY a caller holding the row can say + * the pane owns one. Callers pass the row's truth; `false` (the default) + * yields exactly the pre-ssh list, so every existing caller is unchanged. */ - subshellArtifacts(id: string): string[]; + subshellArtifacts(id: string, sshPane?: boolean): string[]; /** Best-effort deletion of artifact paths from {@link subshellArtifacts}. */ removeArtifacts(paths: string[]): Promise; } diff --git a/apps/server/api/src/services/nodes/remote-launcher.ts b/apps/server/api/src/services/nodes/remote-launcher.ts index 1ba130d57..45fe90e89 100644 --- a/apps/server/api/src/services/nodes/remote-launcher.ts +++ b/apps/server/api/src/services/nodes/remote-launcher.ts @@ -1,4 +1,4 @@ -import type { HarnessPlugin } from "@internal/pane-runtime"; +import { buildSshConfigPath, type HarnessPlugin } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, @@ -300,6 +300,14 @@ export class RemoteLauncher implements NodeLauncher { // Narrowed by the gate above: required since protocol 3, and always // present here. resolve: plan.harness.detectSpec, + // The ssh pane's rendered config (spec 2026-10-07 decision 4): carried + // VERBATIM — `configPath` was composed by the launch service from this + // node's `ready.dataDir` with the same `buildSshConfigPath` the agent + // runs, and the AGENT re-derives and refuses a byte-mismatch, so this + // launcher neither recomputes nor rewrites. Conditional spread, never + // `ssh: undefined` on a plain plan (the frame's parser refuses an + // explicit-undefined optional; the mcp comment above is the precedent). + ...(plan.ssh ? { ssh: { configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent } } : {}), }; try { await this.#send(cmd, LAUNCH_TIMEOUT_MS); @@ -536,18 +544,42 @@ export class RemoteLauncher implements NodeLauncher { return factsPath(this.#requireFacts(), `mcp/${id}.json`); } + /** + * The ssh pane's rendered config ON THE NODE: `/ssh//config` + * — the delete-side twin of the `launch.ssh.configPath` the launcher ships + * and the agent's byte-derived write target. Composed from the same + * `ready.dataDir` as the MCP/meta paths; it is the SAME pane-runtime + * {@link buildSshConfigPath} the node runs at write time, so the unlink + * lands exactly where the write landed (spec 2026-10-07 decision 4). The + * delete-time `remove_paths` unlinks the config FILE; the whole per-pane ssh + * DIR is swept by the agent's exit watcher on natural death (report.ts). + * Class-local (not on {@link NodeLauncher}) like {@link metaArtifactPath} — + * the concept is agent-side layout. Throws {@link NoLiveConnectionError} + * offline (sync member, same shape as {@link logPath}). + */ + sshArtifactPath(id: string): string { + assertNodePathId(id); + return buildSshConfigPath(this.#requireFacts().dataDir, id); + } + /** * The three files a subshell leaves on the node — log + MCP config + the * agent's own meta record, in the order the pre-seam `deleteSubshell` block * pushed them (spec §6.4). Empty when the node has no live `ready` facts: * no facts, no layout to name paths from, and the artifacts age out with * the node — the same offline skip every pre-seam path made individually. - * The id guard rides through the three composed paths, so there is no - * fourth interpolation to forget. + * The id guard rides through the composed paths, so there is no extra + * interpolation to forget. + * + * `sshPane` (the row's kind fact, spec 2026-10-07 decision 4) adds the + * rendered config as a fourth path; every non-ssh caller gets the triple + * unchanged. */ - subshellArtifacts(id: string): string[] { + subshellArtifacts(id: string, sshPane = false): string[] { if (!this.#facts()) return []; - return [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + const artifacts = [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(this.sshArtifactPath(id)); + return artifacts; } /** diff --git a/apps/server/api/src/services/nodes/subshell-paths.ts b/apps/server/api/src/services/nodes/subshell-paths.ts index f3b3fb3ea..bec98b45f 100644 --- a/apps/server/api/src/services/nodes/subshell-paths.ts +++ b/apps/server/api/src/services/nodes/subshell-paths.ts @@ -1,3 +1,4 @@ +import { buildSshConfigPath } from "@internal/pane-runtime"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; /** @@ -20,3 +21,17 @@ export function subshellLogDir(): string { export function subshellLogPath(id: string): string { return `${subshellLogDir()}/${id}.log`; } + +/** + * The rendered ssh config for one pane ON THE SERVER's own disk — the local + * twin of the node-side path the agent derives from its own dataDir + * (spec 2026-10-07 decision 4). The composition is the pane-runtime + * {@link buildSshConfigPath} itself, not a re-spelling of its template: + * plane and node agree byte-for-byte because they call ONE function, and the + * `LocalLauncher` launch path refuses a plan whose `configPath` disagrees + * with this derivation. Throws on an id outside the composition guard, like + * every other call site. + */ +export function subshellSshConfigPath(id: string): string { + return buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index d33dc3811..f62b6c82d 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -54,7 +54,7 @@ import { previewCachePut, } from "@/services/nodes/preview-cache.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; -import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; +import { subshellLogPath, subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; @@ -997,15 +997,29 @@ export class SubshellManagerService { // Best-effort artifact cleanup ON THE ROW'S NODE (the log is only an // attach-replay artifact; the MCP config holds no secrets but nothing // should be left behind). The layout lives behind the launcher seam - // (spec §6.4): a local row leaves exactly its replay log; an agent node - // names the triple (log + MCP config + the agent's own meta record — - // deliberately left behind by a kill, so the DELETE unlinks it) from its - // live `ready` facts, and an OFFLINE agent answers `[]`: no facts, no - // layout to name paths from, artifacts age out with the node (§5.6). + // (spec §6.4): a local row leaves exactly its replay log (plus, for an + // ssh pane, the rendered config the LocalLauncher wrote under the + // server's dataDir); an agent node names its triple (log + MCP config + + // the agent's own meta record — deliberately left behind by a kill, so + // the DELETE unlinks it) from its live `ready` facts, and an OFFLINE + // agent answers `[]`: no facts, no layout to name paths from, artifacts + // age out with the node (§5.6). // The local short-circuit is row-keyed because a TEST launcher answers for // EVERY node id (the phase-0 suites) — a fake standing in for `local` // must keep receiving the local artifact set, never the agent triple. - const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id); + // The ssh config path rides the list for THIS row only, and the row's + // kind fact (spec 2026-10-07 decision 4) is read HERE — the launchers + // compose paths, only the caller holding the row can say which files the + // pane OWNS. `harnessId === "ssh"` is that truth today (the ssh service + // launches the ssh harness and only it carries a rendered config); when + // migration 0048's snapshot column lands, `row.ssh !== null` becomes the + // spelled-out kind fact at this one line — the launchers already take + // the flag. + const sshPane = row.harnessId === "ssh"; + const artifacts = + row.nodeId === LOCAL_NODE_ID + ? [launcher.logPath(id), ...(sshPane ? [subshellSshConfigPath(id)] : [])] + : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); // And the generated MCP config (no secrets, but nothing to leave behind). try { From 9402722246faa8e872989b30c9eae6cb50e516a9 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 20:40:11 -0700 Subject: [PATCH 031/170] feat(db): ssh_saved_hosts + the subshells.ssh snapshot column (migration 0048) The ledger is keyed on the canonical host:port destination (optional user@ prefix) with a unique (owner_user_id, destination) index and a recency index on (owner_user_id, last_connect_at desc); the snapshot column's presence is the owner-only-input rule's trigger. The backup ceiling advances with the registration and the older-snapshot fixture's strip-set now removes 0046+0047+0048 rows and DDL so the simulated ledger stays a valid contiguous prefix (the tier-1 coupling, commit 0ebd6cee). --- .../__tests__/backup-cli-subprocess.test.ts | 28 ++- apps/server/api/src/db/migrate.ts | 4 + .../0048-ssh-launch-and-saved-hosts.ts | 46 +++++ .../0048-ssh-launch-and-saved-hosts.test.ts | 187 ++++++++++++++++++ apps/server/api/src/db/types/index.ts | 2 + .../src/db/types/ssh-saved-hosts.db-types.ts | 48 +++++ .../api/src/db/types/subshells.db-types.ts | 10 + .../api/src/services/backups/database.ts | 2 +- 8 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts create mode 100644 apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index c5c3ce828..6acba4c3c 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,17 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. + // WHY 0046+0047+0048 together: this era's three migrations are the tail of + // the registered ledger, so the prefix runs contiguously through 0045 and + // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0048) means + // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; UPDATE migration_restore_probe SET value='newer destination';", + "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -415,7 +419,9 @@ test("real entry: available older upgrade snapshot restores and migrates before const offline = new Database(instance.databasePath, { readonly: true }); expect( offline - .query("SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled')") + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts')", + ) .all(), ).toEqual([]); offline.close(); @@ -424,10 +430,14 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", ) .all(), - ).toEqual([{ name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }]); + ).toEqual([ + { name: "0046-backup-recovery" }, + { name: "0047-node-ssh-enabled" }, + { name: "0048-ssh-launch-and-saved-hosts" }, + ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ name: "backup_recovery", @@ -437,6 +447,12 @@ test("real entry: available older upgrade snapshot restores and migrates before .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") .all(), ).toEqual([{ name: "ssh_enabled" }, { name: "ssh_enabled_at" }]); + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_saved_hosts'").get()).toEqual({ + name: "ssh_saved_hosts", + }); + expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ + name: "ssh", + }); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 25a4a654e..e47050718 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -47,6 +47,7 @@ import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; /** * Runs all pending Kysely migrations against the app database. @@ -136,6 +137,9 @@ export async function runMigrations(): Promise { // The per-node SSH capability gate: a flag plus its changedAt stamp, // default OFF so an upgrade never turns SSH egress on anywhere (spec 4.3). "0047-node-ssh-enabled": sshEnabledMigration, + // The SSH launcher tier: the per-owner destination ledger and the + // pane's approved snapshot in `subshells.ssh` (spec 2026-10-07 §7). + "0048-ssh-launch-and-saved-hosts": sshLaunchMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts new file mode 100644 index 000000000..b2a529766 --- /dev/null +++ b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts @@ -0,0 +1,46 @@ +import type { Kysely } from "kysely"; + +/** + * The SSH launcher tier (spec 2026-10-07 §7): `ssh_saved_hosts`, the per-owner + * destination ledger, and `subshells.ssh`, the pane's approved snapshot. + * + * The ledger is keyed by the RESOLVED canonical destination (the spelling + * `sshCanonicalDestination` in the db-types file produces), never by the + * alias, so an edited alias cannot silently re-point a saved row. + * `subshells.ssh` is NULL for + * every pane that is not an SSH terminal, which is exactly what the + * owner-only-input rule reads: the value's presence is the trigger. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_saved_hosts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("alias", "text") + .addColumn("node_id", "text", (col) => col.notNull()) + .addColumn("saved_at", "text") + .addColumn("last_connect_at", "text", (col) => col.notNull()) + .execute(); + // The key: one row per owner per canonical destination. An insert that + // arrives at a standing key is the service's refresh, not a second entry. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_destination") + .unique() + .on("ssh_saved_hosts") + .columns(["owner_user_id", "destination"]) + .execute(); + // The list read is one owner's recents, newest first: the recency column is + // `last_connect_at`, refreshed on every launch, DESC spelled in the index. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_recent") + .on("ssh_saved_hosts") + .columns(["owner_user_id", "last_connect_at desc"]) + .execute(); + await db.schema.alterTable("subshells").addColumn("ssh", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("ssh").execute(); + await db.schema.dropTable("ssh_saved_hosts").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts new file mode 100644 index 000000000..e76257d4c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts @@ -0,0 +1,187 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; + +interface MigrationDatabase { + [table: string]: Record; + ssh_saved_hosts: Record; + subshells: Record; + user: Record; +} + +/** + * The SSH launcher tier's storage (spec 2026-10-07 §7): the `ssh_saved_hosts` + * destination ledger (keyed on the canonical destination, unique per owner, + * recency ordered) and `subshells.ssh`, the nullable snapshot column whose + * presence is the owner-only-input rule's trigger. + */ +describe("migration 0048-ssh-launch-and-saved-hosts", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a subshell row the way the pre-0048 repository did - no ssh column at all. */ + const legacySubshell = (id: string) => + db + .insertInto("subshells") + .values({ + id, + user_id: "u1", + profile_id: "p1", + harness_id: "claude-code", + name: id, + working_dir: "/tmp", + }) + .execute(); + + /** A ledger row as the (future) service writes it: full key, live node, connect stamp. */ + const savedHost = (id: string, destination: string, ownerUserId = "u1") => + db + .insertInto("ssh_saved_hosts") + .values({ + id, + owner_user_id: ownerUserId, + destination, + alias: null, + node_id: "local", + saved_at: null, + last_connect_at: "2026-10-07T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0048-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target: better-auth's user table, minimally shaped so + // `references("user.id")` resolves on this throwaway file (same posture + // as the 0040 test). + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@subshell.local')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + // Written BEFORE the column exists - the upgrade case: a pre-ssh pane reads NULL. + await legacySubshell("pre-existing"); + await sshLaunchMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_saved_hosts with the ledger's seven columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_saved_hosts')`.execute(db); + expect(cols.rows.map((c) => c.name)).toEqual([ + "id", + "owner_user_id", + "destination", + "alias", + "node_id", + "saved_at", + "last_connect_at", + ]); + }); + + it("adds the nullable ssh snapshot column; a pre-existing pane reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh"); + const r = await sql<{ ssh: string | null }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0].ssh).toBeNull(); + // A launch row stores its approved snapshot as JSON text and reads back. + const snapshot = JSON.stringify({ host: "example.test", port: 22, user: null }); + await sql`UPDATE subshells SET ssh = ${snapshot} WHERE id = 'pre-existing'`.execute(db); + const back = await sql<{ ssh: string }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(back.rows[0].ssh).toBe(snapshot); + await sql`UPDATE subshells SET ssh = NULL WHERE id = 'pre-existing'`.execute(db); + }); + + it("carries the unique (owner_user_id, destination) index as the key", async () => { + const lists = await sql<{ name: string; unique: number }>` + SELECT name, "unique" FROM pragma_index_list('ssh_saved_hosts') + `.execute(db); + const unique = lists.rows.find((i) => i.name === "idx_ssh_saved_hosts_owner_destination"); + expect(unique).toEqual({ name: "idx_ssh_saved_hosts_owner_destination", unique: 1 }); + await savedHost("h1", "dev@example.test:22"); + // The same destination under one owner is the SAME row's subject, never a second one. + await expect(savedHost("h2", "dev@example.test:22")).rejects.toThrow(); + // ...and under another owner it is a different person's ledger entry. + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@subshell.local')`.execute(db); + await savedHost("h3", "dev@example.test:22", "u2"); + const keys = await sql<{ owner_user_id: string }>` + SELECT owner_user_id FROM ssh_saved_hosts WHERE destination = 'dev@example.test:22' ORDER BY owner_user_id + `.execute(db); + expect(keys.rows.map((r) => r.owner_user_id)).toEqual(["u1", "u2"]); + }); + + it("carries the recency index, last_connect_at DESC for one owner's list read", async () => { + const lists = await sql<{ name: string }>`SELECT name FROM pragma_index_list('ssh_saved_hosts')`.execute(db); + expect(lists.rows.map((i) => i.name)).toContain("idx_ssh_saved_hosts_owner_recent"); + // The direction is the point: the list read is newest-first, pinned in the DDL. + const ddl = await sql<{ sql: string }>` + SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_ssh_saved_hosts_owner_recent' + `.execute(db); + expect(ddl.rows[0].sql).toMatch(/"last_connect_at" desc/); + }); + + it("requires the key parts and cascades the owner away", async () => { + // last_connect_at is NOT NULL: every row is a connect record, saved or not. + await expect( + db + .insertInto("ssh_saved_hosts") + .values({ + id: "h-no-stamp", + owner_user_id: "u1", + destination: "x@y.test:22", + alias: null, + node_id: "local", + saved_at: null, + } as never) + .execute(), + ).rejects.toThrow(); + // Deleting the account drops its ledger (FK cascade, like every owner column). + await savedHost("h-cascade", "gone@example.test:22", "u2"); + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const left = await sql<{ id: string }>`SELECT id FROM ssh_saved_hosts WHERE owner_user_id = 'u2'`.execute(db); + expect(left.rows).toEqual([]); + }); + + it("down drops the table and the snapshot column and leaves the panes", async () => { + await sshLaunchMigration.down(db); + const tables = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ssh_saved_hosts' + `.execute(db); + expect(tables.rows).toEqual([]); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).not.toContain("ssh"); + const left = await sql<{ id: string }>`SELECT id FROM subshells`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["pre-existing"]); + }); +}); + +/** + * The canonical destination spelling (spec 2026-10-07 §7): `host:port` with an + * optional `user@` prefix. The key of the ledger, so its three pinned shapes + * are pinned here, at its only home. + */ +describe("sshCanonicalDestination", () => { + it("prefixes the user when the snapshot carries one", () => { + expect(sshCanonicalDestination({ host: "h", port: 22, user: "u" })).toBe("u@h:22"); + }); + + it("spells a null user as a bare host:port, never an empty prefix", () => { + expect(sshCanonicalDestination({ host: "h", port: 2222, user: null })).toBe("h:2222"); + }); + + it("passes an already-bracketed IPv6 host through untouched", () => { + expect(sshCanonicalDestination({ host: "[::1]", port: 22, user: null })).toBe("[::1]:22"); + }); +}); diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 075613c5c..d7d997127 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,7 @@ import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-s import type { PromptTable } from "@/db/types/prompts.db-types.js"; import type { RecentPathTable } from "@/db/types/recent-paths.db-types.js"; import type { SettingTable } from "@/db/types/settings.db-types.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; import type { SubshellShareTable } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import type { UserMetaTable } from "@/db/types/user-meta.db-types.js"; @@ -57,4 +58,5 @@ export interface Database { identities: IdentityTable; notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; + sshSavedHosts: SshSavedHostTable; } diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts new file mode 100644 index 000000000..23251f460 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -0,0 +1,48 @@ +/** + * One saved or recently-used SSH destination for one owner (spec 2026-10-07 + * §7). Keyed by the RESOLVED canonical destination so an edited alias can + * never silently re-point a saved row. `saved_at` is what the human gave it; + * recency is `last_connect_at`, which every launch refreshes whether or not + * the row was ever saved. + */ +export interface SshSavedHostTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** + * canonical `user@host:port` (user part = the resolved snapshot's effective + * user, or "" default spelled as the connecting account? NO: `host:port` + * with an empty user prefix when the snapshot's user is null). The spelling + * rule is {@link sshCanonicalDestination}; both writer and reader list + * import it, and it has no other home. Unique per owner + * (idx_ssh_saved_hosts_owner_destination). + */ + destination: string; + /** display-only alias (the config token typed or discovered); never used as a key */ + alias: string | null; + /** the connecting machine used for the most recent launch to this destination */ + nodeId: string; + /** ISO 8601 the human saved this destination (null = recency-only row) */ + savedAt: string | null; + /** ISO 8601 of the most recent launch to this destination */ + lastConnectAt: string; +} + +/** + * Spell the canonical destination that keys {@link SshSavedHostTable}: + * `host:port`, prefixed with `user@` when the resolved snapshot carries a + * user. The host is passed through as given - an IPv6 literal arrives + * already bracketed (`[::1]`) and the port suffix is what disambiguates it. + */ +export function sshCanonicalDestination({ + host, + port, + user, +}: { + host: string; + port: number; + user: string | null; +}): string { + return user === null ? `${host}:${port}` : `${user}@${host}:${port}`; +} diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index 4c10e155f..d40c145bb 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -78,6 +78,13 @@ export interface SubshellTable { * later act rewrites it. See migration 0039. */ crossAgent: number; + /** + * an SSH-terminal pane carries its approved snapshot here; the value's + * presence is the owner-only-input rule's trigger (spec §5.4). JSON-encoded + * `SshConnectionSnapshotWire`; never serialized to clients except the + * launch's own answer. See migration 0048. + */ + ssh: string | null; /** * DEPRECATED (spec 2026-09-03): the terminal history cap moved per-USER * (`user_meta.terminal_replay_lines`, migration 0020). This column is @@ -113,6 +120,7 @@ export type NewSubshell = Omit< | "lastPushUrgency" | "nodeId" | "crossAgent" + | "ssh" | "terminalReplayLines" > & { /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ @@ -121,6 +129,8 @@ export type NewSubshell = Omit< nodeId?: string; /** 1 = agent-launched cross-agent comms pane; omitted = DB default 0 (human) */ crossAgent?: number; + /** SSH snapshot JSON for an ssh pane; omitted = NULL = not an ssh pane (migration 0048) */ + ssh?: string | null; /** Per-subshell terminal replay cap; omitted = NULL = instance default */ terminalReplayLines?: number | null; harnessSessionId?: string | null; diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index b897e6afb..284d9d864 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0047-node-ssh-enabled"; +export const LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { From ca24274b0e0600e1950eddb0a6cf7403a19ea69e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 21:09:34 -0700 Subject: [PATCH 032/170] feat(api): ssh launch plumbing - service, RPC wrappers, saved-host ledger, createSubshell ssh params, exit sweep, restart refusal The plane half beneath the routes: BackendErrorCodes joins (SSH_GATE_OFF, NODE_PROTOCOL_HELD, ALIAS_UNSAFE, SSH_NODE_REFUSED, SSH_NO_RESTART), the ssh_saved_hosts repository (touch/markSaved/listSaved/listRecent/remove; the New* insert shape beside it, per task-6's note), ssh-rpc.ts wrapping the two node commands behind their frozen result parsers, and ssh-launch.service.ts: gate -> held -> wire-grammar destination check -> resolve -> compose (parseSshConnectionSnapshot re-validated at the boundary; configPath derived from the target machine's dataDir; the -F-first option tail with the '-- host' end, SSH_AUTH_SOCK only when named) -> createSubshell -> recency touch -> ssh.launch audit on success only. createSubshell (service + manager) grows the honest plumbing params; the row's ssh column is written and the delete path's kind fact re-points from harnessId to the column (the comment's own promise). reportExit sweeps the LOCAL ssh config dir best-effort, ssh panes refuse restart with SSH_NO_RESTART, and the five hand-migrated manager suites get migration 0048 in their chains (the repository now names the column). --- .../restart-ssh-refusal.route.test.ts | 106 ++++ .../ssh-saved-hosts.repository.test.ts | 181 +++++++ .../ssh-saved-hosts.repository.ts | 141 ++++++ .../services/__tests__/helpers/node-fakes.ts | 12 +- .../__tests__/ssh-launch.service.test.ts | 106 ++++ .../subshell-manager-maintenance.test.ts | 2 + .../__tests__/subshell-manager-mcp.test.ts | 2 + .../__tests__/subshell-manager-remote.test.ts | 2 + .../__tests__/subshell-manager-ssh.test.ts | 257 ++++++++++ .../subshell-manager-titling.test.ts | 2 + .../subshell-manager.service.test.ts | 2 + .../subshells-exit-ssh-sweep.test.ts | 118 +++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 98 ++++ .../api/src/services/ssh-launch.service.ts | 476 ++++++++++++++++++ .../src/services/subshell-manager.service.ts | 93 +++- .../api/src/services/subshells.service.ts | 75 +++ packages/backend-errors/src/error-codes.ts | 58 +++ 17 files changed, 1720 insertions(+), 11 deletions(-) create mode 100644 apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts create mode 100644 apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts create mode 100644 apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-launch.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts create mode 100644 apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts create mode 100644 apps/server/api/src/services/nodes/ssh-rpc.ts create mode 100644 apps/server/api/src/services/ssh-launch.service.ts diff --git a/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts new file mode 100644 index 000000000..a3aef3d1a --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * An ssh pane refuses `POST /:id/restart` with a named 409 (decision 7): its + * "session" lives on the far end of a socket, so a restart would spawn a NEW + * ssh process the human never asked to reconnect — the launcher is the way + * back in. The refusal is keyed to the `subshells.ssh` column (the kind fact, + * migration 0048), asked AFTER the visibility gate (a stranger still gets the + * 404) and BEFORE anything the restart path writes. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); +const subshells = new SubshellsRepository(db); + +const pw = "ssh-nores-1"; +const ownerEmail = `sshnr-owner-${crypto.randomUUID()}@subshell.local`; +const strangerEmail = `sshnr-str-${crypto.randomUUID()}@subshell.local`; +let ownerCookie: string; +let strangerCookie: string; +let ownerId: string; +const created: string[] = []; + +function seedSshRow(id: string, ssh: string | null, nodeId = LOCAL_NODE_ID): Promise { + created.push(id); + return subshells.create({ + id, + userId: ownerId, + harnessId: "ssh", + name: "ssh pane", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId, + ssh, + }); +} + +function restart(id: string, cookie: string) { + return app.fetch( + new Request(`http://localhost:3099/api/subshells/${id}/restart`, { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(strangerEmail); + ownerCookie = await signIn(ownerEmail, pw); + strangerCookie = await signIn(strangerEmail, pw); +}); + +afterAll(async () => { + for (const id of created) await subshells.delete(id).catch(() => {}); + for (const mail of [ownerEmail, strangerEmail]) await deleteUserByEmailOrId(mail); +}); + +describe("ssh panes refuse restart (decision 7)", () => { + it("a row whose snapshot column is set is refused with the named 409 before anything restarts", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NO_RESTART); + expect(body.message.toLowerCase()).toContain("launch"); // the remedy: reconnect from the launcher + const row = await subshells.findById(id); + expect(row?.status).toBe("terminated"); // nothing moved + }); + + it("a stranger gets the ordinary 404 on an ssh row (the gate runs first; the refusal is no oracle)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + expect((await restart(id, strangerCookie)).status).toBe(404); + }); + + it("a non-ssh row is NOT refused by this rule (the offline node still answers its own NODE_OFFLINE 409)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, null, "node-gone-offline"); + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts new file mode 100644 index 000000000..a9814f282 --- /dev/null +++ b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; + +/** + * The per-owner SSH destination ledger (spec 2026-10-07 §7). The properties + * this suite exists to pin: + * + * - the key is (owner, canonical destination): `touch` REFRESHES recency on + * an existing row rather than adding a second one, and two owners may hold + * the same destination as two rows; + * - recency (`last_connect_at`) rides EVERY launch whether or not the row was + * ever saved; `saved_at` is only what the human gave (null = recency-only); + * - the list reads never carry another owner's rows, and `remove` refuses a + * foreign id exactly like an absent one (the ownership axis, docs/security.md §3). + */ + +const repo = new SshSavedHostsRepository(db); +const emails: string[] = []; +const inserted: string[] = []; +let ownerA = ""; +let ownerB = ""; + +const at = (iso: string) => ({ at: iso }); + +async function mkUser(tag: string): Promise { + const email = `sh-repo-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("sh-repo-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerA = await mkUser("a"); + ownerB = await mkUser("b"); +}); + +afterAll(async () => { + for (const id of inserted) await db.deleteFrom("sshSavedHosts").where("id", "=", id).execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("ssh-saved-hosts repository", () => { + it("touch inserts a recency-only row and REFRESHES it on the same key (one row, moved node + stamp)", async () => { + const destination = "example.test:22"; + await repo.touch({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "work", + ...at("2026-10-07T10:00:00.000Z"), + }); + const row = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", destination) + .executeTakeFirstOrThrow(); + inserted.push(row.id); + expect(row.ownerUserId).toBe(ownerA); + expect(row.nodeId).toBe("node-1"); + expect(row.alias).toBe("work"); + expect(row.savedAt).toBeNull(); + expect(row.lastConnectAt).toBe("2026-10-07T10:00:00.000Z"); + + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-2", ...at("2026-10-07T12:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(1); // refreshed, not doubled + expect(rows[0].id).toBe(row.id); // the SAME row + expect(rows[0].nodeId).toBe("node-2"); // the machine of the most recent launch + expect(rows[0].lastConnectAt).toBe("2026-10-07T12:00:00.000Z"); + // An absent alias does NOT wipe the standing one (the display token outlives a bare-host launch). + expect(rows[0].alias).toBe("work"); + }); + + it("two owners may hold the same canonical destination as two rows", async () => { + const destination = "shared.test:2222"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + await repo.touch({ ownerUserId: ownerB, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(2); + inserted.push(...rows.map((r) => r.id)); + }); + + it("markSaved stamps saved_at and returns the row (upsert on an existing recency-only key)", async () => { + const destination = "save.test:22"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T09:00:00.000Z") }); + const first = await db + .selectFrom("sshSavedHosts") + .select("id") + .where("destination", "=", destination) + .where("ownerUserId", "=", ownerA) + .executeTakeFirstOrThrow(); + inserted.push(first.id); + + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "lab", + ...at("2026-10-07T11:00:00.000Z"), + }); + expect(row.id).toBe(first.id); // saved the SAME row the launch had touched + expect(row.savedAt).toBe("2026-10-07T11:00:00.000Z"); + expect(row.alias).toBe("lab"); + expect(row.lastConnectAt).toBe("2026-10-07T11:00:00.000Z"); + }); + + it("listSaved reads only saved rows, newest-saved first; listRecent reads everything by recency", async () => { + // Destinations tagged per case: the earlier cases' rows exist too, and a + // whole-table ordering assertion would depend on THEIR stamps. + const tag = crypto.randomUUID().slice(0, 8); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-old.test:22`, + nodeId: "n", + ...at("2026-10-07T08:00:00.000Z"), + }); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-new.test:22`, + nodeId: "n", + ...at("2026-10-07T14:00:00.000Z"), + }); + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-just.test:22`, + nodeId: "n", + ...at("2026-10-07T15:00:00.000Z"), + }); + const saved = await repo.listSaved(ownerA); + const recent = await repo.listRecent(ownerA); + const savedDests = saved.map((r) => r.destination).filter((d) => d.startsWith(tag)); + // Only rows a human gave (savedAt non-null); the recency-only row is absent. + expect(savedDests).toEqual([`${tag}-new.test:22`, `${tag}-old.test:22`]); + expect(savedDests).not.toContain(`${tag}-just.test:22`); + // Recency lists everything, newest connect first; the unsaved launch leads. + expect(recent[0]?.destination).toBe(`${tag}-just.test:22`); + const recentDests = recent.map((r) => r.destination); + expect(recentDests).toContain(`${tag}-old.test:22`); + inserted.push(...saved.map((r) => r.id), ...recent.map((r) => r.id)); + }); + + it("listRecent caps at 20 by default and honors an explicit limit", async () => { + const tag = `cap-${crypto.randomUUID().slice(0, 8)}`; + for (let i = 0; i < 25; i++) { + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-${i}.test:22`, + nodeId: "n", + ...at(`2026-10-0${(i % 7) + 1}T00:00:0${i % 10}.000Z`), + }); + } + const rows = await db.selectFrom("sshSavedHosts").select("id").where("destination", "like", `${tag}-%`).execute(); + inserted.push(...rows.map((r) => r.id)); + expect(await repo.listRecent(ownerA)).toHaveLength(20); + expect(await repo.listRecent(ownerA, 5)).toHaveLength(5); + }); + + it("remove deletes the owner's row; a foreign id and an absent id both answer false", async () => { + const destination = "remove.test:22"; + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "n", + ...at("2026-10-07T10:00:00.000Z"), + }); + expect(await repo.remove(ownerB, row.id)).toBe(false); // foreign: refused like an absent one (no existence oracle) + expect(await db.selectFrom("sshSavedHosts").select("id").where("id", "=", row.id).executeTakeFirst()).toBeDefined(); + expect(await repo.remove(ownerA, row.id)).toBe(true); + expect(await repo.remove(ownerA, row.id)).toBe(false); // already gone + expect(await repo.remove(ownerA, "never-an-id")).toBe(false); + }); +}); diff --git a/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts new file mode 100644 index 000000000..afb28185b --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts @@ -0,0 +1,141 @@ +import { sql } from "kysely"; +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; + +/** + * Repository for the per-owner SSH destination ledger (spec 2026-10-07 §7, + * migration 0048). The whole design fits in two sentences: a row's key is + * (owner, canonical destination) so an edited alias can never re-point a + * saved entry, and every launch refreshes recency whether or not a human ever + * saved the row. + * + * The `New*` insert shape lives HERE, beside the repository, rather than in + * `ssh-saved-hosts.db-types.ts` (the shape task-6 recorded as missing): every + * column is NOT NULL-with-no-default except `alias`/`saved_at`, so an insert + * is the full row and nothing is defaulted — there is no partial-write + * vocabulary to type. `touch` and `markSaved` are its only callers. + */ +export type NewSshSavedHost = SshSavedHostTable; + +/** The caller-composed facts one write carries; `at` is the caller's stamp (the repository owns no clock). */ +interface LedgerWrite { + ownerUserId: string; + /** canonical `host:port` / `user@host:port` from `sshCanonicalDestination` — handoff 1: ONLY fields off a validated snapshot */ + destination: string; + /** the connecting machine of THIS write — the row's node always follows the most recent launch */ + nodeId: string; + /** display token; absent on update KEEPS the standing one (a bare-host launch does not erase an alias) */ + alias?: string | null; + /** ISO 8601 the caller stamps as the act's time */ + at: string; +} + +const RECENT_LIMIT = 20; + +export class SshSavedHostsRepository extends BaseRepository { + /** + * Record a launch: upsert on (owner, destination) and refresh recency. + * Never touches `saved_at` — that column is what the human gave and only + * {@link markSaved} sets it. + */ + async touch(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: null, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + lastConnectAt: write.at, + // COALESCE, not a plain set: an omitted alias must leave the display + // token the human saw standing (the key is the destination; the + // alias is what the list labels it with). + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + } + + /** + * Save a destination (upsert-mark-saved): the same refresh as {@link touch} + * plus `saved_at`, returning the row so the route can answer with it. + */ + async markSaved(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: write.at, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + // Re-saving re-stamps the human's own act (it is a fresh save); + // recency rides with it, same as a launch. + savedAt: write.at, + lastConnectAt: write.at, + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + const row = await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", write.ownerUserId) + .where("destination", "=", write.destination) + .executeTakeFirst(); + // The upsert just wrote the key; a row that is not there now is a dropped + // connection, not a state. + if (!row) throw new Error(`ssh saved-host row vanished mid-write (${write.ownerUserId}/${write.destination})`); + return row; + } + + /** The owner's SAVED rows (a human stamped `saved_at`), newest save first, capped. */ + async listSaved(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("savedAt", "is not", null) + .orderBy("savedAt", "desc") + .limit(limit) + .execute(); + } + + /** The owner's most-recently-connected destinations (saved or not), newest first, capped. */ + async listRecent(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("lastConnectAt", "desc") + .limit(limit) + .execute(); + } + + /** + * Delete one of the owner's rows. A foreign id and an absent id answer the + * same `false` — the caller renders both as the 404 the ownership axis + * demands (ids are never existence oracles, docs/security.md §3). + */ + async remove(ownerUserId: string, id: string): Promise { + const res = await this.db + .deleteFrom("sshSavedHosts") + .where("id", "=", id) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts index a29e788b1..06dbfd882 100644 --- a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts +++ b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; import type { LaunchPlan, NodeLauncher } from "@/services/nodes/node-launcher.js"; import { attachConnection, detachConnection, type NodeFacts, type NodeSocket } from "@/services/nodes/node-registry.js"; @@ -107,10 +108,15 @@ export class FakeNodeLauncher implements NodeLauncher { /** * Mirrors {@link RemoteLauncher.subshellArtifacts}: the delete-time triple, * with the MCP path composed under `nodeOnline()`'s default `/node-data` - * dataDir (the same source the manager's inline delete reads). + * dataDir (the same source the manager's inline delete reads), plus the + * ssh config at the node-derived path for an ssh pane (spec 2026-10-07 + * decision 4) — the fake takes the row's kind flag exactly the real + * launcher does, so a test can pin WHO passes it. */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + const artifacts = [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(buildSshConfigPath("/node-data", id)); + return artifacts; } async readLogTail(): Promise<{ lines: string[]; truncated: boolean }> { return { lines: [], truncated: false }; diff --git a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts new file mode 100644 index 000000000..0ca430c04 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { buildSshConfigPath, renderSshConfigContents } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { composeSshLaunch, isWireSafeSshDestination } from "@/services/ssh-launch.service.js"; + +/** + * The compose half of the ssh launch service (spec 2026-10-07 §5.2, decisions + * 3/4, handoff 4): the pure function that turns an APPROVED snapshot plus the + * target machine's dataDir into everything the launch needs. The route matrix + * (`api/ssh/__tests__/ssh-routes.test.ts`) pins when it runs; this file pins + * WHAT it returns, byte-exactly, because the agent re-derives the path and + * refuses a single-byte mismatch. + */ + +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const ID = "11111111-2222-4333-8444-555555555555"; + +describe("isWireSafeSshDestination", () => { + it("accepts aliases and bare hosts; refuses the option-like, the blank, the oversized and anything with whitespace or control characters", () => { + for (const ok of ["work", "example.test", "dev@10.0.0.7", "a".repeat(253), "10.0.0.7", "[::1]", "box_1"]) { + expect(isWireSafeSshDestination(ok)).toBe(true); + } + for (const bad of [ + "", // blank answers 400 at the wire schema, and false here all the same + "-x", + "-oProxyCommand=evil", + "has space", + "tab\tsep", + "esc\x07ape", + "a".repeat(254), // past SSH_NAME_MAX_CHARS: the wire grammar's own cap + "new\nline", + ]) { + expect(isWireSafeSshDestination(bad)).toBe(false); + } + }); +}); + +describe("composeSshLaunch", () => { + it("composes the config at the target machine's derived path, with the rendered bytes and the full option tail", () => { + const snapshot = SNAP(); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/scripted/.subshell", subshellId: ID }); + const configPath = buildSshConfigPath("/home/scripted/.subshell", ID); + expect(composed.configPath).toBe(configPath); + expect(composed.fileContent).toBe(renderSshConfigContents(snapshot)); + // Handoff 4, verbatim: option tokens (with `-F` first) then `--` then the host. + expect(composed.presetFlags).toEqual(["-F", configPath, "-p", "22", "--", "example.test"]); + // No agent socket named means no SSH_AUTH_SOCK added — decision 3's scope + // rule: non-ssh panes never carry it, and neither does an ssh pane that + // resolved without one. + expect(composed.extraPaneEnv).toBeUndefined(); + }); + + it("the LOCAL machine's dataDir is the server's own, and the byte-derived path is the one LocalLauncher enforces", () => { + const composed = composeSshLaunch({ snapshot: SNAP(), targetDataDir: SUBSHELL_SERVER_DATA_DIR, subshellId: ID }); + expect(composed.configPath).toBe(subshellSshConfigPath(ID)); + }); + + it("a snapshot naming an agent socket carries it as SSH_AUTH_SOCK; a user, non-default port, alias and jump chain ride the option tokens", () => { + const snapshot = SNAP({ + user: "dev", + port: 2222, + hostKeyAlias: "bastion", + authAgentSocket: "/run/user/501/ssh-agent.sock", + proxyJumps: [{ host: "jump.example", user: "j", port: 22 }], + }); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(composed.extraPaneEnv).toEqual({ SSH_AUTH_SOCK: "/run/user/501/ssh-agent.sock" }); + const flags = composed.presetFlags; + expect(flags.slice(0, 2)).toEqual(["-F", composed.configPath]); + expect(flags).toContain("-p"); + expect(flags[flags.indexOf("-p") + 1]).toBe("2222"); + expect(flags).toContain("-l"); + expect(flags[flags.indexOf("-l") + 1]).toBe("dev"); + expect(flags).toContain("HostKeyAlias=bastion"); + expect(flags.some((f) => f.startsWith("ProxyJump=") && f.includes("j@jump.example"))).toBe(true); + expect(flags.slice(-2)).toEqual(["--", "example.test"]); + }); + + it("refuses a snapshot the frozen grammar cannot express, rather than render it (the belt beside the RPC validator)", () => { + const forged = { ...SNAP(), host: "-oProxyCommand=evil" } as unknown as SshConnectionSnapshotWire; + expect(() => composeSshLaunch({ snapshot: forged, targetDataDir: "/home/n/.subshell", subshellId: ID })).toThrow(); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts index 498d18be6..7529a2151 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts @@ -24,6 +24,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -185,6 +186,7 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 779ac50cf..69ead9c15 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -22,6 +22,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -145,6 +146,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts index 57afc7703..f05409fbc 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts @@ -20,6 +20,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -87,6 +88,7 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts new file mode 100644 index 000000000..d32a66803 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { FakeNodeLauncher, nodeOnline } from "@/services/__tests__/helpers/node-fakes.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { EMPTY_PRESET } from "@/services/preset-definition.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; + +/** + * The manager's ssh launch plumbing (Task 7): what `createSubshell` does with + * the ssh service's composed facts, and which row fact the delete sweep reads. + * + * The composition itself (`composeSshLaunch`) and its call from the HTTP + * surface are pinned elsewhere (`ssh-launch.service.test.ts`, + * `api/ssh/__tests__/ssh-routes.test.ts`). This file pins the three manager + * promises: the `presetFlags` launch preset is a FRESH object (EMPTY_PRESET + * is shared and frozen — a mutation there would leak ssh flags into every + * later presetless launch), the composed ssh member and the merged pane env + * reach the {@link LaunchPlan} untouched, and `subshells.ssh` (migration + * 0048's JSON column) is the snapshot's home. + * + * The delete-path half pins the kind fact: `row.ssh !== null`, NOT + * `row.harnessId === "ssh"` — a hand-built row that names the ssh harness + * without a snapshot gets no config cleanup, and a snapshot on any harness + * row gets it. + */ + +const OWNER = "u-ssh-plumb"; +const testDir = mkdtempSync(join(tmpdir(), "subshell-mgr-ssh-")); +let subshellsRepo: SubshellsRepository; +const created: string[] = []; + +const SNAPSHOT: SshConnectionSnapshotWire = { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, +}; + +/** A live socket so the AGENT branch of `#planMcp` has `ready` facts to read. */ +function plumbOnline(): () => void { + return nodeOnline("node-plumb"); +} + +function mkManager(): { manager: SubshellManagerService; launcher: FakeNodeLauncher } { + const launcher = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(db), + launcher, + tokens: { issue: async () => "subshell_stub", revoke: async () => {} }, + audit: async () => {}, + }); + return { manager, launcher }; +} + +beforeAll(async () => { + await runMigrations(); + await prepareLocalPlugins(); + subshellsRepo = new SubshellsRepository(db); +}); + +afterAll(async () => { + for (const id of created) await subshellsRepo.delete(id).catch(() => {}); + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("createSubshell ssh plumbing", () => { + it("threads the composed ssh member, the flag preset and the extra pane env into the LaunchPlan, and writes the snapshot column", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const configPath = buildSshConfigPath("/home/plumb/.subshell", id); + const flags = ["-F", configPath, "-p", "22", "--", "example.test"]; + const off = plumbOnline(); + const created_ = await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath, fileContent: "Host *\n", snapshot: SNAPSHOT }, + presetFlags: flags, + extraPaneEnv: { SSH_AUTH_SOCK: "/run/user/501/sock" }, + }); + off(); + expect(created_.id).toBe(id); // the caller's pre-assigned id IS the row id + + const plan = launcher.plans[0]; + expect(plan?.ssh).toEqual({ configPath, fileContent: "Host *\n" }); // the snapshot itself stays off the wire member + expect(plan?.preset.flags).toEqual(flags); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBe("/run/user/501/sock"); + // The SUBSHELL_* infrastructure env is still there — the ssh pane is a + // token-holding row (decision 10), just with no MCP registration. + expect(plan?.subshellEnv.SUBSHELL_ID).toBe(id); + expect(plan?.mcp).toBeUndefined(); + + const row = await subshellsRepo.findById(id); + expect(JSON.parse(String(row?.ssh))).toEqual(SNAPSHOT); + expect(row?.harnessId).toBe("ssh"); + }); + + it("the flag preset is a fresh object — EMPTY_PRESET is shared and must stay flagless", async () => { + expect(EMPTY_PRESET.flags).toEqual([]); // frozen, and still empty + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["-F", "/d/ssh/x/config", "--", "h"], + }); + off(); + expect(EMPTY_PRESET.flags).toEqual([]); // untouched by the compose above + expect(launcher.plans[0]?.preset).not.toBe(EMPTY_PRESET); + }); + + it("an ordinary launch grows nothing: no ssh member, no SSH_AUTH_SOCK, NULL snapshot", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "terminal", + presetId: null, + workingDir: tmpdir(), + }); + const plan = launcher.plans[0]; + expect(plan?.ssh).toBeUndefined(); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + const row = await subshellsRepo.findById(id); + expect(row?.ssh).toBeNull(); // the column is the kind fact: NULL = not an ssh pane + }); + + it("a null agent socket adds no SSH_AUTH_SOCK (only a named socket rides)", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["--", "h"], + }); + off(); + expect(launcher.plans[0]?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + }); +}); + +describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { + it("a LOCAL row with a snapshot gets the config path removed — whatever its harness id claims", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + // NOT "ssh": the column, not the id, says the pane owned a config. + harnessId: "terminal", + name: "snapshot row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id), subshellSshConfigPath(id)]); + }); + + it("an AGENT row with a snapshot passes the kind flag through subshellArtifacts", async () => { + const nodeId = "node-ssh-del"; + const off = nodeOnline(nodeId, []); + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote ssh pane", + workingDir: tmpdir(), + tmuxSocket: "subshell-x", + status: "terminated", + alive: 0, + nodeId, + ssh: JSON.stringify(SNAPSHOT), + }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + // The ssh config rides the triple at the node-derived path (fake mirrors + // RemoteLauncher's composition from its `/node-data` facts). + expect(launcher.removedPaths.at(-1)).toEqual([ + launcher.logPath(id), + `/node-data/mcp/${id}.json`, + `/node-data/subshells/${id}.meta.json`, + buildSshConfigPath("/node-data", id), + ]); + off(); + }); + + it("a row that merely NAMES the ssh harness but holds no snapshot is not an ssh pane (the re-point's negative arm)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "harness-only row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: null, + }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); // no config path + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index 3c656bf01..d25377ae8 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -22,6 +22,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -114,6 +115,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index ca3082d61..72802b5f2 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -23,6 +23,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -154,6 +155,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh — SubshellsRepository.create writes it on EVERY create now (null for a non-ssh pane), and the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(db); subshellsRepo = new SubshellsRepository(db); subshellManager = new SubshellManagerService({ diff --git a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts new file mode 100644 index 000000000..220a8fae6 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The LOCAL half of the ssh config cleanup (spec 2026-10-07 decision 4; + * plan-2 handoff 2): the exit-report path removes + * `/ssh/` when the row is a LOCAL pane whose + * snapshot column is set. Remote panes sweep on the agent (their own disk); + * this host must NEVER sweep an agent row's directory name off its own disk. + * + * It fires even when the row is already retired — a terminate kills the pane, + * the tmux `pane-died` hook races the retire, and whichever order they land + * in, the config's removal must be a sure thing (that is what Task 9's e2e + * asserts after terminate). + */ + +const OWNER = "u-ssh-exit"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { + id: string; + nodeId: string; + ssh: string | null; + status?: "running" | "terminated"; + alive?: number; +}): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh exit row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${over.id.slice(0, 8)}`, + status: over.status ?? "running", + alive: over.alive ?? 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the exit report sweeps the LOCAL ssh config dir", () => { + it("a running local ssh pane's death removes /ssh/ best-effort", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + await service.reportExit(id, 0); + expect(existsSync(localDir(id))).toBe(false); // handoff 2: the DIR (dirname of the config path), not just the file + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); // the manager's death transition ran as before + }); + + it("a row the terminate already retired still sweeps (the hook-vs-retire race is the e2e's case)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}', status: "terminated", alive: 0 }); + makeConfigDir(id); + await service.reportExit(id, null); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an AGENT row sweeps nothing on this host — the node cleans its own disk", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // a directory with the same NAME must survive: it is not ours + await service.reportExit(id, 1); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("a local row without a snapshot sweeps nothing (non-ssh panes have no config to clean)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an already-dead row's report still converges and the sweep is idempotent on a missing dir", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + // No dir made at all: the sweep must not throw on a missing path. + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts new file mode 100644 index 000000000..9156e5396 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -0,0 +1,98 @@ +import { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, +} from "@internal/subshell-protocol"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; + +/** + * The two ssh discovery/resolve commands as plane-side RPC wrappers + * (spec 2026-10-07 §5). Each is `sendCommand` plus ITS frozen result parser: + * the value these return is a validated plain object (or the function throws), + * so no caller can branch on a machine answer it has not parsed. + * + * **The outcome's `accepted:false` arm is NOT an error.** "This config needs a + * ProxyCommand" is a successful resolution the human must read (§2's refusal + * doctrine), and it travels IN THE DATA; these wrappers return the whole + * outcome and refuse only what the transport or the wire grammar refused. + * + * Every failure leaves as one {@link SshRpcError} whose `kind` says which + * door closed, so the service maps statuses without re-deriving them from + * error sentences: + * - `offline` — no live socket (or it dropped before the frame was sent); + * - `unsupported` — the connected agent does not know the command (an old + * app; the remedy is the node update); + * - `timeout` — the machine did not answer inside the RPC deadline; + * - `refused` — the agent answered `ok:false` (its own mirror gate, a missing + * ssh binary, …); `detail` carries the agent's verbatim string for the LOG, + * never for a response body; + * - `malformed` — `ok:true` with a payload the frozen validator rejects (a + * protocol violation; treated as a refusal, loudly). + */ + +/** Which door closed on a machine-level ssh RPC failure. */ +export type SshRpcFailure = "offline" | "unsupported" | "timeout" | "refused" | "malformed"; + +export class SshRpcError extends Error { + readonly kind: SshRpcFailure; + readonly nodeId: string; + /** The agent's `error` string verbatim on a `refused` failure (log/diagnosis only). */ + readonly detail: string | undefined; + + constructor(kind: SshRpcFailure, message: string, nodeId: string, detail?: string) { + super(message); + this.name = "SshRpcError"; + this.kind = kind; + this.nodeId = nodeId; + this.detail = detail; + } +} + +/** Map the RPC layer's failure onto the ssh surface's failure kind. */ +function mapRpcError(nodeId: string, err: NodeRpcError): SshRpcError { + const kind: SshRpcFailure = + err.code === "offline" + ? "offline" + : err.code === "unsupported" + ? "unsupported" + : err.code === "timeout" + ? "timeout" + : "refused"; + return new SshRpcError(kind, err.message, nodeId, err.detail); +} + +/** Ask one machine for the NAMES of its usable SSH aliases (never config contents). */ +export async function sshDiscover(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_discover_aliases" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAliasList(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed alias list`, nodeId); + } + return parsed; +} + +/** + * Ask one machine to resolve one destination token into the approved + * snapshot (or its named refusal, which is a SUCCESSFUL answer). + */ +export async function sshResolve(nodeId: string, alias: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_resolve_config", alias }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshResolveOutcome(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed resolve outcome`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts new file mode 100644 index 000000000..697ffcc92 --- /dev/null +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -0,0 +1,476 @@ +import { homedir as osHomedir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildSshConfigPath, + discoverSshAliases, + findBinary, + getHarness, + renderSshConfigContents, + resolveSshAliasConfig, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseSshConnectionSnapshot, + SSH_NAME_MAX_CHARS, + type SshConnectionSnapshotWire, +} from "@internal/subshell-protocol"; +import { loadNodeGate, type NodeGate } from "@/api/nodes/node-gate.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { SettingsRepository } from "@/db/repositories/settings.repository.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { nodeCanSsh } from "@/lib/node-access.js"; +import { audit } from "@/services/audit.js"; +import { getHeld, getLive } from "@/services/nodes/node-registry.js"; +import { SshRpcError, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; +import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import type { SubshellsService } from "@/services/subshells.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The SSH launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): the gate, + * the resolve trip, the compose, the launch, the ledger, the audit. + * + * **Responsibilities in order, each pinned by `api/ssh/__tests__/ssh-routes.test.ts`:** + * 1. GATE: `nodeCanSsh` answers from the plane's OWN row and the caller's + * access BEFORE any `sendCommand` — the predicate says yes/no, this file + * names the cause in the message (spec §12); the code is one + * `SSH_GATE_OFF` for both causes. A held agent node is refused with the + * update remedy before any resolve (a held socket answers nothing but + * `update`, and a launch aimed at it is a frame the agent will never see). + * 2. The destination is validated with the wire grammar's own alias rule + * BEFORE the machine is asked; discovery answers and hand-typed tokens + * take the SAME resolve path (`ssh_resolve_config`), so nothing composes a + * snapshot the resolver did not approve. + * 3. The refusal shape splits by endpoint, per the frozen doctrine: `resolve` + * answers a refusal outcome IN THE DATA (200, a fact the human reads); + * `launch` and `saved-hosts PUT` refuse it (422 `{outcome}`), because + * acting on an unapproved destination is the act they are. + * 4. Compose happens only against a snapshot re-validated by + * `parseSshConnectionSnapshot` at THIS trust boundary (the RPC validator + * already ran; the renderer's own entry check is the third copy — the + * tier-1 grammar doc names re-validation at each boundary the + * load-bearing defense, not the plane's approval). + * 5. The audit `ssh.launch` lands AFTER a successful launch, never before — + * the same posture as `subshell.create`, which the manager records only + * once the spawn returned. A refused resolve, a thrown create, an offline + * machine: the trail stays silent about the act that did not happen. + * Saved-host CRUD audits nothing (the prompts precedent: a preference row + * is not an event). + */ + +/** The refusal union the routes render verbatim (a returned `status()` body, never a thrown). */ +export type SshRefusal = + | { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string } + | { status: 422; outcome: Extract }; + +/** The service answer shape: a value, or the refusal the route returns as-is. */ +export type SshAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusal }; + +function ok(value: T): SshAnswer { + return { ok: true, value }; +} + +function codedRefusal(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +function refusedRefusal(outcome: Extract): SshAnswer { + return { ok: false, refusal: { status: 422, outcome } }; +} + +/** + * The destination as the wire grammar reads an alias (ssh-frames.ts + * `isAliasName`, same numbers): 1..{@link SSH_NAME_MAX_CHARS}, no leading + * `-`, no whitespace or control characters. Discovery answers and manual + * tokens take this SAME path before any machine is asked — the tier-1 + * injection posture, applied at the surface rather than trusted to the + * renderer's last-station check. + */ +export function isWireSafeSshDestination(value: string): boolean { + return value.length > 0 && value.length <= SSH_NAME_MAX_CHARS && !value.startsWith("-") && !/\s|\p{Cc}/u.test(value); +} + +/** + * Everything the ssh launch needs, composed from an APPROVED snapshot, the + * target machine's dataDir, and the new pane's id (spec 2026-10-07 decision + * 4: the path is DERIVED from the two facts each end already holds; it never + * travels the wire). Handoff 4's exact tail: every option token, then `--`, + * then the bare host; `SSH_AUTH_SOCK` joins the pane env only when the + * snapshot names a socket (decision 3's scoped exception). + * + * Throws (an impossible-state guard, never a user path) when the snapshot + * fails the grammar re-check at this boundary, or the id is outside the + * path-composition guard. + */ +export function composeSshLaunch(args: { + snapshot: SshConnectionSnapshotWire; + targetDataDir: string; + subshellId: string; +}): { + configPath: string; + fileContent: string; + presetFlags: string[]; + extraPaneEnv?: Record; +} { + const approved = parseSshConnectionSnapshot(args.snapshot); + if (approved === null) throw new Error("ssh compose: snapshot failed grammar re-validation"); + const configPath = buildSshConfigPath(args.targetDataDir, args.subshellId); + const fileContent = renderSshConfigContents(approved); + const presetFlags = [...sshOptionTokens(approved, configPath), "--", sshDestinationToken(approved)]; + return approved.authAgentSocket === null + ? { configPath, fileContent, presetFlags } + : { configPath, fileContent, presetFlags, extraPaneEnv: { SSH_AUTH_SOCK: approved.authAgentSocket } }; +} + +/* ------------------------------------------------------------------ */ +/* the gate (spec §4.3: the plane's row, then the held check, BEFORE */ +/* any command; naming WHY is this surface's copy, per §12) */ +/* ------------------------------------------------------------------ */ + +/** The acting user's SSH-usable gate on one node, or the refusal that closes the request. */ +async function gateSshNode(viewerId: string, nodeId: string): Promise> { + // Invisible and absent collapse to one 404 (the node-gate doctrine: an + // invisible node must never answer 403, so ids cannot be probed). + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) return codedRefusal(404, BackendErrorCodes.NOT_FOUND_ERROR, "Node not found"); + const sshEnabled = gate.row.sshEnabled === 1; + const serverAccountEnabled = await serverSubshellsEnabled(db); + if ( + !nodeCanSsh({ kind: gate.row.kind, access: gate.access, isAdmin: gate.isAdmin, serverAccountEnabled, sshEnabled }) + ) { + return codedRefusal(403, BackendErrorCodes.SSH_GATE_OFF, gateCause(gate.row, serverAccountEnabled)); + } + // Held (spec 2026-09-15 §5.3) means the plane will not speak that socket; + // every ssh command would die at the transport anyway, and the honest + // answer names the one remedy that works: the node update. + if (gate.row.kind === "agent" && getHeld(nodeId)) { + return codedRefusal( + 409, + BackendErrorCodes.NODE_PROTOCOL_HELD, + "This machine is running a Subshell version this server does not speak, so it is held until it is updated. Update it from its machine page, then retry.", + ); + } + return ok(gate); +} + +/** The true cause, named. One `SSH_GATE_OFF` code; the message says which door. */ +function gateCause(row: NodeTable, serverAccountEnabled: boolean): string { + const machine = row.kind === "local" ? "this host" : "this machine"; + if (row.sshEnabled !== 1) { + return row.kind === "local" + ? `Subshell SSH is off for ${machine}. An admin can switch it on from this machine's settings.` + : `Subshell SSH is off for ${machine}. Its owner can switch it on from this machine's settings.`; + } + if (row.kind === "local" && !serverAccountEnabled) { + return `Using ${machine} as a launch target is switched off in the server settings, so SSH panes cannot start there either.`; + } + return `Subshell SSH on ${machine} is reserved to its owner; a shared machine stays the owner's to SSH from.`; +} + +/** The machine refused or could not answer; the code family, never the agent's own text. */ +function rpcRefusal(err: SshRpcError): SshAnswer { + if (err.kind === "offline") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has no live connection right now; bring its Subshell app online and retry.", + ); + } + if (err.kind === "unsupported") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OUTDATED, + "The Subshell app on that machine is too old to speak SSH. Update it from its machine page.", + ); + } + if (err.kind === "timeout") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_UNREACHABLE, + "That machine did not answer the SSH request in time; check its connection and retry.", + ); + } + // refused / malformed: the machine's own answer (`ssh disabled on this + // node`, no ssh binary, a payload that failed the frozen validator). Its + // verbatim text goes to the LOG, never to the response (the + // files-remote-browse posture: agent refusal strings are not client API). + logger.withError(err).warn(`ssh rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + ); +} + +/* ------------------------------------------------------------------ */ +/* transports: the agent answers over the signed RPC; `local` runs */ +/* the SAME pane-runtime engines in-process (no agent socket exists — */ +/* ssh-frames.ts spells the commands transport-agnostic for exactly */ +/* this, and the server account's gate is the DB row, not a mirror). */ +/* ------------------------------------------------------------------ */ + +async function nodeAliases(row: NodeTable): Promise> { + if (row.kind === "local") { + const found = parseNodeSshAliasList(discoverSshAliases({ homeDir: osHomedir() })); + if (!found) { + // Unreachable by construction (the discovery engine is OURS); refuse + // loudly rather than pass anything the wire validator rejected. + return codedRefusal(502, BackendErrorCodes.SSH_NODE_REFUSED, "This host could not produce its alias list."); + } + return ok(found); + } + try { + return ok(await sshDiscover(row.id)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +async function nodeResolve(row: NodeTable, destination: string): Promise> { + if (row.kind === "local") { + // The same ladder the agent runs (decision 2: the rule travels, the path + // is the machine's own) — for `local`, this process IS the machine. + const spec = getHarness("ssh")?.detectSpec; + const sshBin = spec ? await findBinary(spec.binaryName, spec.envOverride, spec.knownPaths) : null; + if (sshBin === null) { + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + "This host has no ssh binary installed, so it cannot resolve a destination.", + ); + } + return ok(await resolveSshAliasConfig(destination, { sshBin, homeDir: osHomedir() })); + } + try { + return ok(await sshResolve(row.id, destination)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +/** The node's dataDir at the compose instant, or null (the offline class). */ +function targetDataDir(row: NodeTable): string | null { + // local: the server's own dir (LocalLauncher byte-checks this exact + // derivation); agent: the ready-reported `dataDir`, which the AGENT + // re-derives from itself and refuses a byte-mismatch on. + return row.kind === "local" ? SUBSHELL_SERVER_DATA_DIR : (getLive(row.id)?.agent?.dataDir ?? null); +} + +/* ------------------------------------------------------------------ */ +/* endpoints */ +/* ------------------------------------------------------------------ */ + +/** `GET /api/ssh/aliases` — the machine's usable alias names (gate, then the transport). */ +export async function sshListAliases(viewerId: string, nodeId: string): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + return await nodeAliases(gate.value.row); +} + +/** + * `POST /api/ssh/resolve` — the destination's outcome. A refusal rides IN THE + * DATA (200): which setting blocked is a fact the human reads, not a failure + * of this request. + */ +export async function sshResolveDestination( + viewerId: string, + nodeId: string, + destination: string, +): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + return await nodeResolve(gate.value.row, destination); +} + +const UNSAFE_DESTINATION_COPY = + "That destination is not a token ssh can be given safely: no leading dash, no whitespace or control characters, at most 253 characters."; + +/** + * `POST /api/ssh/launch` — gate, resolve, compose, launch, ledger, audit. + * The pane arrives through the ordinary create path (`harnessId: "ssh"`, + * presetless: `presetFlags` CARRY the launch, the working directory gets the + * launch node's home by the spec-2026-10-01 terminal default), and the ssh + * member + snapshot ride the plumbing this task added to `createSubshell`. + * `restartOnExit` inherits the preset — a presetless launch inherits nothing + * (0): ssh panes are forced off auto-restart by construction, decision 7's + * other half. + */ +export async function sshLaunch(args: { + viewerId: string; + nodeId: string; + destination: string; + name?: string; + /** The REQUEST-scoped subshells service (the create path is its own). */ + subshells: Pick; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + const row = gate.value.row; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + const resolved = await nodeResolve(row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it + const dataDir = targetDataDir(row); + if (dataDir === null) { + // Between the resolve and here the machine dropped, or it connected + // without reporting `ready` facts yet — there is no dataDir to derive a + // path from, so nothing was composed or spawned. + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has not reported its data directory yet; retry once it is fully connected.", + ); + } + const subshellId = crypto.randomUUID(); + const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + // The create can still refuse (node switched off, harness gone, a + // lockdown, a maintenance window) — those ride the global error handler + // with their own codes. They land here, AFTER the resolve: the honest + // order, since this act's gate (SSH use) is strictly narrower than the + // launch's and every refusal below writes no pane. + const created = await args.subshells.createSubshell({ + userId: args.viewerId, + harnessId: "ssh", + presetId: null, + // The presetless-terminal default (spec 2026-10-01 §2): the launch node's + // home, resolved by the create path itself — exactly what the wizard's + // "connect from this machine" journey wants as the pane's starting cwd. + workingDir: undefined, + name: args.name, + nodeId: row.id, + machineActor: false, // the routes are cookie-only (requireCookieActor) + crossAgent: false, + subshellId, + ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, + presetFlags: composed.presetFlags, + ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), + }); + // Recency FIRST (a bookkeeping refresh the human already sees as part of the + // launch), then the audit — both AFTER the pane exists. The ledger is + // best-effort (a failed touch must not turn a live pane into an error, the + // recentPaths posture); the audit sink never throws by contract. + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + const now = new Date().toISOString(); + await new SshSavedHostsRepository(db) + .touch({ ownerUserId: args.viewerId, destination, nodeId: row.id, alias: snapshot.alias, at: now }) + .catch((err: unknown) => { + logger.withError(err).warn(`ssh saved-hosts recency touch failed for ${args.viewerId}/${destination}`); + }); + // Success-only, matching the manager's own `subshell.create` posture (which + // is recorded after the spawn returned): a refusal writes nothing to that + // trail, and an act that failed to spawn is not an event. + await audit({ + actorUserId: args.viewerId, + action: "ssh.launch", + targetType: "node", + targetId: row.id, + metadataJson: JSON.stringify({ nodeId: row.id, destination, subshellId: created.id }), + }); + return ok({ subshellId: created.id }); +} + +/** + * `PUT /api/ssh/saved-hosts` — save a destination: the SAME gate + resolve + * (the canonical key must come from a validated snapshot's fields, handoff + * 1), refusal-shaped outcomes refuse with 422 exactly as a launch would. + * Unaudited (prompts precedent: this is a preference row, not an event). + */ +export async function sshSaveHost(args: { + viewerId: string; + nodeId: string; + destination: string; + /** Explicit display token; absent falls back to the snapshot's alias. */ + alias?: string; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + const resolved = await nodeResolve(gate.value.row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; + const row = await new SshSavedHostsRepository(db).markSaved({ + ownerUserId: args.viewerId, + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), + nodeId: gate.value.row.id, + alias: args.alias ?? snapshot.alias, + at: new Date().toISOString(), + }); + return ok(row); +} + +/** `DELETE /api/ssh/saved-hosts/:id` — false for foreign AND absent (one 404). */ +export async function sshRemoveSavedHost(viewerId: string, id: string): Promise { + return await new SshSavedHostsRepository(db).remove(viewerId, id); +} + +/** The GET payload of the launcher screen: the owner's rows plus their default machine. */ +export async function sshSavedHostsView(viewerId: string): Promise<{ + saved: SshSavedHostTable[]; + recent: SshSavedHostTable[]; + defaultNodeId: string | null; +}> { + const repo = new SshSavedHostsRepository(db); + const [saved, recent, defaultNodeId] = await Promise.all([ + repo.listSaved(viewerId), + repo.listRecent(viewerId), + getSshDefaultNode(viewerId), + ]); + return { saved, recent, defaultNodeId }; +} + +/* ------------------------------------------------------------------ */ +/* preferences: the default connecting machine, a per-user setting */ +/* ------------------------------------------------------------------ */ + +/** + * The preference lives in the instance `settings` table under a per-user + * NAMESPACED key because that is the only JSON key/value store this schema + * has and a migration for one string was not on Task 7's map: `user_meta` + * columns each need their own migration, and 0048 (the ledger + the snapshot + * column) was frozen before preferences were wired. The namespace keeps two + * users' defaults from sharing a row; nothing renders `settings` rows raw, so + * the key shape is storage detail, not API surface. + */ +const sshDefaultNodeKey = (userId: string): string => `ssh.defaultNode:${userId}`; + +export async function getSshDefaultNode(userId: string): Promise { + return (await new SettingsRepository(db).get(sshDefaultNodeKey(userId), null)) ?? null; +} + +/** + * `PATCH /api/ssh/preferences`. The WRITE validates the node exists and is + * visible to the caller (404 otherwise — the ordinary invisibility, never a + * "you may not" leak). The READ path (above) deliberately returns the stored + * id WITHOUT a visibility read: a node deleted under a saved default must not + * silently clear a preference the human chose, and a preference that + * re-validates on every page load would make the launcher's first GET + * machine-visibility-coupled; the SPA resolves the id against its node list + * and decides what to show. + */ +export async function sshSetDefaultNode(viewerId: string, nodeId: string | null): Promise> { + if (nodeId !== null) { + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) return codedRefusal(404, BackendErrorCodes.NOT_FOUND_ERROR, "Node not found"); + } + await new SettingsRepository(db).set(sshDefaultNodeKey(viewerId), nodeId); + return ok(nodeId); +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index f62b6c82d..b670cff17 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -17,6 +17,7 @@ import { type NodeProbeEntry, normalizeLabel, parseNodeProbeEntries, + type SshConnectionSnapshotWire, } from "@internal/subshell-protocol"; import { harnessUsable } from "@/api/harness-utils.js"; import type { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -128,6 +129,23 @@ const defaultNotify: (subshellId: string, kind: NotifyKind) => Promise = a await getNotifyService().notifySubshell(id, kind); }; +/** + * The composed half of an ssh-pane launch (spec 2026-10-07 decision 4, Task 7 + * plumbing): the derived config path, the rendered bytes, and the APPROVED + * snapshot the row keeps. `subshells.ssh` = `JSON.stringify(snapshot)` is + * written by `createSubshell`; the LaunchPlan member carries only + * `configPath`/`fileContent` (the snapshot never rides the wire — the + * launcher re-derives the path and the renderer produced the bytes). + */ +export interface SshLaunchPlumbing { + /** `buildSshConfigPath(, )` — derived, never chosen. */ + configPath: string; + /** `renderSshConfigContents(snapshot)`. */ + fileContent: string; + /** The snapshot the resolver approved (grammar-validated at each boundary already). */ + snapshot: SshConnectionSnapshotWire; +} + /** * Orchestrates agent subshells: validate inputs, spawn a tmux-backed harness, * stream output to a per-subshell log file, and reconcile DB state with what @@ -317,6 +335,10 @@ export class SubshellManagerService { notify, nodeId, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { userId: string; /** Harness plugin to launch — required, with or without a preset. */ @@ -356,7 +378,40 @@ export class SubshellManagerService { * silent. Only creation writes it; a restart reuses the row. */ crossAgent?: number; + /** + * The composed ssh pane (Task 7 plumbing): the row gets + * `ssh = JSON.stringify(snapshot)`, the LaunchPlan gets the config member, + * and the launch is refused nothing else — an ssh pane is a terminal-type + * harness whose flags arrive through `presetFlags`. + */ + ssh?: SshLaunchPlumbing; + /** + * The launch preset's flags when a caller composes them (the ssh service + * does; no one else does today). Present ⇒ the launch preset is + * `{ ...EMPTY_PRESET, flags }` — a FRESH object, since EMPTY_PRESET is the + * shared frozen constant every other presetless launch reads. Ignored when + * a preset row is also given (the row wins: presetFlags exists for the + * presetless compose path, not to edit somebody's saved preset). + */ + presetFlags?: string[]; + /** + * Env additions merged into the pane's `subshellEnv` at the compose site + * (spec 2026-10-07 decision 3: an ssh pane's `SSH_AUTH_SOCK` — the pane's + * command IS the ssh process, so the pane env is the ssh env). Merged + * AFTER the preset-independent `SUBSHELL_*` block, before the launch. + */ + extraPaneEnv?: Record; + /** + * Pre-assigned row id, for callers that must compose id-derived facts + * BEFORE the row exists (the ssh config path is derived from the pane's + * id; Task 7). Shape-guarded; production callers pass nothing and get the + * `crypto.randomUUID()` they always got. + */ + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; apiKey: string; promptDelivered: boolean }> { + if (subshellId !== undefined && !/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) { + throw new Error("createSubshell: subshellId is outside the id grammar"); + } const presetRow = presetId ? await this.#presets.findById(presetId) : undefined; if (presetId && (!presetRow || presetRow.userId !== userId)) { throw new Error("Preset not found"); @@ -373,13 +428,21 @@ export class SubshellManagerService { const launcher = this.#launcherFor(targetNode); const realPath = await launcher.validateWorkingDir(workingDir); await assertDirAllowed(targetNode, realPath); - const preset = presetRow ? parsePreset(presetRow) : EMPTY_PRESET; + const preset = presetRow + ? parsePreset(presetRow) + : presetFlags !== undefined + ? // Fresh object every time: EMPTY_PRESET is the shared frozen constant + // every presetless launch reads, and mutating it (or handing out the + // shared reference with new flags hung on it) would leak ssh option + // tokens into unrelated panes. + { ...EMPTY_PRESET, flags: [...presetFlags] } + : EMPTY_PRESET; const binary = await launcher.resolveBinary(harness); if (!binary) { throw new Error(`Harness "${harness.name}" is not installed on this machine.`); } - const id = crypto.randomUUID(); + const id = subshellId ?? crypto.randomUUID(); const socket = tmuxSocketFor(id); // Display name vs LAUNCH name (titling spec 2026-09-03): only a name a // HUMAN chose travels to the pane command — an unnamed create passes "" @@ -423,6 +486,11 @@ export class SubshellManagerService { // How the pane was OPENED, recorded once; the rail's category and the // caller's bell default both read this. 0 = the DB default, human. crossAgent: crossAgent === 1 ? 1 : 0, + // The ssh snapshot column IS the pane's kind fact (migration 0048): the + // owner-only input rule, the restart refusal and the delete sweep all + // read its presence. NULL for every pane this call did not compose from + // an approved snapshot. + ssh: ssh ? JSON.stringify(ssh.snapshot) : null, }); // The token is minted AFTER the row exists (issueSubshellToken writes the @@ -448,6 +516,10 @@ export class SubshellManagerService { // means nothing on the node; the agent's own dataDir is the truth there. subshellEnv.SUBSHELL_DATA_DIR = facts.dataDir; } + // Caller-composed env (Task 7: the ssh pane's SSH_AUTH_SOCK, decision 3) + // merges after the SUBSHELL_* block — `subshellEnv` is a per-launch + // object built just above, never a shared constant, so assigning is safe. + if (extraPaneEnv) Object.assign(subshellEnv, extraPaneEnv); // Command assembly happens INSIDE launch, which runs inside this try: // a rejected env key throws there, and the row + token must roll back // like any other spawn failure below. @@ -464,6 +536,9 @@ export class SubshellManagerService { mcpConfigPath, harnessSession, reporter, + // The composed ssh member (spec 2026-10-07 decision 4). Conditional + // spread: absent on every non-ssh launch, byte-identical plan there. + ...(ssh ? { ssh: { configPath: ssh.configPath, fileContent: ssh.fileContent } } : {}), }); if (prompt?.trim()) { promptDelivered = await this.#deliverPrompt( @@ -1010,12 +1085,14 @@ export class SubshellManagerService { // The ssh config path rides the list for THIS row only, and the row's // kind fact (spec 2026-10-07 decision 4) is read HERE — the launchers // compose paths, only the caller holding the row can say which files the - // pane OWNS. `harnessId === "ssh"` is that truth today (the ssh service - // launches the ssh harness and only it carries a rendered config); when - // migration 0048's snapshot column lands, `row.ssh !== null` becomes the - // spelled-out kind fact at this one line — the launchers already take - // the flag. - const sshPane = row.harnessId === "ssh"; + // pane OWNS. `row.ssh !== null` IS that truth (migration 0048's snapshot + // column): the value's presence means a rendered config was composed for + // this pane, which is exactly what the cleanup must remove — a row that + // merely NAMES the ssh harness (hand-built, or a launch that never got a + // snapshot) has no config file to find, and a snapshot under any other + // harness id still owns one. Task 7's re-point, promised by the comment + // this replaces. + const sshPane = row.ssh !== null; const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id), ...(sshPane ? [subshellSshConfigPath(id)] : [])] diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index b24feb566..247ba163a 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -1,4 +1,6 @@ +import { rmSync } from "node:fs"; import { homedir as osHomedir } from "node:os"; +import { dirname } from "node:path"; import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; // The attention kinds are imported from the reporter that SPEAKS them rather // than restated here: the old local `Extract`, widened once to @@ -36,6 +38,7 @@ import { waitSentinel, } from "@/services/nodes/pane-exec.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { @@ -43,6 +46,7 @@ import { PROMPT_SETTLE_TIMEOUT_MS, RestartInFlightSwapError, readSubshellLogWindow, + type SshLaunchPlumbing, SubshellManagerService, } from "@/services/subshell-manager.service.js"; import { extendSubshellToken, subshellTokenTtlSeconds } from "@/services/subshell-tokens.js"; @@ -412,6 +416,10 @@ export class SubshellsService extends BaseService { nodeId, machineActor, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { /** Owner of the new subshell (never taken from the body). */ userId: string; @@ -449,6 +457,19 @@ export class SubshellsService extends BaseService { * they can still turn it on from the pane's menu. */ crossAgent: boolean; + /** + * The composed ssh pane (Task 7 plumbing, spec 2026-10-07 §5/decision 4), + * forwarded to the manager untouched: the snapshot column, the LaunchPlan + * config member, the flags preset, the merged pane env, the pre-assigned + * id the config path was derived from. The ssh-launch service + * (`services/ssh-launch.service.ts`) is the only caller that passes these; + * every other door (REST create, MCP, mobile) launches panes the ordinary + * way and passes none of them. + */ + ssh?: SshLaunchPlumbing; + presetFlags?: string[]; + extraPaneEnv?: Record; + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { // Lockdown is the FIRST question the instance asks of every create // (operator ask 2026-09-24): it answers valid bodies and invalid ones @@ -611,6 +632,13 @@ export class SubshellsService extends BaseService { // OFF — agent-to-agent comms are not news a human needs rung for — // and it stays togglable exactly like any other pane's. notify: !crossAgent, + // The ssh plumbing rides to the manager untouched (its own doc there + // says what each part writes); `undefined` everywhere else keeps the + // pre-Task-7 launch byte-identical. + ...(ssh ? { ssh } : {}), + ...(presetFlags !== undefined ? { presetFlags } : {}), + ...(extraPaneEnv !== undefined ? { extraPaneEnv } : {}), + ...(subshellId !== undefined ? { subshellId } : {}), }) .catch(rethrowLaunchRefusal); // Feed the picker's Recents (and the new-subshell form's pre-fill) from @@ -670,9 +698,43 @@ export class SubshellsService extends BaseService { * @param exitCode - tmux's `#{pane_dead_status}`, null when it could not be read */ async reportExit(id: string, exitCode: number | null): Promise { + await this.#sweepLocalSshConfig(id); await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } + /** + * The LOCAL half of the ssh config lifecycle (spec 2026-10-07 decision 4; + * plan-2 handoff 2): when the dying pane is a LOCAL ssh pane, best-effort + * `rm -rf` of `/ssh/` — the directory the + * LocalLauncher wrote the rendered config into, the dirname of the one + * byte-derived path the launchers and the sweep agree on. + * + * Deliberately keyed on the row's `ssh` column plus its `local` node and + * fired BEFORE the manager's death transition, and it runs even when the + * row is already retired: a terminate kills the pane, the tmux `pane-died` + * hook races the retire stamp, and whichever order they land in the config + * must be gone once the report has been heard (Task 9's e2e asserts exactly + * that after terminate). Agent rows are skipped by rule, not by accident — + * their config lives on the NODE's disk and the agent's own exit watcher + * unlinks it; removing a directory named after a remote pane off THIS + * server would be this host deleting a file that is not its (the delete + * path's per-node launcher doctrine, same reason). + * + * Best-effort by shape: any failure (missing dir, an id outside the path + * guard, a refusing filesystem) costs one log line and nothing else — the + * death transition must land regardless, and a stale config in a dir whose + * name is a dead pane's id is garbage, not a hazard. + */ + async #sweepLocalSshConfig(id: string): Promise { + try { + const row = await this.repos.subshells.findById(id); + if (!row || row.nodeId !== LOCAL_NODE_ID || row.ssh === null) return; + rmSync(dirname(subshellSshConfigPath(id)), { recursive: true, force: true }); + } catch (err) { + logger.withError(err).warn(`ssh config sweep failed for local pane ${id}; the dir ages out with the delete path`); + } + } + /** * Screens for the subshells a viewer asked to see, filtered to those they * actually may (spec 2026-09-19 §4.4). @@ -1226,6 +1288,19 @@ export class SubshellsService extends BaseService { prompt?: string, ): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + // An ssh pane refuses restart (spec 2026-10-07 decision 7): its session + // lives on the far end of a socket, and a restart would spawn a NEW ssh + // process the human never asked to reconnect — the launcher is the way + // back in. Keyed to the `subshells.ssh` column (the kind fact, migration + // 0048), asked AFTER the visibility gate so a stranger still gets the + // ordinary 404, and before anything this path reads or writes. + if (row.ssh !== null) { + throwApiError({ + code: BackendErrorCodes.SSH_NO_RESTART, + message: "SSH panes are not restarted; launch a new one to reconnect", + doNotLog: true, + }); + } // Lockdown is instance-wide, so it is asked before anything machine-shaped // is read. It names no machine, per the restart path's own rule (the row's // owner is the only guaranteed viewer of this refusal). And a stopped row's diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 132daef06..5414e5700 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -182,6 +182,41 @@ export enum BackendErrorCodes { * this act cannot invent, so it is refused before anything runs. */ NETWORK_UNCONFIGURED = "NETWORK_UNCONFIGURED", + /** + * A `/api/ssh` act on a machine SSH is not open for, or not open for THIS + * caller: the node row's flag is off, or the acting viewer is not the + * machine's owner (`nodeCanSsh`, spec 2026-10-07 §4.3). One code for both + * causes; the message names the true one, because naming WHY is the + * surface's job (spec §12) and the code is the machine-readable half. + */ + SSH_GATE_OFF = "SSH_GATE_OFF", + /** + * A `/api/ssh` act against a HELD agent: the plane refuses to speak that + * machine's protocol, so no ssh command can be sent and none is. The + * message names the remedy — update the node (spec 2026-10-07 decision 6). + */ + NODE_PROTOCOL_HELD = "NODE_PROTOCOL_HELD", + /** + * A `/api/ssh` destination (or alias) that the wire grammar cannot carry: + * blank, over the 253-char cap, leading `-` (option-like), or carrying + * whitespace/control characters. Refused before the machine is asked, so + * the token never reaches `ssh -G` or a config parse. + */ + ALIAS_UNSAFE = "ALIAS_UNSAFE", + /** + * The connecting machine answered an ssh command with its own `ok:false` + * (its ssh-enabled mirror says no, no ssh binary, a malformed answer): the + * plane's row cannot overrule the machine. The agent's own text is never + * echoed; the message names the human's remedy. + */ + SSH_NODE_REFUSED = "SSH_NODE_REFUSED", + /** + * `POST /api/subshells/:id/restart` on an ssh pane (spec 2026-10-07 + * decision 7): the pane's session lives on the far end of a socket, so a + * restart would spawn a new ssh process nobody asked to reconnect. Launch + * again from the launcher instead. Keyed to the `subshells.ssh` column. + */ + SSH_NO_RESTART = "SSH_NO_RESTART", } /** @@ -421,4 +456,27 @@ export const BackendErrorCodeDefs = { message: "Configure this network before continuing", statusCode: 409, }, + [BackendErrorCodes.SSH_GATE_OFF]: { + message: "Subshell SSH is off on this machine", + statusCode: 403, + }, + [BackendErrorCodes.NODE_PROTOCOL_HELD]: { + message: "This node is held for speaking a protocol this server does not", + statusCode: 409, + }, + [BackendErrorCodes.ALIAS_UNSAFE]: { + message: "That SSH destination is not a safe token", + statusCode: 400, + }, + // The machine's own refusal is not a client error and not a server fault: + // the request was valid, the connecting host answered no. 502 says the + // upstream (the node) refused, which is exactly the shape. + [BackendErrorCodes.SSH_NODE_REFUSED]: { + message: "The connecting machine refused the SSH request", + statusCode: 502, + }, + [BackendErrorCodes.SSH_NO_RESTART]: { + message: "SSH panes are not restarted; launch again to reconnect", + statusCode: 409, + }, }; From 55ab85323fef0b6a7802692a1392312f937abed4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 21:10:26 -0700 Subject: [PATCH 033/170] feat(api): the ssh launcher surface - gated discovery, resolve, launch, saved hosts /api/ssh (mounted inside the nodeRoutes basket for the Elysia depth ceiling): GET /aliases, POST /resolve, POST /launch, the saved-hosts read/write/delete pair and PATCH /preferences. Cookie-only, gated before any command leaves the plane, refusal-shaped resolve outcomes answer 200 in the data and refuse a launch with 422 {outcome}. The saved rows key on the CANONICAL destination (never the edited alias); the delete answers 204 with no body and 404 identically for foreign and absent; the default machine keeps a vanished node's id rather than silently clearing a human's choice. security.md s10 and the security-context mirror name ssh.launch (success-only, no saved-host CRUD rows, per the prompts precedent). --- .claude/rules/security-context.md | 2 +- apps/server/api/src/api/routes.ts | 11 +- .../src/api/ssh/__tests__/ssh-routes.test.ts | 691 ++++++++++++++++++ apps/server/api/src/api/ssh/aliases.route.ts | 58 ++ apps/server/api/src/api/ssh/index.ts | 20 + apps/server/api/src/api/ssh/launch.route.ts | 80 ++ apps/server/api/src/api/ssh/resolve.route.ts | 54 ++ .../api/src/api/ssh/saved-hosts.route.ts | 182 +++++ apps/server/api/src/api/ssh/ssh-views.ts | 129 ++++ docs/security.md | 9 +- 10 files changed, 1230 insertions(+), 6 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts create mode 100644 apps/server/api/src/api/ssh/aliases.route.ts create mode 100644 apps/server/api/src/api/ssh/index.ts create mode 100644 apps/server/api/src/api/ssh/launch.route.ts create mode 100644 apps/server/api/src/api/ssh/resolve.route.ts create mode 100644 apps/server/api/src/api/ssh/saved-hosts.route.ts create mode 100644 apps/server/api/src/api/ssh/ssh-views.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index a037fd698..29215d351 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/server/api/src/api/routes.ts b/apps/server/api/src/api/routes.ts index 93b011a5d..150769651 100644 --- a/apps/server/api/src/api/routes.ts +++ b/apps/server/api/src/api/routes.ts @@ -24,6 +24,7 @@ import { instancePublicRoutes } from "@/api/settings-public.route.js"; import { setupRoutes } from "@/api/setup.route.js"; import { setupAgentInstallRoute } from "@/api/setup-agent-install.route.js"; import { setupTmuxInstallRoute } from "@/api/setup-tmux-install.route.js"; +import { sshRoutes } from "@/api/ssh/index.js"; import { subshellRoutes } from "@/api/subshells/index.js"; import { systemKeysRoutes } from "@/api/system-keys.route.js"; import { transferRoutes } from "@/api/transfers/index.js"; @@ -80,10 +81,12 @@ const paneRoutes = new Elysia() .use(filesRoutes) .use(workspaceRoutes); -// The machine-side surfaces: node administration and the node-to-node transfer -// relay. Its own group (not an eighth `.use()` on the pane basket) keeps the -// composed type shallow enough to compile. -const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes); +// The machine-side surfaces: node administration, the node-to-node transfer +// relay, and the SSH launcher (every `/api/ssh` act is gated on a NODE row, +// so it belongs with the machine basket it asks about). Its own group (not an +// eighth `.use()` on the pane basket) keeps the composed type shallow enough +// to compile — the depth budget the grouping header exists to protect. +const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes).use(sshRoutes); const commsRoutes = new Elysia().use(notificationsRoutes).use(devicesRoutes).use(channelRoutes); diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts new file mode 100644 index 000000000..b2c7056b3 --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -0,0 +1,691 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { getHeld, holdConnection, releaseHeld, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, SCRIPTED_DATA_DIR, statDirEcho } from "@/test-helpers/scripted-node.js"; + +/** + * The `/api/ssh` surface (spec 2026-10-07 §4.3/§5/§7) — the gate doctrine, the + * refusal shapes, and the compose the launch answers with. + * + * The properties this matrix exists to pin, each by its own case: + * - **Gate order**: the plane's row + `nodeCanSsh` answer lands BEFORE any + * `sendCommand`; a held agent is refused before the resolve round trip; + * discovery, resolve and launch all sit behind the same gate. + * - **Refusal placement**: `POST /resolve` answers a refusal outcome IN THE + * DATA (200, tier-1 grammar); `POST /launch` and `PUT /saved-hosts` refuse a + * refusal-shaped outcome with 422 carrying `{outcome}`. + * - **Compose**: the launch frame carries the config at the NODE's derived + * path, the rendered bytes, and the option tail `-F … -- host`; the row + * carries the approved snapshot; `SSH_AUTH_SOCK` rides only when the + * snapshot names a socket. + * - **Saved hosts**: lists never carry another owner's rows; a foreign delete + * is the same 404 as an absent one; preferences store the id and the read + * tolerates the node vanishing. + * - **Audit**: `ssh.launch` lands on SUCCESS only, with `{nodeId, destination, + * subshellId}` and nothing else; saved-host CRUD audits nothing (prompts precedent). + * + * The agent answers come from the scripted node (the real `sendCommand` chain, + * the real frame parser) — the `ssh`/`resolve` frames this surface emits are + * what the shipped agent would switch on. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "ssh-api-1"; +const ownerEmail = `ssha-owner-${crypto.randomUUID()}@subshell.local`; +const editorEmail = `ssha-editor-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `ssha-other-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `ssha-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let editorId: string; +let ownerCookie: string; +let editorCookie: string; +let otherCookie: string; +let adminCookie: string; + +/** The agent node this file's launch cases run on (SSH row switched ON). */ +const NODE_ON = `n-ssh-on-${crypto.randomUUID()}`; +/** Same machine with the SSH row OFF — the gate's negative fixture. */ +const NODE_OFF = `n-ssh-off-${crypto.randomUUID()}`; + +const nodes = new NodesRepository(db); + +/** An approved snapshot in its wire spelling (the shape `parseSshConnectionSnapshot` rebuilds). */ +function snapshot(over: Record = {}): Record { + return { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} +const CANON = "example.test:22"; +const ACCEPTED = snapshot(); +const REFUSED = { accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }; + +function sshFetch(path: string, init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +const aliases = (node: string, cookie: string) => sshFetch(`/api/ssh/aliases?node=${node}`, { cookie }); +const resolve = (node: string, alias: string, cookie: string) => + sshFetch("/api/ssh/resolve", { method: "POST", body: { node, alias }, cookie }); +const launch = (body: Record, cookie: string) => + sshFetch("/api/ssh/launch", { method: "POST", body, cookie }); + +async function auditRows(): Promise< + { id: string; targetId: string | null; metadata: Record | null }[] +> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "targetId", "metadataJson"]) + .where("action", "=", "ssh.launch") + .execute(); + return rows.map((r) => ({ + id: r.id, + targetId: r.targetId, + metadata: r.metadataJson ? JSON.parse(r.metadataJson) : null, + })); +} + +async function mkNode(id: string, sshEnabled: boolean, owner = ownerId): Promise { + await nodes.create({ id, ownerUserId: owner, name: `sshapi-${id.slice(0, 20)}`, kind: "agent", status: "offline" }); + if (sshEnabled) await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + // A fresh detection that found the ssh binary — the launch's per-node + // usability gate reads it exactly as the picker does (spec §6.2). + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +/** The scripted node every gate-order case observes from the outside. */ +function silentScript(nodeId: string) { + return attachScriptedNode(nodeId, { + ssh_discover_aliases: () => ({ aliases: ["work"], includeCycle: false, truncated: false }), + ssh_resolve_config: (cmd) => + cmd.type === "ssh_resolve_config" && cmd.alias === "work" ? { accepted: true, snapshot: ACCEPTED } : REFUSED, + launch: ok, + stat_dir: statDirEcho, + }); +} + +const createdSubshellIds: string[] = []; +const createdNodeIds: string[] = []; +const emails: string[] = []; + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string, role: "user" | "admin") => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + ownerId = await mk(ownerEmail, "user"); + editorId = await mk(editorEmail, "user"); + await mk(otherEmail, "user"); + await mk(adminEmail, "admin"); + emails.push(ownerEmail, editorEmail, otherEmail, adminEmail); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + await mkNode(NODE_ON, true); + await mkNode(NODE_OFF, false); + await new NodeSharesRepository(db).replaceForNode( + NODE_ON, + [{ granteeUserId: editorId, permission: "edit" }], + ownerId, + ); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshSavedHosts").execute(); + for (const id of createdNodeIds.concat(NODE_ON, NODE_OFF)) await nodes.deleteById(id).catch(() => {}); + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + await db.deleteFrom("settings").where("key", "like", "ssh.defaultNode:%").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("/api/ssh gate order (row + nodeCanSsh BEFORE any sendCommand)", () => { + it("a node whose row is OFF never receives a command: aliases, resolve and launch all 403 ssh_gate_off", async () => { + const scripted = silentScript(NODE_OFF); + try { + for (const res of [ + await aliases(NODE_OFF, ownerCookie), + await resolve(NODE_OFF, "work", ownerCookie), + await launch({ node: NODE_OFF, destination: "work" }, ownerCookie), + ]) { + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + } + // The load-bearing assertion: the gate answered before the machine was + // asked. A sendCommand anywhere in the path would have put a frame here. + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a held agent is refused with the update remedy before any resolve; even ahead of destination validation", async () => { + const scripted = silentScript(NODE_ON); + const held = holdConnection(NODE_ON, scripted.conn, { + reason: "protocol-mismatch", + agentVersion: "0.0.1", + protocolVersion: 12, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + try { + const res = await aliases(NODE_ON, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.NODE_PROTOCOL_HELD); + expect(body.message.toLowerCase()).toContain("update"); + // Held BEFORE the destination check: an unsafe token still answers 409. + expect((await launch({ node: NODE_ON, destination: "-oProxyCommand=x" }, ownerCookie)).status).toBe(409); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + if (getHeld(NODE_ON)) releaseHeld(NODE_ON, held.conn); + scripted.detach(); + } + }); + + it("the machine's owner acts; an edit grantee is 403 (visible, not theirs), a stranger is 404 (never a leak)", async () => { + const scripted = silentScript(NODE_ON); + try { + // Row ON, but SSH USE is owner-only: the grantee sees the node (it is on + // their screen) and is refused, not hidden. + expect((await aliases(NODE_ON, editorCookie)).status).toBe(403); + expect((await resolve(NODE_ON, "work", editorCookie)).status).toBe(403); + // The admin boost is instance-wide EDIT, never ownership — SSH use stays + // with the machine's owner (spec §4.3: broker an OS account's keys). + expect((await aliases(NODE_ON, adminCookie)).status).toBe(403); + expect((await aliases(NODE_ON, otherCookie)).status).toBe(404); + expect((await launch({ node: NODE_ON, destination: "work" }, otherCookie)).status).toBe(404); + // An unknown id answers the same 404 a foreign one did (no existence oracle). + expect((await aliases(`n-never-${crypto.randomUUID()}`, ownerCookie)).status).toBe(404); + } finally { + scripted.detach(); + } + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + // A real subshell token (its own row, its owner's key) — the actor kind is + // refused regardless of the owner it resolves to. + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "bearer-probe", + workingDir: tmpdir(), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + // An empty cookie is just a failed authentication (401), before any actor kind exists. + expect((await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { cookie: "" })).status).toBe(401); + const res = await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { bearer: key }); + expect(res.status).toBe(403); + }); +}); + +describe("/api/ssh/resolve (refusal rides IN THE DATA)", () => { + it("an accepted alias returns the full outcome, and the machine got exactly one resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; snapshot?: { host: string } }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("example.test"); + expect(scripted.cmdsOf("ssh_resolve_config").map((c) => c.alias)).toEqual(["work"]); + } finally { + scripted.detach(); + } + }); + + it("a refused resolution is a 200 carrying the refusal (settings named), never an error status", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "proxy-box", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; code: string; settings: string[] }; + expect(body.accepted).toBe(false); + expect(body.code).toBe("unsupported_setting"); + expect(body.settings).toEqual(["ProxyCommand"]); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe and never reaches the machine", async () => { + const scripted = silentScript(NODE_ON); + try { + for (const alias of ["-x", "-oProxyCommand=evil", "has space", "escape"]) { + const res = await resolve(NODE_ON, alias, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + } + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("an offline agent answers 409 NODE_OFFLINE, a machine-level refusal answers 502 SSH_NODE_REFUSED", async () => { + // No socket at all: the row says ON, the machine is not there. + const resOffline = await resolve(NODE_ON, "work", ownerCookie); + expect(resOffline.status).toBe(409); + expect(((await resOffline.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + // Attached but answering `ok:false` (no ssh binary / gate mirror says no): + // the machine's own refusal maps to the 502 family, verbatim text NOT echoed. + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => new Error("ssh binary missing: ssh"), + }); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(502); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NODE_REFUSED); + expect(body.message).not.toContain("ssh binary missing"); + } finally { + scripted.detach(); + } + }); +}); + +describe("POST /api/ssh/launch", () => { + it("refuses a refusal-shaped outcome with 422 carrying {outcome}, and launches nothing", async () => { + const scripted = silentScript(NODE_ON); + const rowsBefore = (await db.selectFrom("subshells").select("id").execute()).length; + try { + const res = await launch({ node: NODE_ON, destination: "proxy-box" }, ownerCookie); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { accepted: boolean; code: string; settings: string[] } }; + expect(body.outcome.accepted).toBe(false); + expect(body.outcome.code).toBe("unsupported_setting"); + expect(body.outcome.settings).toEqual(["ProxyCommand"]); + // Nothing was written: no row, no audit, no recency touch. + expect((await db.selectFrom("subshells").select("id").execute()).length).toBe(rowsBefore); + expect(scripted.countOf("launch")).toBe(0); + expect(await auditRows()).toHaveLength(0); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe before any resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await launch({ node: NODE_ON, destination: "-x" }, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("launches the ssh pane: the frame carries the config at the NODE's derived path, the option tail, the agent socket env; the row keeps the snapshot", async () => { + const snap = snapshot({ authAgentSocket: "/run/user/501/ssh-agent.sock" }); + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snap }), + launch: ok, + stat_dir: statDirEcho, + }); + const beforeIds = new Set((await auditRows()).map((r) => r.id)); + try { + const res = await launch({ node: NODE_ON, destination: "work", name: "desk" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: Record }; + createdSubshellIds.push(subshell.id as string); + expect(subshell.harnessId).toBe("ssh"); + expect(subshell.name).toBe("desk"); + expect(subshell.nodeOffline).toBe(false); + + const parsed = snap; // what the agent answered is what the row must hold + const row = await new SubshellsRepository(db).findById(subshell.id as string); + expect(row).toBeDefined(); + expect(JSON.parse(String(row?.ssh))).toEqual(parsed); // the snapshot column IS the kind fact + expect(row?.presetId).toBeNull(); + expect(row?.nodeId).toBe(NODE_ON); + expect(row?.restartOnExit).toBe(0); // auto-restart is forced off for ssh panes (decision 7) + + const cmd = scripted.cmdsOf("launch")[0] as Record & { + ssh?: { configPath: string; fileContent: string }; + argv?: string[]; + subshellEnv?: Record; + preset?: { flags?: string[] }; + }; + expect(cmd.ssh?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string)); // the node's dataDir, not the server's + expect(cmd.ssh?.fileContent).toBe(renderSshConfigContents(parsed as never)); + // Handoff 4's tail: every option token, then `--`, then the bare host. + const configPath = buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string); + expect(cmd.argv?.slice(1)).toEqual([ + ...sshOptionTokens(parsed as never, configPath), + "--", + sshDestinationToken(parsed as never), + ]); + expect(cmd.preset?.flags?.[0]).toBe("-F"); + // Decision 3: the snapshot's agent socket rides the pane env ONLY when named. + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + } finally { + scripted.detach(); + } + // Audit: success lands exactly one ssh.launch row with the three named facts. + const added = (await auditRows()).filter((r) => !beforeIds.has(r.id)); + expect(added).toHaveLength(1); + expect(added[0].targetId).toBe(NODE_ON); + expect(added[0].metadata).toEqual({ nodeId: NODE_ON, destination: CANON, subshellId: createdSubshellIds.at(-1) }); + }); + + it("a snapshot naming no agent socket leaves SSH_AUTH_SOCK out of the pane env", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("a successful launch records the recency row (saved_at stays null until the human saves)", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + await res.json(); + const rows = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", CANON) + .where("ownerUserId", "=", ownerId) + .execute(); + expect(rows).toHaveLength(1); + expect(rows[0].savedAt).toBeNull(); + expect(rows[0].alias).toBe("work"); + expect(rows[0].nodeId).toBe(NODE_ON); + } finally { + scripted.detach(); + } + }); +}); + +describe("/api/ssh/saved-hosts", () => { + it("PUT resolves then upsert-marks-saved, and audits NOTHING (prompts precedent)", async () => { + const scripted = silentScript(NODE_ON); + const before = await db.selectFrom("auditEvents").select("id").execute(); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "work" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(200); + const row = (await res.json()) as { destination: string; savedAt: string | null; alias: string | null }; + expect(row.destination).toBe(CANON); + expect(row.savedAt).not.toBeNull(); + expect(row.alias).toBe("work"); + } finally { + scripted.detach(); + } + const after = await db.selectFrom("auditEvents").select("id").execute(); + expect(after.length).toBe(before.length); // saved-host CRUD records nothing, ever + }); + + it("PUT refuses a refusal-shaped outcome with 422 {outcome} and writes no row", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "proxy-box" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { code: string } }; + expect(body.outcome.code).toBe("unsupported_setting"); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("ownerUserId", "=", ownerId).execute(); + expect(rows.find((r) => r.destination === "proxy-box")).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("GET lists the caller's own saved and recent rows only — never another owner's", async () => { + // Owner A saves; owner B (a second human with their own node) must see + // none of A's rows, and vice versa. + await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "a-private.test:22", + alias: "mine", + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .execute(); + const resA = await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie }); + expect(resA.status).toBe(200); + const viewA = (await resA.json()) as { + saved: { destination: string }[]; + recent: unknown[]; + defaultNodeId: string | null; + }; + expect(viewA.saved.map((r) => r.destination)).toContain("a-private.test:22"); + + const resB = await sshFetch("/api/ssh/saved-hosts", { cookie: editorCookie }); + const viewB = (await resB.json()) as { saved: { destination: string }[]; recent: unknown[] }; + expect(viewB.saved.map((r) => r.destination)).not.toContain("a-private.test:22"); + expect(viewB.recent).toEqual([]); + }); + + it("DELETE removes the owner's row (204) and a foreign id answers the same 404 as an absent one", async () => { + const row = await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "to-delete.test:22", + alias: null, + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .returningAll() + .executeTakeFirstOrThrow(); + const foreign = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: editorCookie }); + expect(foreign.status).toBe(404); // refused like an absent row — the id is no oracle + const own = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(own.status).toBe(204); + const again = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(again.status).toBe(404); + }); +}); + +describe("/api/ssh/preferences (default connecting machine)", () => { + async function patchPreferences(cookie: string, defaultNodeId: string | null) { + return sshFetch("/api/ssh/preferences", { method: "PATCH", body: { defaultNodeId }, cookie }); + } + + it("stores a visible node's id, reads it back on the saved-hosts view, and null clears it", async () => { + const set = await patchPreferences(ownerCookie, NODE_ON); + expect(set.status).toBe(200); + expect(((await set.json()) as { defaultNodeId: string | null }).defaultNodeId).toBe(NODE_ON); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(NODE_ON); + const cleared = await patchPreferences(ownerCookie, null); + expect(((await cleared.json()) as { defaultNodeId: string | null }).defaultNodeId).toBeNull(); + }); + + it("an invisible or unknown node id is 404 (validation happens at the write)", async () => { + expect((await patchPreferences(ownerCookie, "no-such-node")).status).toBe(404); + expect((await patchPreferences(editorCookie, NODE_OFF)).status).toBe(404); // the editor cannot see NODE_OFF + }); + + it("a node that vanishes after the write still reads back as the stored id (the SPA decides)", async () => { + const transient = `n-vanish-${crypto.randomUUID()}`; + createdNodeIds.push(transient); + await nodes.create({ + id: transient, + ownerUserId: ownerId, + name: `sshapi-vanish-${transient.slice(0, 8)}`, + kind: "agent", + status: "offline", + }); + await patchPreferences(ownerCookie, transient); + await nodes.deleteById(transient); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(transient); + }); +}); + +describe("the local node answers in-process (no agent socket exists to reach)", () => { + // NOTE on HOME: Bun's `os.homedir()` reads HOME at PROCESS START and caches + // it (measured on 1.4.2), so a test cannot swap `process.env.HOME` to feed + // the discovery engine a fixture `~`. The alias-list CONTENTS are therefore + // whatever the server account's own config holds (machine-dependent; the + // discovery engine's fixture-parsed contents are pinned in + // pane-runtime's `ssh-discover` suite). What this case owns is the plane + // half: the gate is asked BEFORE the filesystem is read, and `local` answers + // through the in-process runtime rather than an RPC. + let homeDir: string; + let previousSshPath: string | undefined; + + beforeAll(async () => { + homeDir = mkdtempSync(join(tmpdir(), "subshell-ssh-home-")); + // A stub ssh that answers `-G` like a clean OpenSSH would for the manual + // destination (no forwarding lines, one hostname, a chosen user, a + // non-default port): the resolution engine here is the SAME code the + // agent runs, fed this deterministic answer. + const stub = join(homeDir, "ssh-stub.sh"); + writeFileSync( + stub, + '#!/bin/sh\nprintf "host verify.example\\nhostname verify.example\\nport 2222\\nuser stubuser\\n"\nexit 0\n', + ); + chmodSync(stub, 0o755); + previousSshPath = process.env.SUBSHELL_SSH_PATH; + process.env.SUBSHELL_SSH_PATH = stub; // findBinary's first rung, like every harness override + }); + + afterAll(() => { + if (previousSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = previousSshPath; + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("local SSH off answers 403 before the account's config is read; the admin opens it and the in-process discovery answers", async () => { + const gated = await aliases(LOCAL_NODE_ID, adminCookie); + expect(gated.status).toBe(403); // local ships OFF (spec §4.3) + + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await aliases(LOCAL_NODE_ID, adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { aliases: string[]; includeCycle: boolean; truncated: boolean }; + // The SHAPE is the contract here (sorted names + the two cycle/truncation + // facts); contents belong to whoever's account runs the server. + expect(Array.isArray(body.aliases)).toBe(true); + expect(body.includeCycle).toBe(false); + expect(body.truncated).toBe(false); + // A member holds Everyone/edit on `local` — visibility, never SSH use. + expect((await aliases(LOCAL_NODE_ID, otherCookie)).status).toBe(403); + }); + + it("local resolve runs the same engine in-process: the -G answer becomes an approved snapshot", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await resolve(LOCAL_NODE_ID, "verify.example", adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accepted: boolean; + snapshot?: { host: string; port: number; user: string | null; alias: string }; + connectingAccount?: string; + }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("verify.example"); + expect(body.snapshot?.port).toBe(2222); + expect(body.snapshot?.user).toBe("stubuser"); + expect(body.snapshot?.alias).toBe("verify.example"); + expect(body.connectingAccount).toBeDefined(); + }); +}); diff --git a/apps/server/api/src/api/ssh/aliases.route.ts b/apps/server/api/src/api/ssh/aliases.route.ts new file mode 100644 index 000000000..338ccc1f1 --- /dev/null +++ b/apps/server/api/src/api/ssh/aliases.route.ts @@ -0,0 +1,58 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshListAliases } from "@/services/ssh-launch.service.js"; + +/** + * `GET /api/ssh/aliases?node=` — the NAMES of the machine's usable SSH + * aliases (spec 2026-10-07 §5: discovery returns names, never config file + * contents). Cookie-only; the gate (`nodeCanSsh` over the plane's row) and + * the held-machine refusal run BEFORE the machine is asked anything, so a + * machine the caller may not SSH through never sees a frame. + * + * A machine-level failure maps by kind: offline 409, an app too old for the + * ssh commands 409 naming the update, no answer 409, the machine's own + * `ok:false` 502 (its verbatim text is logged, never echoed). + */ +export const sshAliasesRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/aliases", + async ({ query, user, actor }) => { + requireCookieActor(actor, "SSH alias discovery is restricted to browser sessions"); + const answer = await sshListAliases(user.id, query.node); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + query: t.Object({ + node: t.String({ + minLength: 1, + description: "Node id to read aliases from ('local' = the control-plane host)", + }), + }), + response: { + 200: t.Object({ + aliases: t.Array(t.String({ description: "Alias name (sorted, deduplicated, concrete names only)" }), { + description: "Usable alias NAMES, at most 500", + }), + includeCycle: t.Boolean({ description: "An include cycle was detected; the list is what parsed before it" }), + truncated: t.Boolean({ description: "The alias cap was hit; more exist" }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshListAliases", + tags: ["ssh"], + description: + "Lists the connecting machine's usable SSH alias names (its own config, parsed bounded). Owner-only and off by default; a machine whose SSH is not switched on refuses the command", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts new file mode 100644 index 000000000..0b4e086de --- /dev/null +++ b/apps/server/api/src/api/ssh/index.ts @@ -0,0 +1,20 @@ +import { Elysia } from "elysia"; +import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; +import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; +import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; +import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; + +/** + * `/api/ssh` — the launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): + * discovery, resolution, the gated launch, and the caller's own destination + * ledger. One Elysia instance per endpoint file (the `api/nodes/` shape the + * file-size rule grew this surface into), all cookie-actor, all gated through + * `nodeCanSsh` over the plane's own node row BEFORE any machine is asked + * anything — the refusal order is the contract, pinned in + * `__tests__/ssh-routes.test.ts`. + */ +export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) + .use(sshAliasesRoute) + .use(sshResolveRoute) + .use(sshLaunchRoute) + .use(sshSavedHostsRoutes); diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts new file mode 100644 index 000000000..4cbfdfad6 --- /dev/null +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -0,0 +1,80 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SubshellSchema } from "@/api/models.js"; +import { SshDestinationField, SshResolveRefusalViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { sshLaunch } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/launch` `{node, destination, name?}` — open an interactive + * SSH pane to one destination (spec 2026-10-07 §5): gate (row + owner, before + * anything), resolve the destination on the machine (the SAME path a discovery + * answer takes; an unsafe token 400s first), refuse a refusal-shaped outcome + * with **422 `{outcome}`** (the endpoint that acts does not carry a refusal in + * a 200: a pane must never exist for a destination nobody approved), then + * compose the config at the machine's derived path and launch the ssh harness + * through the ordinary create path (presetless, working dir = the launch + * node's home, auto-restart off by inheritance). + * + * No `prompt` field: an ssh launch types nothing (decision 7's compose: + * reconnecting is not a task to submit). The audit `ssh.launch` lands after + * the pane exists, never before. + */ +export const sshLaunchRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/launch", + async ({ body, user, actor, ctx, status }) => { + requireCookieActor(actor, "SSH launch is restricted to browser sessions"); + const answer = await sshLaunch({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + // The same label rule every human-chosen name takes (idempotent + // through the manager's choke-point re-normalization). + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + subshells: ctx.services.subshells, + }); + if (!answer.ok) { + // The 422 is the ONE arm returned rather than thrown: its body is the + // resolve outcome, not an error envelope. Everything else is a coded + // refusal the handler maps with its standard status. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + const subshell = await ctx.services.subshells.getSubshell(user.id, answer.value.subshellId, actor); + return status(201, { subshell }); + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), + destination: SshDestinationField, + name: t.Optional(t.String({ minLength: 1, maxLength: 120, description: "Pane display name" })), + }), + response: { + 201: t.Object({ subshell: SubshellSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + // The resolve refused: the body is the outcome, in the shape the + // launcher screen already knows how to render from `/resolve`. + 422: t.Object({ + outcome: SshResolveRefusalViewSchema, + }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshLaunchPane", + tags: ["ssh"], + description: + "Opens an interactive SSH pane from the connecting machine to one approved destination. Owner-only and off by default; a destination whose resolution refused answers 422 carrying the outcome and launches nothing", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/resolve.route.ts b/apps/server/api/src/api/ssh/resolve.route.ts new file mode 100644 index 000000000..33fd5f5bb --- /dev/null +++ b/apps/server/api/src/api/ssh/resolve.route.ts @@ -0,0 +1,54 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshDestinationField, SshResolveOutcomeViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshResolveDestination } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/resolve` `{node, alias}` — resolve one destination token on + * the connecting machine (`ssh -G` through the frozen engine) and answer the + * OUTCOME. + * + * **A refusal is a 200 carrying `accepted: false`** (tier-1 grammar): "this + * config needs a ProxyCommand" is a fact the human reads and goes and edits, + * which settings blocked — not a transport error. The endpoint that ACTS + * (`/launch`, saved-hosts PUT) is where a refusal-shaped outcome becomes a + * 422; here nothing is launched and nothing is stored. + * + * Same gate as every ssh surface (row + owner, before any command), and the + * destination takes the wire-grammar shape check FIRST: an unsafe token is + * 400 and never reaches the machine. + */ +export const sshResolveRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .post( + "/resolve", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH resolution is restricted to browser sessions"); + const answer = await sshResolveDestination(user.id, body.node, body.alias); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Node to resolve on ('local' = the control-plane host)" }), + alias: SshDestinationField, + }), + response: { + 200: SshResolveOutcomeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshResolveDestination", + tags: ["ssh"], + description: + "Resolves one destination token into the approved connection snapshot (or the named refusal, which is a successful answer to read). Runs on the connecting machine; nothing is launched or stored by this call", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/saved-hosts.route.ts b/apps/server/api/src/api/ssh/saved-hosts.route.ts new file mode 100644 index 000000000..a814ef5a0 --- /dev/null +++ b/apps/server/api/src/api/ssh/saved-hosts.route.ts @@ -0,0 +1,182 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { + SshDestinationField, + SshResolveRefusalViewSchema, + SshSavedHostViewSchema, + throwCodedRefusal, +} from "@/api/ssh/ssh-views.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { + sshRemoveSavedHost, + sshSavedHostsView, + sshSaveHost, + sshSetDefaultNode, +} from "@/services/ssh-launch.service.js"; + +/** + * The launcher screen's own data (spec 2026-10-07 §7): the owner's saved and + * recently-connected destinations plus the default connecting machine. + * + * The rows are per-owner like prompts: a foreign row is invisible and its id + * answers the same 404 an absent one does (the ownership axis, docs/ + * security.md §3), and CRUD here audits NOTHING — a preference row is not an + * event, the prompts precedent (the ACT the ledger records is `ssh.launch`, + * audited by the service). + * + * `PUT` resolves the destination first (the ledger is keyed by the CANONICAL + * resolved destination, so an edited alias can never silently re-point a + * saved row), which means the write carries the launch's gate: a machine the + * caller may not SSH through cannot hold their saved entry, and a + * refusal-shaped outcome is the same 422 `{outcome}` a launch answers. + */ +export const sshSavedHostsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/saved-hosts", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + return await sshSavedHostsView(user.id); + }, + { + response: { + 200: t.Object({ + saved: t.Array(SshSavedHostViewSchema, { description: "Rows a human saved, newest save first (max 20)" }), + recent: t.Array(SshSavedHostViewSchema, { + description: "Most-recently-connected destinations, saved or not, newest first (max 20)", + }), + defaultNodeId: t.Nullable( + t.String({ description: "Stored default connecting machine id; resolves to null when none was chosen" }), + { + description: + "The caller's default connecting machine, or null. A node deleted since reads back as the stored id; the client decides", + }, + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshSavedHosts", + tags: ["ssh"], + description: "Lists the caller's saved and recently-used SSH destinations and their default connecting machine", + }, + }, + ) + .put( + "/saved-hosts", + async ({ body, user, actor, status }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const answer = await sshSaveHost({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + alias: body.alias, + }); + if (!answer.ok) { + // A refusal-shaped outcome refuses the SAVE with the same 422 body a + // launch answers (the saved key comes from the resolution; there is + // nothing to store without it). Everything else is a coded refusal. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Machine to resolve the destination on (the gate owner)" }), + destination: SshDestinationField, + alias: t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "Display label override; absent keeps the resolved snapshot's own alias", + }), + ), + }), + response: { + 200: SshSavedHostViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 422: t.Object({ outcome: SshResolveRefusalViewSchema }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "saveSshHost", + tags: ["ssh"], + description: + "Saves one destination for the caller (resolves it first; the key is the canonical host:port, never the alias). Refusal-shaped outcomes answer 422 carrying the outcome and store nothing", + }, + }, + ) + .delete( + "/saved-hosts/:id", + async ({ params, user, actor, status, set }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const removed = await sshRemoveSavedHost(user.id, params.id); + if (!removed) { + // Foreign AND absent, one 404: the id is not an existence oracle. + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Saved host not found" })); + } + // 204 with NO body at all, set on `set` and returned undefined: a + // `status(204, null)` body trips the fetch Response constructor (204 + // may not carry one) and Elysia would serialize the null anyway. + set.status = 204; + }, + { + params: t.Object({ id: t.String({ description: "Saved-host row id" }) }), + response: { + // 204 is deliberately NOT declared: a declared schema makes Elysia + // validate the (absent) body and answer 400 for a successful delete. + // The handler sets `set.status = 204` and returns nothing; OpenAPI + // reads the empty success from the detail description below. + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshSavedHost", + tags: ["ssh"], + description: + "Deletes one of the caller's saved destinations (204 no body on success; a foreign row is the same 404 as an absent one)", + }, + }, + ) + .patch( + "/preferences", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH preferences are restricted to browser sessions"); + const answer = await sshSetDefaultNode(user.id, body.defaultNodeId); + if (!answer.ok) return throwCodedRefusal(answer.refusal); // the one arm it can carry is the write-time 404 + return { defaultNodeId: answer.value }; + }, + { + body: t.Object({ + defaultNodeId: t.Nullable( + t.String({ minLength: 1, description: "Node id to default the launcher's machine picker to" }), + { description: "null clears the default" }, + ), + }), + response: { + 200: t.Object({ + defaultNodeId: t.Nullable(t.String({ description: "The stored default machine id (null = none)" })), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setSshPreferences", + tags: ["ssh"], + description: + "Sets (or clears) the caller's default connecting machine; the write validates the node is one the caller can see, a later deletion leaves the stored id readable", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts new file mode 100644 index 000000000..2489168cb --- /dev/null +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -0,0 +1,129 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { t } from "elysia"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; + +/** + * The wire views of the `/api/ssh` surface, shared by the routes that carry + * them (the resolve outcome rides its own endpoint, the launch's 422, and the + * save's 422; the saved-host row rides GET and PUT). One definition per + * shape — the schemas are the API contract, and two copies of a contract + * drift. + */ + +/** One ProxyJump hop as the frozen snapshot spells it. */ +export const SshHopViewSchema = t.Object({ + host: t.String({ description: "Hop hostname (bracketed IPv6 literal kept as given)" }), + user: t.Nullable(t.String({ description: "Hop user; null means the connecting account's own default" })), + port: t.Number({ description: "Hop port, resolved" }), +}); + +/** The approved connection snapshot, exactly as `SshConnectionSnapshotWire` serializes. */ +export const SshSnapshotViewSchema = t.Object({ + alias: t.String({ description: "The config token the human chose (display and review context only)" }), + host: t.String({ description: "Resolved destination hostname" }), + user: t.Nullable(t.String({ description: "Destination user; null means the connecting account's own default" })), + port: t.Number({ description: "Destination port, resolved (1..65535)" }), + identityFiles: t.Array(t.String({ description: "Absolute identity-file path on the connecting machine" }), { + description: "Identity file references (paths, never key contents)", + }), + certificateFiles: t.Array(t.String({ description: "Absolute certificate-file path on the connecting machine" }), { + description: "Certificate file references (paths only)", + }), + authAgentSocket: t.Nullable(t.String({ description: "Absolute agent-socket path; null runs with no agent" }), { + description: "The authentication-agent socket to use, or null", + }), + knownHostsFiles: t.Array(t.String({ description: "Absolute known-hosts path on the connecting machine" }), { + description: "Known-hosts files to consult (paths only)", + }), + hostKeyAlias: t.Nullable(t.String({ description: "OpenSSH HostKeyAlias when the config sets one" }), { + description: "The name strict host checking looks up, or null", + }), + proxyJumps: t.Array(SshHopViewSchema, { description: "Bounded ProxyJump chain, outermost first" }), + proxyCommand: t.Null({ + description: "Always null: a resolved ProxyCommand makes a destination refusable, never renderable", + }), + forwards: t.Null({ description: "Always null: any resolved forwarding refuses the snapshot" }), + tunnels: t.Null({ description: "Always null: any resolved tunnel refuses the snapshot" }), + localCommands: t.Null({ description: "Always null: any resolved local command refuses the snapshot" }), + remoteCommand: t.Null({ description: "Always null: a resolved RemoteCommand refuses the snapshot" }), + sendEnv: t.Null({ description: "Always null: a resolved SendEnv refuses the snapshot" }), + setEnv: t.Null({ description: "Always null: a resolved SetEnv refuses the snapshot" }), + escapes: t.Null({ description: "Always null: live escape characters refuse the snapshot" }), +}); + +/** The `accepted:false` arm: a successful answer whose CONTENT is a refusal. */ +export const SshResolveRefusalViewSchema = t.Object({ + accepted: t.Literal(false, { + description: "Resolution refused: the config needs more than the approved normalization can run", + }), + code: t.String({ + description: + "The named limitation (SshErrorCode): unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, host_key_unknown, …", + }), + settings: t.Array( + t.String({ description: "Config keyword that blocked acceptance (a ProxyCommand row, a LocalForward row, …)" }), + { + description: "What blocked; empty when the code names the whole cause", + }, + ), +}); + +/** `POST /api/ssh/resolve`'s answer: the tier-1 outcome, refusal IN THE DATA. */ +export const SshResolveOutcomeViewSchema = t.Union([ + t.Object( + { + accepted: t.Literal(true, { description: "The destination normalized cleanly into an approved snapshot" }), + snapshot: SshSnapshotViewSchema, + connectingAccount: t.Optional( + t.String({ + description: "The connecting account's OS user name, when the machine could report it (display only)", + }), + ), + }, + { description: "An approved snapshot the human reviews, then saves or launches" }, + ), + SshResolveRefusalViewSchema, +]); + +/** One saved/recency row as its owner sees it. */ +export const SshSavedHostViewSchema = t.Object({ + id: t.String({ description: "Row id (uuid)" }), + destination: t.String({ description: "Canonical host:port (or user@host:port), the resolved destination as key" }), + alias: t.Nullable(t.String({ description: "Display token (the alias typed or discovered); never a key" })), + nodeId: t.String({ description: "The connecting machine of the most recent launch to this destination" }), + savedAt: t.Nullable(t.String({ description: "ISO 8601 the human saved it; null = recency-only row" })), + lastConnectAt: t.String({ description: "ISO 8601 of the most recent launch to this destination" }), +}); + +/** The shared destination-field schema (1..253 chars; the deeper shape rule lives in the service, next to the resolve that enforces it). */ +export const SshDestinationField = t.String({ + minLength: 1, + maxLength: 253, + description: + "Destination token: an alias from the machine's config or a concrete host. Same path either way: it is resolved on the connecting machine before anything launches.", +}); + +/** + * Throw a CODED service refusal as the structured `ApiError` the global + * handler serializes (the `NODE_IN_MAINTENANCE` posture: an expected refusal + * mapped at the service boundary, doNotLog so the 4xx never fills the error + * log). The status rides the code's own definition, and every code here is + * defined with exactly the status the gate doctrine gives it (403 gate, 404 + * invisible, 409 held/offline, 400 unsafe token, 502 machine-refused) — the + * pairing is in `error-codes.ts`, not re-derived here. + * + * The 422 arm — the resolve outcome an ACTING endpoint refuses — is NOT an + * error envelope (its body is `{outcome}`) and belongs to the `status(422,…)` + * return of the launch/save routes; reaching this helper with one is a + * routing bug in this file's own callers, so it answers 500 rather than + * inventing a body. + */ +export function throwCodedRefusal(refusal: SshRefusal): never { + if (refusal.status === 422) { + throwApiError({ + code: BackendErrorCodes.INTERNAL_SERVER_ERROR, + message: "SSH refusal routed to the wrong surface", + }); + } + throwApiError({ code: refusal.code, message: refusal.message, doNotLog: true }); +} diff --git a/docs/security.md b/docs/security.md index 95bfb308f..39bbdfede 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2280,7 +2280,14 @@ Audit events are written, grouped by family: `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart - verb shares its action with the rest of the service surface). + verb shares its action with the rest of the service surface). The SSH + launcher's act lands in this family too as `ssh.launch` (spec 2026-10-07 + §12): metadata `{ nodeId, destination, subshellId }`, naming which machine + dialed out to which canonical `host:port` and the pane that came of it, and + never a config value, path list, or key material (§13). It is written AFTER + the pane spawned, success only, the same posture as `subshell.create`, and + saved-host CRUD audits nothing (the prompts precedent: a preference row is + not an event). - **The server process**: `server.config.update`, `server.restart`, `server.logging.update`, `server.autostart.update`, `server.update`, and `server.supervision.request`, the last one best-effort and written by the From 4e0e8393a5937929ad7b976c3ecef73f5385d123 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 21:31:59 -0700 Subject: [PATCH 034/170] =?UTF-8?q?feat(api):=20ssh=20panes=20take=20input?= =?UTF-8?q?=20from=20their=20owner=20alone=20(spec=20=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ssh pane runs a real shell under the OWNER's ssh identity on the far machine, so typing into it is not an `edit` act a grantee holds — it is the owner's own hands on a remote session. Every input door now asks ONE predicate, `lib/ssh-pane-access.ts` `paneInputAllowed(row, actorUserId, bearerActor)`: an ordinary pane (`ssh === null`) passes untouched; an ssh pane admits the owner's human account and refuses everyone else. `bearerActor` is a separate input (not folded into the id test) because raw REST resolves a pane's OWN subshell key as its OWNER, so a bare owner comparison would let an ssh pane type into itself; the WS door carries the same fact as the scoped-token marker (identity.subshellId !== null). Refuses carry the named 403 code SSH_OWNER_INPUT_ONLY (T7's error-code module, a distinct refusal from the gate-off and no-restart codes). Doors: - REST input (`sendSubshellInput`) and exec (`execInTerminal`): the check rides the `#gate` follow-up (a stranger still 404s, a `view` grantee the plain 403) and precedes the running/harness/quiet checks, so a non-owner types and reads nothing. `requireInputAccess`/`resolvePaneInputAccess` named in the plan are not the real shape — the two service verbs gate in-method. - WS live input: `resolveAttach` computes `canInput` ONCE (edit/owner grant AND the ssh carve-out) and both attach paths (local `handleSubshellWs` and the node relay) carry it; the keystroke branch already drops `canInput:false` frames silently, so the ssh carve-out lands exactly like a `view` drop. The relay's `access`-only derivation stays as the test-fixture fallback. Also freezes the snapshot: `SubshellUpdate` now excludes `ssh` beside `crossAgent`/`id`/`userId` — the approved snapshot is written once at create and it IS the input rule's kind fact, so no later act may rewrite it (compile-time pinned; the predicate suite carries the `@ts-expect-error`). No route response-map or treaty change: both verbs already declare 403 ApiErrorResponse (like SSH_NO_RESTART reuses its 409), so the new code rides the existing type. Tests: the predicate directly (pure), the input and exec doors (each: ordinary+edit-grantee 200 unchanged, ssh+owner 200, ssh+edit-grantee 403 named code, ssh + the pane's own key 403), and the WS authority via resolveAttach.canInput (ssh+view/edit-grantee, ssh+owner, ssh + own scoped key, and the ordinary-row regression pins). --- .../__tests__/subshell-exec-ssh.route.test.ts | 226 ++++++++++++++++++ .../subshell-input-ssh.route.test.ts | 205 ++++++++++++++++ .../src/api/subshells/input-subshell.route.ts | 12 +- .../api/src/db/types/subshells.db-types.ts | 8 +- .../src/lib/__tests__/ssh-pane-access.test.ts | 72 ++++++ apps/server/api/src/lib/ssh-pane-access.ts | 48 ++++ .../api/src/services/subshells.service.ts | 36 ++- .../src/ws/__tests__/attach-resolve.test.ts | 72 ++++++ apps/server/api/src/ws/attach-resolve.ts | 23 +- apps/server/api/src/ws/remote-subshell-ws.ts | 12 +- apps/server/api/src/ws/subshell-ws.ts | 10 +- packages/backend-errors/src/error-codes.ts | 14 ++ 12 files changed, 723 insertions(+), 15 deletions(-) create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts create mode 100644 apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts create mode 100644 apps/server/api/src/lib/ssh-pane-access.ts diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts new file mode 100644 index 000000000..3b76d54bd --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/exec` on SSH panes (spec 2026-10-07 §5.4): exec + * TYPES into the pane, so it carries the input rule verbatim. The kind check + * rides the same `#gate` follow-up as `sendSubshellInput`, and it precedes + * every frame AND every read: the refusal never even quiet-probes the log. + * + * The grant rules, the sentinel machinery and the scripted-log idiom are + * `subshell-exec-route.test.ts`'s, copied rather than shared so that suite + * stays byte-identical; what differs here is the row's kind. The first case + * re-pins the ordinary terminal row + edit grantee from this suite's own + * door, because the rule must not graze panes whose snapshot column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { + attachScriptedNode, + ok, + type ScriptedHandler, + type ScriptedNode, + statDirEcho, +} from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; the exec suites extend it with `log_read`. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok }; + +/** A minimal but truthful snapshot JSON; the exec door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +const enc = new TextEncoder(); +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); +const SENTINEL_FRAME_RE = /__xcomm_([0-9a-f]{16})_DONE/; + +/** The scripted pane's log as a function of the typed frames (the exec suite's idiom). */ +function scriptedExecLog(typed: string[]): string { + const sentinelFrame = typed[2]; + if (typed.length < 4 || sentinelFrame === undefined) return ""; + const token = SENTINEL_FRAME_RE.exec(sentinelFrame)?.[1]; + if (!token) return ""; + return `out line\n__xcomm_${token}_DONE rc=0\n$ `; +} + +function windowOver(text: string, fromByte: number, maxBytes: number) { + const full = enc.encode(text); + const end = Math.min(full.byteLength, fromByte + maxBytes); + return { bytes_b64: b64(full.subarray(fromByte, end)), next: end, size: full.byteLength }; +} + +function execLogRead(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type !== "log_read") return new Error(`exec-ssh sim: wrong cmd ${cmd.type}`); + return windowOver(scriptedExecLog(typed), cmd.fromByte, cmd.maxBytes); + }; +} + +function typingInto(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type === "input") typed.push(cmd.data); + return undefined; + }; +} + +const fourFramesTyped = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data).length === 4; + +describe("POST /api/subshells/:id/exec on ssh panes (spec §5.4)", () => { + const ownerEmail = `exssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `exssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "execssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; + const createdSubshellIds: string[] = []; + + async function exec(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/exec`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `exssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `exssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary terminal row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types the four frames (the harness is a terminal too)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await exec(id, { cookie: ownerCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; exitCode: number | null }; + expect(body.status).toBe("completed"); + expect(body.exitCode).toBe(0); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY before any frame or read", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + // The refusal precedes even the quiet probes: nothing reached the pane + // and nothing was read from it. + expect(sim.countOf("input")).toBe(0); + expect(sim.countOf("log_read")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + const res = await exec(id, { bearer: key, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts new file mode 100644 index 000000000..864abfba5 --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts @@ -0,0 +1,205 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/input` on an SSH pane (spec 2026-10-07 §5.4, + * decision 5): the pane's session lives on a remote machine under the + * OWNER's ssh identity, so only the owner's own account may type there. The + * `subshells.ssh` column is the kind fact; the refusal is the named 403 code + * `SSH_OWNER_INPUT_ONLY`, asked AFTER the ordinary gate (a stranger still + * gets the 404; a `view` grantee still gets the plain 403) and BEFORE + * anything is typed. + * + * The decisive case is the bearer one: raw REST resolves a pane's own + * subshell key as its OWNER (boost/shares off), so a bare owner comparison + * would let an ssh pane type into itself. `bearerActor` is what refuses it. + * + * The sibling suite (`subshell-input-route.test.ts`) pins the ordinary-pane + * rules; the first case here re-pins the ordinary row + edit grantee from the + * SSH suite's own door, because the rule must not graze panes whose snapshot + * column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, type ScriptedNode, statDirEcho } from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; refusals must leave it untouched. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok, input: ok }; + +/** A minimal but truthful snapshot JSON; the input door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +describe("POST /api/subshells/:id/input on ssh panes (spec §5.4)", () => { + const ownerEmail = `inssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `inssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "inputssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; // agent node with a per-test scripted agent attached + const createdSubshellIds: string[] = []; + + async function input(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/input`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `inssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + const inputBytes = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data); + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `inssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "still types", submit: false } }); + expect(res.status).toBe(200); + expect(inputBytes(sim)).toEqual(["still types"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types (the owner rule is a permission, not a lockout)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await input(id, { cookie: ownerCookie, body: { text: "owner types" } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(inputBytes(sim)).toEqual(["owner types", "\r"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY and types nothing", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "grantee types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + // Raw REST resolves a pane's own key as its OWNER (boost/shares off), + // so the owner comparison alone would ADMIT this; `bearerActor` is the + // explicit second refusal decision 5 names. + const res = await input(id, { bearer: key, body: { text: "self type" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + a SIBLING pane's key 403s too (the §4 owner-resolution is still a bearer act)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const ordinaryPane = await directRow({ name: "inssh-sib-src" }); // the token's own row + const sshPane = await directRow({ name: "inssh-sib-dest", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(ordinaryPane, ownerId); + // The input route's §4 surface admits the owner's OTHER rows to a pane + // key; the ssh kind fence closes exactly that door for ssh panes. + const res = await input(sshPane, { bearer: key, body: { text: "sibling types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/input-subshell.route.ts b/apps/server/api/src/api/subshells/input-subshell.route.ts index 9d7b5bd77..2c0b98697 100644 --- a/apps/server/api/src/api/subshells/input-subshell.route.ts +++ b/apps/server/api/src/api/subshells/input-subshell.route.ts @@ -39,10 +39,14 @@ const InputOkSchema = t.Object({ * MCP `send_to_subshell` tool rides). An `edit` act, like terminal input * on the live attach socket: a `view` grantee 403s, a foreign row 404s, and a * bearer pane key acts as its OWNER with boost and shares off (its own and its - * owner's other subshells), the same per-subshell rule restart follows. A - * non-running row is 409 SUBSHELL_NOT_RUNNING and an offline agent node 409 - * NODE_OFFLINE, both before anything is typed. `{ ok: true }` means the bytes - * were accepted by the pane's machine, not that the pane acted on them. + * owner's other subshells), the same per-subshell rule restart follows. An SSH + * pane is the exception to that bearer-as-owner resolution: its input is the + * owner's account ALONE (an `edit` grantee or the pane's own key 403s + * SSH_OWNER_INPUT_ONLY), because the far side runs under the owner's ssh + * identity (spec 2026-10-07 §5.4). A non-running row is 409 + * SUBSHELL_NOT_RUNNING and an offline agent node 409 NODE_OFFLINE, both before + * anything is typed. `{ ok: true }` means the bytes were accepted by the pane's + * machine, not that the pane acted on them. */ export const inputSubshellRoute = new Elysia() .use(contextPlugin) diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index d40c145bb..a351af3e9 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -152,6 +152,10 @@ export type NewSubshell = Omit< /** * The patch shape for row updates. `crossAgent` is excluded beside `id` and * `userId` because provenance is written exactly once, by the create path: - * "no later act un-stamps it" is a type fact, not a convention. + * "no later act un-stamps it" is a type fact, not a convention. `ssh` is + * excluded for the same reason (spec 2026-10-07 decision 5): the approved + * snapshot is written once at create, and it IS the input rule's kind fact, + * the restart refusal's trigger, and the delete sweep's pointer. No later + * act may rewrite or clear it, so no update can name it. */ -export type SubshellUpdate = Partial>; +export type SubshellUpdate = Partial>; diff --git a/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts new file mode 100644 index 000000000..e2aee8a57 --- /dev/null +++ b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import type { SubshellUpdate } from "@/db/types/subshells.db-types.js"; +import { type PaneInputRow, paneInputAllowed } from "@/lib/ssh-pane-access.js"; + +/** + * `paneInputAllowed` is the ONE ssh input predicate (spec 2026-10-07 §5.4, + * plan decision 5). The GRANT half (edit/view, the bearer switch-off) lives + * in `#gate` and the attach resolver as always; this composes with them. + * + * Compile-time pin in the last block: `SubshellUpdate` must NOT carry `ssh`, + * evaluated by `verify-types` (bun test strips types and runs the assignment + * harmlessly). The `@ts-expect-error` fails the build the moment `ssh` + * reappears in the patch shape, which is the drift being bounded: the + * snapshot is written exactly once, by the create path, like the + * `crossAgent` exclusion it mirrors. + */ + +const OWNER = "u-owner"; +const STRANGER = "u-stranger"; + +const ordinary: PaneInputRow = { ssh: null, userId: OWNER }; +const sshRow: PaneInputRow = { ssh: '{"destination":"host.example"}', userId: OWNER }; + +describe("paneInputAllowed (spec §5.4, decision 5)", () => { + it("admits every actor on an ordinary pane (the grant gate is not this predicate's question)", () => { + // The regression direction: for ssh === null the predicate is true for + // everyone, so every existing door behaves exactly as it did before. + expect(paneInputAllowed(ordinary, OWNER, false)).toBe(true); // owner, cookie + expect(paneInputAllowed(ordinary, STRANGER, false)).toBe(true); // a grantee, cookie + expect(paneInputAllowed(ordinary, OWNER, true)).toBe(true); // the pane's own key (bearer) + expect(paneInputAllowed(ordinary, STRANGER, true)).toBe(true); // a bearer resolving as another owner + }); + + it("admits the owner's human account on an ssh pane", () => { + expect(paneInputAllowed(sshRow, OWNER, false)).toBe(true); + }); + + it("refuses any other account on an ssh pane, however they got past the grant gate", () => { + // An `edit` grantee passes the grant gate; the kind rule still refuses. + expect(paneInputAllowed(sshRow, STRANGER, false)).toBe(false); + }); + + it("refuses a bearer machine credential on an ssh pane, EVEN one resolving as the owner", () => { + // The whole point of the `bearerActor` input: raw REST resolves a pane's + // own subshell key as its OWNER (boost/shares off), so a bare owner + // comparison would let an ssh pane type into itself (decision 5). + expect(paneInputAllowed(sshRow, OWNER, true)).toBe(false); + expect(paneInputAllowed(sshRow, STRANGER, true)).toBe(false); + }); + + it("is decided by the column's presence, not its content", () => { + // The kind fact is "the column is not NULL". Any non-null content is an + // ssh pane (minimal, or otherwise odd): the SAME owner/non-owner split + // holds whatever the bytes are, because snapshot validation belongs to + // the launch boundary, not the input door. + for (const ssh of ['{"destination":"host.example"}', "{}", "not-json"]) { + expect(paneInputAllowed({ ssh, userId: OWNER }, OWNER, false)).toBe(true); + expect(paneInputAllowed({ ssh, userId: OWNER }, STRANGER, false)).toBe(false); + } + // Only NULL reads as an ordinary pane. + expect(paneInputAllowed({ ssh: null, userId: OWNER }, STRANGER, false)).toBe(true); + }); +}); + +describe("SubshellUpdate excludes ssh (frozen at create, like crossAgent)", () => { + it("a patch naming `ssh` is a type error (pinned by verify-types)", () => { + const patch: SubshellUpdate = {}; + // @ts-expect-error `ssh` is excluded from the update-patch shape + patch.ssh = '{"destination":"host.example"}'; + void patch; + }); +}); diff --git a/apps/server/api/src/lib/ssh-pane-access.ts b/apps/server/api/src/lib/ssh-pane-access.ts new file mode 100644 index 000000000..64d1bddd2 --- /dev/null +++ b/apps/server/api/src/lib/ssh-pane-access.ts @@ -0,0 +1,48 @@ +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; + +/** + * The two facts {@link paneInputAllowed} reads: the row's snapshot column (the + * kind fact) and the owner. `SubshellTable` is assignable to it; the narrow + * shape lets the WS path and tests hand over a partial row without pulling the + * whole record into the predicate's contract. + */ +export type PaneInputRow = Pick; + +/** + * The owner-only input rule for ssh panes (spec 2026-10-07 §5.4 / plan + * decision 5). It answers ONE question so every input door (REST input, exec, + * MCP-via-REST, the WS keystroke) asks it the same way: + * + * - An ordinary pane (`ssh === null`) is admitted unconditionally. This + * predicate is the ssh carve-out ONLY; the grant logic (`#gate` for REST, the + * attach resolver for WS) decides edit/view for ordinary panes exactly as + * before, and this predicate never narrows it. + * - An ssh pane admits input ONLY from its owner account, and NEVER from a + * bearer machine credential. The second half is why `bearerActor` is a + * separate input and not folded into the id comparison: raw REST resolves a + * pane's OWN subshell key as its owner (boost and shares off), so a bare + * `actorUserId === row.userId` would let an ssh pane type into itself — the + * exact slip decision 5 closes. A system key resolves through the human gate + * as the `system` service user (never the row's owner), so it is refused by + * the id test regardless; `bearerActor` makes the refusal explicit. + * + * The decision is keyed to the COLUMN'S PRESENCE (`subshells.ssh`, migration + * 0048), the pane's kind fact — not its content. Snapshot validation belongs + * to the launch boundary, not the input door. + * + * @param row - The subshell's kind + owner (a full `SubshellTable` fits) + * @param actorUserId - The account the caller resolves as (row's owner for a + * bearer subshell key on raw REST; the row's owner for a scoped WS token too, + * which is why `bearerActor` must carry the machine-ness) + * @param bearerActor - True for any machine credential: a `subshell-key` or + * `system-key` on REST, or a scoped WS token. False for a human (cookie + * session, or a cookie-minted unscoped WS token). + * @returns True when input may proceed on kind grounds; the caller has ALREADY + * applied its own grant/visibility gate + */ +export function paneInputAllowed(row: PaneInputRow, actorUserId: string, bearerActor: boolean): boolean { + // Ordinary pane: the ssh rule is silent, the existing grant logic governs. + if (row.ssh === null) return true; + // An ssh pane: owner account only, and never a bearer machine credential. + return !bearerActor && actorUserId === row.userId; +} diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 247ba163a..9cfcdaac1 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -20,6 +20,7 @@ import type { PresetTable } from "@/db/types/presets.db-types.js"; import type { SubshellSharePermission } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess, resolveSubshellAccess } from "@/lib/subshell-access.js"; import { BaseService, type CommonServiceParams } from "@/services/base.service.js"; import { publishLive } from "@/services/live-bus.js"; @@ -924,6 +925,29 @@ export class SubshellsService extends BaseService { return { ok: true }; } + /** + * The ssh owner-only carve-out on an input act, applied AFTER the visibility + * + grant gate (so a stranger still gets the ordinary 404, and a `view` + * grantee the ordinary 403). An ssh pane runs a real shell under the OWNER's + * ssh identity on the far machine, so its input is the owner's alone: an + * `edit` grantee who is not the owner is refused, and so is any bearer + * machine credential (a pane must not type into itself). Ordinary panes + * (`ssh === null`) pass through untouched. The rule lives in + * {@link paneInputAllowed}, shared with the WS keystroke door (spec + * 2026-10-07 §5.4, decision 5). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY, before anything is typed. + */ + #requireInputAuthority(row: SubshellTable, viewerId: string, actor: GuardActor): void { + // A bearer actor is a `subshell-key` OR a `system-key`; a human is `cookie`. + if (!paneInputAllowed(row, viewerId, actor !== "cookie")) { + throwApiError({ + code: BackendErrorCodes.SSH_OWNER_INPUT_ONLY, + message: "SSH panes accept input from their owner only", + doNotLog: true, + }); + } + } + /** * Types text into a RUNNING pane over REST (spec 2026-09-25 MCP DX), the * input the live attach socket already carries, given an HTTP door for @@ -960,6 +984,9 @@ export class SubshellsService extends BaseService { * nothing is typed into a row that is not there), and 409 * NODE_OFFLINE when its agent node has no live connection (the * create/restart mapper again). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY when the pane is an ssh pane and + * the caller is not its owner (or is a bearer credential), before the + * launcher is resolved and nothing is typed. */ async sendSubshellInput( viewerId: string, @@ -969,6 +996,7 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + this.#requireInputAuthority(row, viewerId, actor); // The TWO facts, both required — a lesson from the live incident // (2026-09-25): `status` is the lifecycle INTENT and a pane that exited // on its own parks at `status: "running"` with `alive: 0` (parked is what @@ -1019,7 +1047,9 @@ export class SubshellsService extends BaseService { * a terminal; 409 EXEC_IN_FLIGHT when another exec already holds the * pane's lease; 409 EXEC_PANE_BUSY when the quiet probe finds the * log growing. All four refuse before anything is typed, and any - * launcher refusal maps through the create/restart mapper. + * launcher refusal maps through the create/restart mapper. Exec on + * an ssh pane also carries the input rule: a non-owner 403s + * SSH_OWNER_INPUT_ONLY before even the first quiet probe (spec §5.4). */ async execInTerminal( viewerId: string, @@ -1029,6 +1059,10 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise { const { row } = await this.#gate(viewerId, id, "edit", actor); + // The ssh carve-out precedes the running/harness/quiet checks: exec TYPES, + // so it carries the input rule verbatim, and a non-owner never reaches the + // first quiet probe (spec 2026-10-07 §5.4). + this.#requireInputAuthority(row, viewerId, actor); if (row.status !== "running" || row.alive !== 1) { throwApiError({ code: BackendErrorCodes.SUBSHELL_NOT_RUNNING, diff --git a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts index a7cd9ccdb..df7e675bb 100644 --- a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts +++ b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts @@ -403,6 +403,78 @@ describe("resolveAttach", () => { }); }); +describe("resolveAttach canInput: the ssh owner-only keystroke rule (spec §5.4)", () => { + // The WS door is `handleSubshellMessage`'s `data.canInput`, and BOTH attach + // paths now carry the value `resolveAttach` computes here. So this is where + // the ssh carve-out is decided for keystrokes; the socket just obeys. A + // keystroke is DROPPED (no error frame) when it refuses, exactly as a `view` + // grantee's is — that is the WS posture, so `canInput` is the whole surface. + + /** A row whose `ssh` column is set (the kind fact, migration 0048). */ + async function seedSshRow(): Promise<{ id: string; userId: string }> { + const { repos } = getRequestlessContext(); + seq += 1; + const row = await repos.subshells.create({ + id: crypto.randomUUID(), + userId: `u-attach-resolve-${seq}`, + presetId: "p-test", + harnessId: "ssh", + name: "attach-ssh-input", + workingDir: "/tmp", + tmuxSocket: "subshell-attach-ssh", + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + return { id: row.id, userId: row.userId }; + } + + const canInput = async (query: string): Promise => { + const out = await resolveAttach(request(query)); + if (!out.ok) throw new Error(`attach refused: ${JSON.stringify(out)}`); + return out.canInput; + }; + + it("an ordinary pane + an edit grantee types (canInput unchanged from the grant)", async () => { + const row = await seedRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-editor-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(guest)}`)).toBe(true); + }); + + it("an ssh pane + the OWNER (unbound token) types", async () => { + const row = await seedSshRow(); + // `issueWsToken(userId)` with no subshell is a cookie-minted unbound + // identity: the human's own keystrokes, so bearerActor is false. + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(row.userId)}`)).toBe(true); + }); + + it("an ssh pane + an EDIT grantee is refused (access says edit, canInput says no)", async () => { + const row = await seedSshRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-blocked-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(guest)}`)); + expect(out.ok).toBe(true); + // The grant is genuinely `edit` — the refusal is the ssh kind, not access. + if (out.ok) { + expect(out.access).toBe("edit"); + expect(out.canInput).toBe(false); + } + }); + + it("an ssh pane + its OWN scoped key is refused (decision 5: no self-typing)", async () => { + const row = await seedSshRow(); + // A scoped token records the OWNER as its identity, so `access` resolves to + // owner; only `bearerActor` closes the self-typing door. + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(row.userId, row.id)}`)); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.access).toBe("owner"); + expect(out.canInput).toBe(false); + } + }); +}); + describe("resolveAttach clears the unseen push (spec 2026-09-23)", () => { const email = `attach-unseen-${crypto.randomUUID()}@subshell.local`; const pw = "attach-unseen-pass-1"; diff --git a/apps/server/api/src/ws/attach-resolve.ts b/apps/server/api/src/ws/attach-resolve.ts index 0bde440ba..90b6aeb56 100644 --- a/apps/server/api/src/ws/attach-resolve.ts +++ b/apps/server/api/src/ws/attach-resolve.ts @@ -15,6 +15,7 @@ import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; import { resolveCookieSession } from "@/lib/session-cookie.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; import { accountDisabled } from "@/services/account-status.js"; import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; @@ -47,6 +48,16 @@ export interface AttachResolved { row: SubshellTable; /** The caller's access to it — `view` watches, `edit`/`owner` may type. */ access: Access; + /** + * Whether this socket may TYPE — the ONE WS keystroke authority, decided + * here and carried to both attach paths (local + remote) so they stop each + * deriving it from `access` alone. `edit`/`owner` grant AND the ssh + * owner-only carve-out ({@link paneInputAllowed}): an ssh pane drops a + * non-owner's or a scoped (bearer-minted) token's keystrokes exactly as it + * drops a `view` grantee's (spec 2026-10-07 §5.4). For an ordinary pane + * this is exactly `accessAtLeast(access, "edit")`. + */ + canInput: boolean; /** What the client declared on its URL. */ params: AttachParams; /** @@ -179,6 +190,16 @@ export async function resolveAttach(input: AttachRequest): Promise { const { size, deviceLabel, hidden, wireMode } = params; if (!getLive(row.nodeId)) { @@ -157,8 +163,10 @@ export async function attachRemoteSubshellWs( // keys the plane→node input hold by it. nodeId: row.nodeId, logFile: "", - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `resolveAttach`'s one authority (edit/owner + // grant AND the ssh owner-only carve-out); the `access`-only fallback is the + // grant rule the relay's own fixtures attach a plain row with. + canInput: canInput ?? accessAtLeast(access, "edit"), capacity: size ?? undefined, hidden, // Presence identity: who this viewer is in the `viewers` frame. The id diff --git a/apps/server/api/src/ws/subshell-ws.ts b/apps/server/api/src/ws/subshell-ws.ts index 5d7003183..1d9549f08 100644 --- a/apps/server/api/src/ws/subshell-ws.ts +++ b/apps/server/api/src/ws/subshell-ws.ts @@ -1,7 +1,6 @@ import { parseClientFrame } from "@internal/subshell-protocol"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; -import { accessAtLeast } from "@/lib/subshell-access.js"; import { launcherFor } from "@/services/nodes/launcher-registry.js"; import { replayLineCap } from "@/services/nodes/log-tail.js"; import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; @@ -92,7 +91,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { ws.close(resolved.code, resolved.reason); return; } - const { row, access, params, attendsPush } = resolved; + const { row, access, canInput, params, attendsPush } = resolved; // Stash WHO authenticated onto the socket before either path continues — // the local `Object.assign` and the remote relay's both build their own // `data` literals, and this channel is what lets an account disable find @@ -115,7 +114,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // The WHOLE params struct, not a hand-picked few: this call site is where // `hidden` went missing for node panes, because it took each input as its // own positional argument and one of them was simply never added. - await attachRemoteSubshellWs(ws, row, launcher as RemoteLauncher, access, params); + await attachRemoteSubshellWs(ws, row, launcher as RemoteLauncher, access, params, canInput); return; } // Split out so the rest of this function sees a non-null socket, and so the @@ -157,8 +156,9 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // session's holds. nodeId: row.nodeId, logFile: subshellLogPath(row.id), - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `edit`/`owner` grant AND the ssh owner-only + // carve-out, both folded at the one authority (`resolveAttach`). + canInput, // This viewer's own capacity, from the connect URL. One input to the // shared decision below — never applied on its own. capacity: params.size ?? undefined, diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 5414e5700..07df9a5f5 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -217,6 +217,16 @@ export enum BackendErrorCodes { * again from the launcher instead. Keyed to the `subshells.ssh` column. */ SSH_NO_RESTART = "SSH_NO_RESTART", + /** + * A pane-input act (REST input, exec, MCP-via-REST, WS keystroke) on an + * ssh pane by a caller who is not the row's OWNER account, or by ANY + * machine credential (spec 2026-10-07 §5.4, decision 5: the far side of an + * ssh pane runs under the owner's own trust; the pane's key must not type + * into itself either). Keyed to the `subshells.ssh` column like the + * restart refusal; the WS door drops the frame rather than answering this + * code, exactly as it drops a `view` grantee's keystrokes. + */ + SSH_OWNER_INPUT_ONLY = "SSH_OWNER_INPUT_ONLY", } /** @@ -479,4 +489,8 @@ export const BackendErrorCodeDefs = { message: "SSH panes are not restarted; launch again to reconnect", statusCode: 409, }, + [BackendErrorCodes.SSH_OWNER_INPUT_ONLY]: { + message: "SSH panes accept input from their owner only", + statusCode: 403, + }, }; From 2fd8be47c3159aefcb629495f5a39e0edc249c6e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 21:39:07 -0700 Subject: [PATCH 035/170] fix(api): ssh relay input admission is required, not defaulted; channel nudge never targets an ssh pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the ssh launcher tier: - attachRemoteSubshellWs took `canInput` as an OPTIONAL last argument and fell back to `accessAtLeast(access, "edit")` - the pre-§5.4 grant-only rule. A future caller that omitted the argument would silently reopen the ssh owner-only relay door, exactly the trap the file's own AttachParams comment records for `hidden`. The argument is now a REQUIRED positional (`canInput: boolean`), the fallback is gone, and the `access` slot stays (underscored: deliberately unread) as the relay's positional contract with its one production caller. Test call sites pass the truthful grant value (`accessAtLeast(access, "edit")`: true for owner/edit fixtures, false for view) and a new relay case pins the CONSUMPTION (edit access + canInput:false types nothing) beside Task 8's attach-resolve pins of the COMPUTATION. - the channel nudge loop typed its fixed cue into RUNNING recipient panes with a tmux socket, which includes ssh panes: raw shells under the owner's ssh identity on a far host, where input is the owner's alone (spec §5.4) and no agent would read the line. The skip guard now also excludes `row.ssh !== null`; the new channels-route case posts to a live ssh pane and a plain pane in one breath and asserts only the plain one is touched (red-check: the test fails without the guard). --- .../channels/__tests__/channels-route.test.ts | 104 ++++++++++++++++++ .../api/src/services/channels.service.ts | 7 +- .../ws/__tests__/remote-subshell-ws.test.ts | 84 +++++++++++--- apps/server/api/src/ws/remote-subshell-ws.ts | 28 +++-- 4 files changed, 195 insertions(+), 28 deletions(-) diff --git a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts index 7734f9f83..5a109b055 100644 --- a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts +++ b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts @@ -442,4 +442,108 @@ describe("channels route", () => { setNudgeTransportForTests(null); } }); + + it("nudge NEVER types into an ssh pane (raw remote shell, owner-only input, spec §5.4)", async () => { + nudges.length = 0; + class FakeTmux extends TmuxRunner { + override async sendInput(socket: string, subshellName: string, input: string): Promise { + nudges.push({ socket, name: subshellName, text: input }); + } + } + setNudgeTransportForTests(new FakeTmux()); + try { + await registerIdentity(aliceToken); + await call(channelRoutes, "/api/channels", aliceToken, postJson({ name: "sshwake" })); + + // A RUNNING ssh-pane row: alive, with a tmux socket, snapshot column + // set — every fact the plain nudge guard reads passes; only `ssh` + // distinguishes it. (A tmux-less or dead row would be skipped for the + // ordinary reasons and prove nothing about the §5.4 carve-out.) + async function sshPane(id: string): Promise { + createdSubshells.push(id); + await new SubshellsRepository(db).create({ + id, + userId: bob, + presetId: null, + harnessId: "ssh", + name: "s", + workingDir: "/tmp", + tmuxSocket: `sock-${id}`, + alive: 1, + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + const key = await issueSubshellToken(id, bob); + const { publicJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: publicJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${key}` }, + }), + ); + } + + const sshId = crypto.randomUUID(); + const plainId = crypto.randomUUID(); + await sshPane(sshId); + // A plain running recipient pane posted to in the SAME breath: it must + // still be nudged (the ssh skip is targeted, not a blanket stop). + createdSubshells.push(plainId); + await new SubshellsRepository(db).create({ + id: plainId, + userId: bob, + presetId: null, + harnessId: "claude-code", + name: "p", + workingDir: "/tmp", + tmuxSocket: `sock-${plainId}`, + alive: 1, + }); + const plainKey = await issueSubshellToken(plainId, bob); + const { publicJwk: plainJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: plainJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}` }, + }), + ); + + await call( + channelRoutes, + "/api/channels/sshwake/posts", + aliceToken, + postJson({ + envelope: envelope("go", [`sess:${sshId}`, `sess:${plainId}`]), + recipientIds: [`sess:${sshId}`, `sess:${plainId}`], + nudge: true, + }), + ); + + // The ssh pane receives NOTHING (typing the cue into a raw shell under + // the owner's ssh identity is input, and input there is owner-only); + // the plain pane receives its usual inert line. + expect(nudges.map((n) => n.name)).toEqual([plainId]); + expect(nudges[0].text).toContain("#sshwake"); + + for (const n of [sshId, plainId]) { + const row = await new SubshellsRepository(db).findById(n); + if (row?.apiKeyId) authDatabase().run("DELETE FROM apikey WHERE id = ?", [row.apiKeyId]); + } + } finally { + setNudgeTransportForTests(null); + } + }); }); diff --git a/apps/server/api/src/services/channels.service.ts b/apps/server/api/src/services/channels.service.ts index 3133c505e..80f725275 100644 --- a/apps/server/api/src/services/channels.service.ts +++ b/apps/server/api/src/services/channels.service.ts @@ -219,12 +219,15 @@ export class ChannelsService extends BaseService { // waiting at its prompt is WOKEN — the line names the tool and is // submitted, so the agent reads the post without being told to poll. // A mid-turn pane gets the old Enter-less cue (submitting into a busy - // harness corrupts the turn). Never peer content in either line. + // harness corrupts the turn). Never peer content in either line. An + // SSH pane is NEVER nudged: it is a raw shell under the owner's ssh + // identity that takes input from the owner alone (spec §5.4), and no + // agent sits behind it to read the cue. const subshells = this.repos.subshells; for (const recipientId of new Set(recipientIds)) { if (!recipientId.startsWith("sess:") || recipientId === principal) continue; const row = await subshells.findById(recipientId.slice("sess:".length)); - if (row?.alive !== 1 || !row.tmuxSocket) continue; + if (row?.alive !== 1 || !row.tmuxSocket || row.ssh !== null) continue; const waiting = row.waitingSince != null; const line = waiting ? `[#${channel.name}] subshell peer post, read it: call read_channel("${channel.name}")` diff --git a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts index ed163bd5d..778fb664f 100644 --- a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts @@ -231,7 +231,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail_start on the wire"); // Command ORDER through the real sendCommand: liveness, the tail's JOIN @@ -284,7 +284,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("log_read", { bytes_b64: "", next: 0, size: 0 }); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed at offset 0"); // Terminal frames only: the socket also carries `viewers` presence now. @@ -310,7 +310,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { // still starts at EOF (12) — history ships inside the capture, never as // re-played log bytes. await setOwnerCap(2); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail at EOF"); expect(sim.cmdsOf("capture")).toEqual([{ type: "capture", subshellId: SID, lines: 2 }]); expect((sim.cmdsOf("tail_start")[0] as { fromByte: number }).fromByte).toBe(12); @@ -324,7 +324,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", "SCREEN"); scriptLogRead(sim, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(9999); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdsOf("capture").length === 1, "capture (cap 200)"); expect((sim.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(200); @@ -334,7 +341,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim2.answer("capture", "SCREEN"); scriptLogRead(sim2, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(0); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim2.cmdsOf("capture").length === 1, "capture (cap 1)"); expect((sim2.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(1); }); @@ -357,6 +371,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Resize lands ahead of the capture so the replay matches the client @@ -405,6 +420,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); // Exactly ONE resize — the fit. The nudge would add 133-then-back. @@ -426,6 +442,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); expect(sim.cmdsOf("resize")).toEqual([{ type: "resize", subshellId: SID, cols: 132, rows: 43 }]); @@ -446,7 +463,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("pane_cursor", { x: 4, y: 2 }); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sent.length > 0, "replay"); // tmux's 0-based (4,2) becomes the 1-based CUP ESC[3;5H, and the replay // frame — the browser's first paint — must end with it. @@ -457,7 +474,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { it("refuses 4004 'node offline' when the node has no live connection (nothing hits the wire)", async () => { const { ws, sent, closed } = fakeBrowser(); // No attachConnection for NODE_ID — the registry says offline. - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "node offline" }]); expect(sent).toEqual([]); }); @@ -467,7 +484,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("probe", [{ subshellId: SID, alive: false, exitCode: 1 }]); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); expect(sim.cmdTypes()).toEqual(["probe"]); // no capture, no reads, no tail @@ -480,7 +497,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", fail("can't find session: pane gone")); // tmux stderr, verbatim const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); // The pump is armed before the capture (join-point rule), so a refusal @@ -497,7 +514,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent, closed } = fakeBrowser(() => 4 * 1024 * 1024 + 1); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Terminal frames only: the socket also carries `viewers` presence now. expect(sent.filter((f) => !f.includes('"type":"viewers"'))).toEqual([ @@ -522,7 +539,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "ls\r" })); @@ -539,7 +556,29 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams(), false); + await until(() => sim.cmdTypes().includes("tail_start"), "attached"); + + handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); + handleSubshellMessage(ws, JSON.stringify({ type: "resize", cols: 80, rows: 24 })); + await until(() => sim.cmdsOf("resize").length === 1, "resize through"); + expect(sim.cmdsOf("input")).toEqual([]); + }); + + it("canInput:false at edit access types NOTHING (the ssh carve-out arrives as the required argument, not a grant re-derivation)", async () => { + // The §5.4 posture split: `access` still says `edit` for an ssh pane's + // grantee (they may WATCH, and would type on an ordinary pane); the ONE + // fact that stops them is `canInput:false`, computed by `resolveAttach` + // and passed here. The relay must not read the grant to decide input — + // a fallback that did would reopen the ssh relay door to exactly this + // caller. Task 8's `attach-resolve.test.ts` ("an ssh pane + an EDIT + // grantee is refused (access says edit, canInput says no)") pins the + // COMPUTATION; this pins the CONSUMPTION. + const sim = makeNodeSim(); + scriptHappy(sim); + const { ws } = fakeBrowser(); + + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), false); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); @@ -553,7 +592,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); cleanupSubshellWs(ws); @@ -595,7 +634,14 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle ); const { ws } = fakeBrowser(); - const attaching = attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + const attaching = attachRemoteSubshellWs( + ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdTypes().includes("capture"), "parked in the capture"); cleanupSubshellWs(ws); // browser vanished mid-attach release?.("SCREEN"); @@ -637,6 +683,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); // The laptop alone: the pane holds ITS size. expect(sim.cmdsOf("resize").at(-1)).toEqual({ type: "resize", subshellId: SID, cols: 120, rows: 40 }); @@ -648,6 +695,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 60, rows: 20 } }), + true, ); // The joiner's attach really did drive the pane... @@ -683,6 +731,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); const geometry = sent @@ -716,6 +765,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); await attachRemoteSubshellWs( pocketed.ws, @@ -723,6 +773,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 }, deviceLabel: "Phone", hidden: true }), + true, ); // The hidden joiner takes no part: the pane stays at the laptop's size. @@ -754,6 +805,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 } }), + true, ); cleanupSubshellWs(ws); // browser gone before the attach assigned anything await attaching; @@ -779,9 +831,9 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => const first = fakeBrowser(); const second = fakeBrowser(); - await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "first tail"); - await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(sim.cmdTypes().filter((t) => t === "tail_start")).toHaveLength(1); diff --git a/apps/server/api/src/ws/remote-subshell-ws.ts b/apps/server/api/src/ws/remote-subshell-ws.ts index 9a1f48ebf..a78c0d4a5 100644 --- a/apps/server/api/src/ws/remote-subshell-ws.ts +++ b/apps/server/api/src/ws/remote-subshell-ws.ts @@ -1,5 +1,5 @@ import { getRequestlessContext } from "@/lib/context.js"; -import { type Access, accessAtLeast } from "@/lib/subshell-access.js"; +import type { Access } from "@/lib/subshell-access.js"; import { replayLineCap } from "@/services/nodes/log-tail.js"; import { getLive } from "@/services/nodes/node-registry.js"; import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; @@ -104,21 +104,27 @@ const CLIENT_LAG_LIMIT_BYTES = 4 * 1024 * 1024; * @param ws - the browser socket (Elysia WS, narrowed to {@link WsSocket}) * @param row - the subshell row (must name a non-local node) * @param launcher - the node's cached {@link RemoteLauncher} (registry-resolved) - * @param access - the caller's effective access; only `edit`/`owner` may type + * @param _access - the caller's effective access, from {@link resolveAttach}. + * The relay decides nothing input from it any more (§5.4 made that + * `canInput`'s alone), so it is deliberately unread - underscore-prefixed for + * the same reason the local twin's fixtures name their unused contexts; it + * stays in the signature because the attach's one authority is handed back + * beside it and this is the relay's positional contract with the caller. * @param size - the client's fitted geometry; when present the pane is * resized (and given {@link RESIZE_SETTLE_MS} to repaint) BEFORE the * capture, so the replay matches the geometry the client renders into * @param canInput - the relay's keystroke authority, computed ONCE by * {@link resolveAttach} (the `edit`/`owner` grant AND the ssh owner-only - * carve-out, spec §5.4). Production always passes it; the `access`-only - * fallback exists for the relay's own test fixtures (which attach a plain - * row and expect the grant to decide), and is exactly the pre-§5.4 rule. + * carve-out, spec §5.4). REQUIRED, with no fallback: an omission must be a + * type error, not a grant-only default — the same doctrine the `params` + * struct below records for `hidden`, and the reason a caller that forgot + * the argument cannot silently reopen the ssh relay door to `edit` grantees. */ export async function attachRemoteSubshellWs( ws: WsSocket, row: RemoteAttachRow, launcher: RemoteLauncher, - access: Access, + _access: Access, /** * Everything the client declared on the connect URL, as ONE value. * @@ -129,7 +135,7 @@ export async function attachRemoteSubshellWs( * struct makes the next omission a type error instead. */ params: AttachParams, - canInput?: boolean, + canInput: boolean, ): Promise { const { size, deviceLabel, hidden, wireMode } = params; if (!getLive(row.nodeId)) { @@ -164,9 +170,11 @@ export async function attachRemoteSubshellWs( nodeId: row.nodeId, logFile: "", // Whether THIS socket may type: `resolveAttach`'s one authority (edit/owner - // grant AND the ssh owner-only carve-out); the `access`-only fallback is the - // grant rule the relay's own fixtures attach a plain row with. - canInput: canInput ?? accessAtLeast(access, "edit"), + // grant AND the ssh owner-only carve-out, spec §5.4), taken verbatim. No + // `access`-derived fallback: that pre-§5.4 rule would silently reopen the + // ssh relay door to a caller that omitted the argument, so the argument is + // required and an omission is a type error. + canInput, capacity: size ?? undefined, hidden, // Presence identity: who this viewer is in the `viewers` frame. The id From 324b2c7d84d2590185c1c70ef954e368eb8bcaea Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:01:32 -0700 Subject: [PATCH 036/170] fix(errors): stripAnsi ends an OSC at ST as well as BEL, so shell-integration output cannot swallow a line --- .../src/__tests__/strip-ansi.test.ts | 14 ++++++++++++++ packages/backend-errors/src/strip-ansi.ts | 14 +++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/backend-errors/src/__tests__/strip-ansi.test.ts b/packages/backend-errors/src/__tests__/strip-ansi.test.ts index b96ffef88..8d024662e 100644 --- a/packages/backend-errors/src/__tests__/strip-ansi.test.ts +++ b/packages/backend-errors/src/__tests__/strip-ansi.test.ts @@ -14,6 +14,20 @@ describe("stripAnsi", () => { expect(stripAnsi("\x1b]0;window title\x07body")).toBe("body"); }); + it("strips OSC sequences terminated by ST (ESC backslash), keeping the body", () => { + // Shell-integration output (e.g. OSC 3008/133) terminates with ST, not + // BEL. Measured in spec 22's pane: a BEL-only regex ran past the ST + // terminator and swallowed every byte up to the NEXT BEL, deleting the + // line the command had just printed. + expect(stripAnsi("\x1b]3008;start=abc;type=command\x1b\\SSH-PANE-2-OK\r\n\x1b]0;~\x07prompt")).toBe( + "SSH-PANE-2-OK\nprompt", + ); + }); + + it("leaves an unterminated OSC (no BEL, no ST) untouched rather than swallowing the tail", () => { + expect(stripAnsi("\x1b]66;never ended")).toBe("\x1b]66;never ended"); + }); + it("strips carriage returns", () => { expect(stripAnsi("line1\r\nline2")).toBe("line1\nline2"); }); diff --git a/packages/backend-errors/src/strip-ansi.ts b/packages/backend-errors/src/strip-ansi.ts index 10ae7a2f9..5fa7c1d4f 100644 --- a/packages/backend-errors/src/strip-ansi.ts +++ b/packages/backend-errors/src/strip-ansi.ts @@ -19,9 +19,17 @@ */ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI CSI escape sequences const csiRegex = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g; -/** OSC title sequences (BEL-terminated). */ -// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC title sequences (BEL-terminated) -const oscRegex = /\x1b\][^\x07]*\x07/g; +/** + * OSC strings, terminated either way: BEL (xterm's titles) or ST = `ESC \` + * (ECMA-48's terminator, what shell-integration output like OSC 133/3008 + * emits). A BEL-only scan ran PAST an ST terminator and swallowed every byte + * up to the next BEL — measured deleting a whole printed line in spec 22's + * ssh pane. Lazy matching ends the sequence at the nearest terminator, and + * one with neither stays untouched: a swallowed tail is worse than a stray + * escape. + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC terminators (BEL and ST) +const oscRegex = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g; export function stripAnsi(s: string): string { return s.replace(csiRegex, "").replace(oscRegex, "").replace(/\r/g, ""); From 79e9fe2effcfa1f09f30cacc6ff33c0da5f2688b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:01:34 -0700 Subject: [PATCH 037/170] test(e2e): ssh pane end to end - gated launch, owner input typed over the wire, sharee refused, files cleaned --- e2e/AGENTS.md | 11 +- e2e/ports.ts | 10 + e2e/tests/22-ssh-terminal.spec.ts | 462 ++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 e2e/tests/22-ssh-terminal.spec.ts diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 6349bff93..5889afbf0 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -50,9 +50,14 @@ its providers via the admin cookie at `POST /api/auth-providers` (whose save gat runs discovery against this fake), and no spec ever dials a real IdP. Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`, -`E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`) with the -committed defaults unchanged, the escape hatch for two suites running side by -side on one machine, which the defaults collide on. +`E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`, +`E2E_PORT_SSH`) with the committed defaults unchanged, the escape hatch for +two suites running side by side on one machine, which the defaults collide on. + +Spec `22` boots its own backend too (port 3201) for a reason the shared stack +cannot serve: the ssh tier reads the connecting account's HOME and bun caches +`os.homedir()` per process, so that child is SPAWNED with `HOME=` +plus the spec's `ssh-agent` socket, on its own temp DB/data dir/tmux base. Spec `12` extends the stack itself: it spawns the **real `subshell` from source** (`bun apps/node/agent/src/main.ts enroll|run` via `stub/client.ts`, with diff --git a/e2e/ports.ts b/e2e/ports.ts index c4a21fcad..07c7451f3 100644 --- a/e2e/ports.ts +++ b/e2e/ports.ts @@ -26,6 +26,15 @@ * never repeat there. A clean-machine claim needs a clean instance, and this * port is where spec 15 boots one — its own temp DB, its own data dir, and * every agent-CLI override pointed at a file that does not exist. + * + * `ssh` belongs to spec 22's SECOND backend for the same shared-DB reason, + * plus one that is its own: the ssh tier reads the connecting account's HOME + * (its `~/.ssh/config` is the alias source and `ssh -G` runs as that account), + * and bun caches `os.homedir()` per process — so that backend must be SPAWNED + * with `HOME=` in its env, which the shared stack can never + * carry for the other specs. Its own temp DB, its own data dir (whose + * `ssh/` dir the spec asserts on through the filesystem), and its own + * tmux base. */ const envPort = (name: string, fallback: number): number => { const raw = process.env[name]; @@ -42,6 +51,7 @@ export const PORTS = { fakeRegistry: envPort("E2E_PORT_FAKE_REGISTRY", 3198), fakeIdp: envPort("E2E_PORT_FAKE_IDP", 3197), onboarding: envPort("E2E_PORT_ONBOARDING", 3200), + ssh: envPort("E2E_PORT_SSH", 3201), } as const; export const BASE_URL = `http://127.0.0.1:${PORTS.backend}`; diff --git a/e2e/tests/22-ssh-terminal.spec.ts b/e2e/tests/22-ssh-terminal.spec.ts new file mode 100644 index 000000000..bdaf4e7b3 --- /dev/null +++ b/e2e/tests/22-ssh-terminal.spec.ts @@ -0,0 +1,462 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { type APIRequestContext, expect, type PlaywrightWorkerArgs, test } from "@playwright/test"; +import { missingSshBins, SSH_FIXTURE_ALIAS, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { PORTS } from "../ports"; +import { shortTmuxBase } from "../stack"; + +/** + * Milestone 1's proof (spec 2026-10-07 §14, plan 2 Task 9): a gated SSH pane, + * end to end over REAL HTTP and a REAL sshd. No mocks anywhere on the path — + * the fixture daemon (fixtures/sshd.ts) answers the connection, the backend + * (this spec's own child, HOME pointed at the fixture account) resolves the + * alias with a real `ssh -G`, renders a real config onto disk, and the pane + * IS a real ssh session whose typed line is echoed by a real remote shell and + * read back through the pane-log route. + * + * Why its own backend (the ports.ts `ssh` comment): bun caches `os.homedir()` + * per process, and the ssh tier reads the connecting account's HOME for both + * discovery and resolution — so the backend must be SPAWNED with + * `HOME=`. The shared stack cannot carry that for the other + * specs, and mutating THIS process's HOME would not reach the server anyway. + * + * Why the assertions land where they do: + * - owner input types `echo SSH-PANE-$((1+1))-OK`; only the REMOTE SHELL's + * evaluation prints `SSH-PANE-2-OK`, so the log match proves the round trip + * (the typed line itself never spells the result), + * - the sharee refusal is a CODE assertion (403 `SSH_OWNER_INPUT_ONLY`) and + * the sharee's own text must be ABSENT from the log ("before anything is + * typed" is a promise the route makes), + * - the config-dir cleanup is checked through the FILESYSTEM: the backend is + * a child process, but the spec passed it the data dir and both run on this + * host, so `/ssh/` is directly observable here. + * + * Binaries: ssh / ssh-keygen / sshd must run unprivileged here. The fixture's + * own gate (`missingSshBins`) decides; a host without them SKIPS LOUDLY (the + * skip names the missing binaries), it never fails and never fakes a pass. + * CI's builder image has openssh-server installed for exactly this spec. + */ + +const ROOT = path.join(import.meta.dirname, "..", ".."); +const BACKEND_DIR = path.join(ROOT, "apps", "server", "api"); +const ORIGIN = `http://127.0.0.1:${PORTS.ssh}`; + +/** Boot + sshd + agent + a real tmux ssh pane: budgets on spec 12's scale. */ +const READY_TIMEOUT = 60_000; +const SPAWN_TIMEOUT = 60_000; + +const missing = missingSshBins(); +test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join(", ")}) - spec 22 needs a real sshd`); + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** Polls `check` until true or the deadline; on timeout throws the label's current text. */ +async function pollUntil( + label: string | (() => string), + check: () => Promise | boolean, + timeoutMs = SPAWN_TIMEOUT, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return; + if (Date.now() > deadline) throw new Error(typeof label === "string" ? label : label()); + await sleep(500); + } +} + +interface SshInstance { + child: ReturnType; + /** The scratch root: config dir, temp DB, and the data dir whose `ssh/` the spec watches. */ + dir: string; + dataDir: string; + tmuxBase: string; +} + +/** Boots the spec's OWN backend on `PORTS.ssh`, with HOME + SSH_AUTH_SOCK in ITS env (see the header). */ +async function startSshBackend(fixtureHome: string, sshAuthSock: string): Promise { + // Same orphan guard as spec 15: afterAll-like teardowns run per attempt, so + // a dead retry's detached backend would hold :3201 for the next attempt. + stopSshBackend(); + const dir = mkdtempSync(path.join(tmpdir(), "subshell-e2e-ssh-")); + const dataDir = path.join(dir, "data"); + const tmuxBase = shortTmuxBase(); + mkdirSync(tmuxBase, { recursive: true }); + + const env: Record = { + ...process.env, + NODE_ENV: "development", + SUBSHELL_TEST_MODE: "false", + SUBSHELL_SERVER_CONFIG_DIR: dir, + SERVER_PORT: String(PORTS.ssh), + HOST: "127.0.0.1", + DATABASE_PATH: path.join(dir, "subshell.db"), + SUBSHELL_SERVER_DATA_DIR: dataDir, + APP_BASE_URL: ORIGIN, + BETTER_AUTH_SECRET: "e2e-secret-not-used-outside-tests-0000000000", + SUBSHELL_PLUGIN_REGISTRY_URL: `http://127.0.0.1:${PORTS.fakeRegistry}`, + SUBSHELL_RELEASE_URL: "", + TMUX_TMPDIR: tmuxBase, + // The two the ssh tier reads: HOME is the alias/config/known_hosts source + // (discovery and `ssh -G` run as this account), SSH_AUTH_SOCK is what the + // resolve may answer as the snapshot's agent socket. + HOME: fixtureHome, + SSH_AUTH_SOCK: sshAuthSock, + }; + // A developer inside tmux must not hand the backend their session's pane + // (spec 15's rule, same failure mode). + delete env.TMUX; + delete env.TMUX_PANE; + + const child = spawn("bun", ["run", "src/index.ts"], { + cwd: BACKEND_DIR, + detached: true, + stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore", + env, + }); + child.unref(); + const inst: SshInstance = { child, dir, dataDir, tmuxBase }; + instance = inst; + + const deadline = Date.now() + READY_TIMEOUT; + for (;;) { + try { + const res = await fetch(`${ORIGIN}/api/setup/status`); + if (res.ok) { + const body = (await res.json()) as { needsSetup: boolean }; + if (!body.needsSetup) throw new Error("[e2e] ssh instance: port already set up (leaked run)"); + return inst; + } + } catch (err) { + if ((err as Error).message.includes("port already set up")) throw err as Error; + // Not listening yet. + } + if (Date.now() > deadline) throw new Error(`[e2e] ssh instance did not become ready at ${ORIGIN}`); + await sleep(300); + } +} + +/** The instance this attempt started (module-scope so the cleanup paths reach it across retries). */ +let instance: SshInstance | undefined; + +function stopSshBackend(): void { + const inst = instance; + if (!inst) return; + instance = undefined; + try { + if (inst.child.pid) process.kill(-inst.child.pid, "SIGTERM"); + } catch { + // Already gone. + } + // The ssh pane's tmux server detached away from the backend's process group + // (the stack.ts lesson), so kill by socket, then wipe the scratch dirs. + for (const uidDir of readdirSafe(inst.tmuxBase)) { + for (const socket of readdirSafe(path.join(inst.tmuxBase, uidDir))) { + try { + spawnSync("tmux", ["-S", path.join(inst.tmuxBase, uidDir, socket), "kill-server"]); + } catch { + // Best-effort per socket. + } + } + } + rmSync(inst.dir, { recursive: true, force: true }); + rmSync(path.dirname(inst.tmuxBase), { recursive: true, force: true }); +} + +function readdirSafe(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** Mode without the type bits (what the 0600/0700 assertions read). */ +function modeOf(p: string): number { + return statSync(p).mode & 0o777; +} + +const ADMIN = { name: "SSH Admin", email: "ssh-admin@subshell.test", password: "e2e-ssh-admin-pass-1" } as const; +const MEMBER_PASSWORD = "e2e-ssh-member-pass-1"; + +/** + * A fresh cookieless API context (spec 07's rule: a cookie-carrying /api/auth + * call without an Origin is a CSRF 403; the first sign-in passes the guard and + * seeds the jar, so each principal acts as itself). + */ +function freshCtx(playwright: PlaywrightWorkerArgs["playwright"]): Promise { + return playwright.request.newContext({ baseURL: ORIGIN, storageState: { cookies: [], origins: [] } }); +} + +test("ssh pane: gated launch to the fixture sshd, owner input echoes from the remote shell, an edit sharee is refused, the config dir is swept", async ({ + playwright, +}) => { + test.setTimeout(300_000); + const nonce = test.info().retry; + + const leaks: string[] = []; + let fixture: SshFixture | undefined; + let fixtureRoot: string | undefined; + let agentPid: number | undefined; + // The refs the finally reaches for; the consts inside the try are what the + // story calls (a let loses its non-undefined narrowing across the poll + // closures, a const does not). + let adminRef: APIRequestContext | undefined; + let memberRef: APIRequestContext | undefined; + let subshellId: string | undefined; + + // Trackers first: every allocation below is reclaimed by the finally even + // when the story fails halfway (spec 12's no-leaves posture). + try { + // ── The fixture sshd + its keys + the alias HOME it writes under the root. + fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-sshtier-")); + fixture = await startSshFixture(fixtureRoot); + + // ── An ssh-agent holding the fixture's trusted key (plan-2 handoff): the + // backend's resolve reads SSH_AUTH_SOCK from the SERVER process env. The + // fixture's alias pins `IdentityAgent none`, so the pane dials with the + // IdentityFile key and the agent is the env fact the resolve sees, not the + // thing that authenticates - the agent-socket snapshot itself is pinned by + // the service's unit tests. + const agentOut = spawnSync("ssh-agent", ["-s"], { encoding: "utf8" }); + if (agentOut.status !== 0) throw new Error(`ssh-agent failed: ${agentOut.stderr}`); + const agentEnv = Object.fromEntries( + ["SSH_AUTH_SOCK", "SSH_AGENT_PID"].map((k) => [k, agentOut.stdout.match(new RegExp(`${k}=([^;]+)`))?.[1] ?? ""]), + ); + agentPid = Number(agentEnv.SSH_AGENT_PID); + if (!agentEnv.SSH_AUTH_SOCK || !Number.isInteger(agentPid) || agentPid <= 0) { + throw new Error(`ssh-agent output unparseable: ${agentOut.stdout.slice(0, 200)}`); + } + const add = spawnSync("ssh-add", [fixture.trustedKeyPath], { + env: { ...process.env, SSH_AUTH_SOCK: agentEnv.SSH_AUTH_SOCK }, + encoding: "utf8", + }); + if (add.status !== 0) throw new Error(`ssh-add failed: ${add.stderr}`); + + // ── The backend, spawned with the fixture HOME + the agent socket in ITS env. + const inst = await startSshBackend(fixture.sshConfigHome, agentEnv.SSH_AUTH_SOCK); + + // ── 1. Admin: the first registered account becomes admin (spec 01's rule + // on this fresh instance), through the same endpoint the wizard's first + // step posts. A cookieless context: the sign-up itself seeds the jar. + adminRef = await freshCtx(playwright); + const admin = adminRef; + const signUp = await admin.post("/api/auth/sign-up/email", { data: ADMIN }); + expect(signUp.ok(), await signUp.text()).toBe(true); + + // ── 2. The gate: SSH is OFF by default - discovery refuses before the + // switch, naming the one code for both gate causes. + const gated = await admin.get("/api/ssh/aliases?node=local"); + expect(gated.status(), await gated.text()).toBe(403); + expect(((await gated.json()) as { code: string }).code).toBe("SSH_GATE_OFF"); + + const enable = await admin.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enable.ok(), await enable.text()).toBe(true); + expect(((await enable.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + + // ── 3. Discovery: the fixture's alias is the machine's own answer. + const aliases = await admin.get("/api/ssh/aliases?node=local"); + expect(aliases.ok(), await aliases.text()).toBe(true); + const { aliases: names } = (await aliases.json()) as { aliases: string[] }; + expect(names).toContain(SSH_FIXTURE_ALIAS); + + // ── 4. The launch: gate -> resolve (a real `ssh -G` as the fixture HOME) + // -> compose -> create. 201 carries the pane view; its id is the config + // dir's name. + const launch = await admin.post("/api/ssh/launch", { + data: { node: "local", destination: SSH_FIXTURE_ALIAS, name: `e2e-ssh-pane-${nonce}` }, + }); + expect(launch.status(), await launch.text()).toBe(201); + subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id; + expect(subshellId).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + // The pane is genuinely alive: the ssh process is the pane, so alive is + // the sshd handshake and the remote shell, not just a spawned tmux. + await pollUntil( + `ssh pane ${subshellId} never reached running+alive`, + async () => { + const res = await admin.get(`/api/subshells/${subshellId}`); + if (!res.ok()) return false; + const view = (await res.json()) as { status: string; alive: boolean }; + return view.status === "running" && view.alive === true; + }, + READY_TIMEOUT, + ); + + // The rendered config, byte-checked where it lives (decision 4's path, + // 0600 in a 0700 dir, and the M1 posture's own lines). + const sshDir = path.join(inst.dataDir, "ssh", subshellId); + const configPath = path.join(sshDir, "config"); + await pollUntil(`the rendered ssh config never appeared at ${configPath}`, () => existsSync(configPath)); + expect(modeOf(sshDir), "ssh pane dir").toBe(0o700); + expect(modeOf(configPath), "ssh pane config").toBe(0o600); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("StrictHostKeyChecking accept-new"); + expect(config).toContain(path.join(fixture.sshConfigHome, ".ssh", "known_hosts")); + expect(config).toContain(fixture.trustedKeyPath); + + // ── 5. Owner input, typed through the ordinary REST input seam, answered + // by the REMOTE shell: only bash's arithmetic prints the 2, so a match of + // SSH-PANE-2-OK proves bytes travelled both ways through the real ssh. + // + // Typed only once the DAEMON has started the shell, not when the row says + // alive: alive is stamped at create, and the pane's pty is still in the + // pre-raw line discipline during the ssh handshake, which swallows the + // Enter (observed: the text reached the remote prompt, the CR never did). + // "Starting session: shell" is sshd's own LogLevel-VERBOSE line - the + // fixture's truth source, environment-independent. The settle beat covers + // the remote shell's init before its line editor exists. + const daemonLog = fixture.logPath; + await pollUntil( + () => + `sshd never reported a shell for ${subshellId}; its log ends: ${readFileSync(daemonLog, "utf8").slice(-800)}`, + () => /Starting session: shell/.test(readFileSync(daemonLog, "utf8")), + READY_TIMEOUT, + ); + await sleep(1_500); + const typed = "echo SSH-PANE-$((1+1))-OK"; + const input = await admin.post(`/api/subshells/${subshellId}/input`, { data: { text: typed } }); + expect(input.ok(), await input.text()).toBe(true); + let lastLogView = ""; + await pollUntil( + () => + `the pane log never carried the remote shell's evaluated marker; last read: ${JSON.stringify(lastLogView.slice(-1200))}`, + async () => { + const res = await admin.get(`/api/subshells/${subshellId}/log`); + if (!res.ok()) { + lastLogView = `HTTP ${res.status()} ${(await res.text()).slice(0, 300)}`; + return false; + } + lastLogView = ((await res.json()) as { lines: string[] }).lines.join("\n"); + return lastLogView.includes("SSH-PANE-2-OK"); + }, + READY_TIMEOUT, + ).catch((err: Error) => { + // Failure-only diagnostics: the RAW pipe-pane file (the route's strip may + // differ from what the pane wrote) and the daemon's own log. Both live + // on paths this spec owns; the happy path never pays for them. + let raw = ""; + try { + raw = readFileSync(path.join(inst.dataDir, "subshells", `${subshellId}.log`), "utf8").slice(-3000); + } catch (e) { + raw = String(e); + } + let daemon = ""; + try { + daemon = fixture === undefined ? "(no fixture)" : readFileSync(fixture.logPath, "utf8").slice(-1500); + } catch (e) { + daemon = String(e); + } + throw new Error(`${err.message}\n--- raw log tail ---\n${raw}\n--- sshd log tail ---\n${daemon}`); + }); + + // ── 6. The owner-only input carve-out (spec §5.4): a second account with + // the ordinary EVERYONE `edit` grant may act on a plain pane, and must + // still be refused on an ssh pane - before anything is typed. + const memberEmail = `ssh-member-${nonce}@subshell.test`; + const created = await admin.post("/api/users", { + data: { email: memberEmail, name: memberEmail, password: MEMBER_PASSWORD, role: "user" }, + }); + expect(created.ok(), await created.text()).toBe(true); + const shared = await admin.put(`/api/subshells/${subshellId}/shares`, { + data: { shares: [{ granteeUserId: null, permission: "edit" }] }, + }); + expect(shared.ok(), await shared.text()).toBe(true); + + memberRef = await freshCtx(playwright); + const member = memberRef; + const memberSignIn = await member.post("/api/auth/sign-in/email", { + data: { email: memberEmail, password: MEMBER_PASSWORD }, + }); + expect(memberSignIn.ok(), await memberSignIn.text()).toBe(true); + // The grant is real: the member sees the pane at `edit` (the refusal below + // must be the SSH rule, not an invisibility or a missing grant). + const memberView = await member.get(`/api/subshells/${subshellId}`); + expect(memberView.ok(), await memberView.text()).toBe(true); + expect(((await memberView.json()) as { access: string }).access).toBe("edit"); + + const refused = await member.post(`/api/subshells/${subshellId}/input`, { + data: { text: `echo NOT-YOUR-PANE-${nonce}` }, + }); + expect(refused.status(), await refused.text()).toBe(403); + expect(((await refused.json()) as { code: string }).code).toBe("SSH_OWNER_INPUT_ONLY"); + + // The refusal typed NOTHING: give any raced write a beat, then read the + // whole log and require the sharee's marker to be absent. + await sleep(1_000); + const afterRefusal = await admin.get(`/api/subshells/${subshellId}/log`); + expect(afterRefusal.ok()).toBe(true); + expect(((await afterRefusal.json()) as { lines: string[] }).lines.join("\n")).not.toContain( + `NOT-YOUR-PANE-${nonce}`, + ); + + // ── 7. The config dir is swept at the pane's death (decision 4): the + // owner types `exit`, the ssh process ends on its own, and the tmux + // pane-died hook lands on reportExit - whose local branch rm -rfs + // /ssh/. Natural death is THE sweep path to drive: a + // terminate revokes the pane's token as it kills, so the hook it races + // can answer 401, while here the token is still live when the hook fires + // (measured: swept inside a second of the typed exit; the row parks at + // status running, alive false). The backend is a separate process but on + // THIS host with a data dir the spec itself chose, so the absence is + // checked straight off the disk - in-proc vs child: CHILD, fs still + // observable because the spec passed the path in. + const exitLine = await admin.post(`/api/subshells/${subshellId}/input`, { data: { text: "exit" } }); + expect(exitLine.ok(), await exitLine.text()).toBe(true); + await pollUntil(`ssh config dir ${sshDir} outlived the pane's own exit`, () => !existsSync(sshDir), READY_TIMEOUT); + + // Retire the parked row (terminate accepts a dead pane, delete removes + // it; the delete path's unlink-only artifact list has nothing left to + // take, which is exactly what "swept" means here). + const terminated = await admin.post(`/api/subshells/${subshellId}/terminate`); + expect(terminated.ok(), await terminated.text()).toBe(true); + const removed = await admin.delete(`/api/subshells/${subshellId}`); + expect(removed.ok() || removed.status() === 404, await removed.text()).toBe(true); + subshellId = undefined; + } finally { + // Row first (while the admin ctx still works), then the principals, then + // the daemons. A mid-story failure leaves the pane alive on a tmux server + // that lives under THIS attempt's tmuxBase, so the socket sweep inside + // stopSshBackend() is what kills the ssh process when the row delete fails. + if (subshellId !== undefined && adminRef) { + try { + const res = await adminRef.post(`/api/subshells/${subshellId}/terminate`); + if (!res.ok() && res.status() !== 404) leaks.push(`cleanup terminate: HTTP ${res.status()}`); + await adminRef.delete(`/api/subshells/${subshellId}`); + } catch (err) { + // The instance is going away anyway; the temp dir rm below is the real backstop. + leaks.push(`cleanup row: ${String(err)}`); + } + } + for (const [label, p] of [ + ["admin ctx", adminRef], + ["member ctx", memberRef], + ] as const) { + try { + await p?.dispose(); + } catch (err) { + leaks.push(`${label} dispose: ${String(err)}`); + } + } + if (agentPid !== undefined) { + try { + process.kill(agentPid, "SIGTERM"); + } catch { + // Already gone. + } + } + try { + await fixture?.stop(); + } catch (err) { + leaks.push(`sshd fixture stop: ${String(err)}`); + } + try { + stopSshBackend(); + } catch (err) { + leaks.push(`backend stop: ${String(err)}`); + } + if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true }); + if (leaks.length > 0) console.error(`[22-ssh-terminal] cleanup problems: ${leaks.join("; ")}`); + } +}); From 4e07918afa0c1675466f70286766673be3a52b6e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:15:58 -0700 Subject: [PATCH 038/170] fix(api): local ssh panes are swept at terminate and delete, not only via the dying hook; doc-truth pass on tier-2 comments --- .../__tests__/subshell-manager-ssh.test.ts | 19 +++- .../subshells-terminate-ssh-sweep.test.ts | 97 +++++++++++++++++++ .../api/src/services/ssh-launch.service.ts | 51 +++++++++- .../src/services/subshell-manager.service.ts | 41 ++++---- .../api/src/services/subshells.service.ts | 61 ++++-------- packages/backend-errors/src/strip-ansi.ts | 6 +- packages/pane-runtime/src/ssh/ssh-render.ts | 20 ++-- 7 files changed, 216 insertions(+), 79 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts index d32a66803..942f534f0 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -1,9 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildSshConfigPath } from "@internal/pane-runtime"; import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -184,7 +185,7 @@ describe("createSubshell ssh plumbing", () => { }); describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { - it("a LOCAL row with a snapshot gets the config path removed — whatever its harness id claims", async () => { + it("a LOCAL row with a snapshot gets its config DIR swept — whatever its harness id claims", async () => { const id = crypto.randomUUID(); created.push(id); await subshellsRepo.create({ @@ -200,9 +201,16 @@ describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { nodeId: LOCAL_NODE_ID, ssh: JSON.stringify(SNAPSHOT), }); + // The dir the LocalLauncher would have written, on the SERVER's own disk + // (the sweep is byte-derived, not launcher-provided). + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); const { manager, launcher } = mkManager(); expect(await manager.deleteSubshell(OWNER, id)).toBe(true); - expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id), subshellSshConfigPath(id)]); + // The config path is NO LONGER in the launcher's unlink list (a file unlink + // left the DIR); the sweep function took the whole directory instead. + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); }); it("an AGENT row with a snapshot passes the kind flag through subshellArtifacts", async () => { @@ -250,8 +258,13 @@ describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { nodeId: LOCAL_NODE_ID, ssh: null, }); + // A dir carrying the pane's NAME exists, but the row owns no config: the + // sweep's guard, not the launcher list, is what keeps it standing. + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); const { manager, launcher } = mkManager(); expect(await manager.deleteSubshell(OWNER, id)).toBe(true); expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); // no config path + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(true); // sweep refused + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); }); }); diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts new file mode 100644 index 000000000..0b670e578 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The TERMINATE half of the ssh config sweep (spec 2026-10-07 decision 4; the + * e2e spec-22 finding): terminate revokes the pane's token synchronously with + * the kill, so the tmux `pane-died` hook's report can arrive 401 and its sweep + * never runs. The terminate verb therefore sweeps itself, through the SAME + * {@link sweepLocalSshDir} the exit report calls (one function, no drift). + * + * The rows here carry no tmux socket: the manager's kill is skipped, which is + * the point — the sweep is the verb's own act after the teardown, not the + * pane's last breath. + */ + +const OWNER = "u-ssh-term"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { id: string; nodeId: string; ssh: string | null }): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh terminate row", + workingDir: tmpdir(), + tmuxSocket: null, // no pane to kill: the test isolates the verb's sweep step + status: "running", + alive: 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the terminate verb sweeps the LOCAL ssh config dir", () => { + it("terminating a local ssh row removes /ssh/ (the dying hook is not relied on)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const row = await subshellsRepo.findById(id); + expect(row?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminating a local ORDINARY row touches nothing ssh-shaped (the guard, not luck)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + makeConfigDir(id); // a dir with the pane's NAME but no snapshot column: not its to remove + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("terminating an AGENT ssh row sweeps nothing on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 697ffcc92..4cca84df0 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -1,4 +1,6 @@ +import { rmSync } from "node:fs"; import { homedir as osHomedir } from "node:os"; +import { dirname } from "node:path"; import { BackendErrorCodes } from "@internal/backend-errors"; import { buildSshConfigPath, @@ -23,13 +25,15 @@ import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; import { db } from "@/db/index.js"; import { SettingsRepository } from "@/db/repositories/settings.repository.js"; import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; -import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { nodeCanSsh } from "@/lib/node-access.js"; import { audit } from "@/services/audit.js"; import { getHeld, getLive } from "@/services/nodes/node-registry.js"; import { SshRpcError, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import type { SubshellsService } from "@/services/subshells.service.js"; import { logger } from "@/utils/logger.js"; @@ -130,6 +134,51 @@ export function composeSshLaunch(args: { : { configPath, fileContent, presetFlags, extraPaneEnv: { SSH_AUTH_SOCK: approved.authAgentSocket } }; } +/* ------------------------------------------------------------------ */ +/* config lifecycle: the LOCAL sweep, ONE function for every teardown */ +/* point (spec 2026-10-07 decision 4; plan-2 handoff 2) */ +/* ------------------------------------------------------------------ */ + +/** + * Best-effort `rm -rf` of the per-pane ssh config dir a LOCAL ssh pane wrote + * on this disk: `/ssh/` — the dirname of the + * byte-derived config path the LocalLauncher wrote into. ONE function, called + * by all three teardown points, so no copy can drift: + * + * - the pane's own death report (the service's `reportExit`), which fires even + * when the row is already retired: a terminate kills the pane, the tmux + * `pane-died` hook races the retire stamp, and whichever order they land in + * the config must be gone once the report has been heard; + * - the terminate verb, AFTER the kill: terminate revokes the pane's token + * synchronously with the kill, so the dying hook's report can arrive 401 + * and run no sweep at all (e2e spec 22) — the hand that kills must also + * remove the dir; + * - the delete path: the row is gone before the sweep could ever ride the + * exit hook, and the artifacts list must not carry the config path either, + * because removing the file and leaving the DIR behind is not a removal. + * + * The guard is the row's OWN facts (`local` node ∧ the snapshot column, + * migration 0048) applied HERE, once, so callers hand over the row they + * already hold unconditionally. Agent rows are skipped by rule, not by + * accident — their config lives on the NODE's disk and the agent's own exit + * watcher unlinks it; removing a directory named after a remote pane off THIS + * server would be this host deleting a file that is not its (the delete + * path's per-node launcher doctrine, same reason). + * + * Best-effort by shape: any failure (missing dir, an id outside the path + * guard, a refusing filesystem) costs one log line and nothing else — the + * teardown must land regardless, and a stale config in a dir whose name is a + * dead pane's id is garbage, not a hazard. + */ +export function sweepLocalSshDir(row: Pick | null | undefined): void { + if (!row || row.nodeId !== LOCAL_NODE_ID || row.ssh === null) return; + try { + rmSync(dirname(subshellSshConfigPath(row.id)), { recursive: true, force: true }); + } catch (err) { + logger.withError(err).warn(`ssh config sweep failed for local pane ${row.id}`); + } +} + /* ------------------------------------------------------------------ */ /* the gate (spec §4.3: the plane's row, then the held check, BEFORE */ /* any command; naming WHY is this surface's copy, per §12) */ diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index b670cff17..32f2070a1 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -55,10 +55,11 @@ import { previewCachePut, } from "@/services/nodes/preview-cache.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; -import { subshellLogPath, subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; import { logger } from "@/utils/logger.js"; @@ -1072,32 +1073,28 @@ export class SubshellManagerService { // Best-effort artifact cleanup ON THE ROW'S NODE (the log is only an // attach-replay artifact; the MCP config holds no secrets but nothing // should be left behind). The layout lives behind the launcher seam - // (spec §6.4): a local row leaves exactly its replay log (plus, for an - // ssh pane, the rendered config the LocalLauncher wrote under the - // server's dataDir); an agent node names its triple (log + MCP config + - // the agent's own meta record — deliberately left behind by a kill, so - // the DELETE unlinks it) from its live `ready` facts, and an OFFLINE - // agent answers `[]`: no facts, no layout to name paths from, artifacts - // age out with the node (§5.6). + // (spec §6.4): a local row leaves exactly its replay log; an agent node + // names its triple (log + MCP config + the agent's own meta record — + // deliberately left behind by a kill, so the DELETE unlinks it) plus the + // ssh config at the node-derived path, from its live `ready` facts, and + // an OFFLINE agent answers `[]`: no facts, no layout to name paths from, + // artifacts age out with the node (§5.6). // The local short-circuit is row-keyed because a TEST launcher answers for // EVERY node id (the phase-0 suites) — a fake standing in for `local` // must keep receiving the local artifact set, never the agent triple. - // The ssh config path rides the list for THIS row only, and the row's - // kind fact (spec 2026-10-07 decision 4) is read HERE — the launchers - // compose paths, only the caller holding the row can say which files the - // pane OWNS. `row.ssh !== null` IS that truth (migration 0048's snapshot - // column): the value's presence means a rendered config was composed for - // this pane, which is exactly what the cleanup must remove — a row that - // merely NAMES the ssh harness (hand-built, or a launch that never got a - // snapshot) has no config file to find, and a snapshot under any other - // harness id still owns one. Task 7's re-point, promised by the comment - // this replaces. + // A LOCAL ssh pane's config is deliberately NOT in that list: it lives in + // its own directory under the server's dataDir, and unlinking the file + // while leaving the DIR is not a removal. `sweepLocalSshDir` (the one + // sweep function, shared with the exit report and terminate) removes the + // dir, keyed on the same row facts the old list entry consulted: the + // node id here, the snapshot column (`row.ssh !== null`, migration 0048) + // inside the guard — a row that merely NAMES the ssh harness has no + // config to find, and a snapshot under any other harness id still owns + // one. const sshPane = row.ssh !== null; - const artifacts = - row.nodeId === LOCAL_NODE_ID - ? [launcher.logPath(id), ...(sshPane ? [subshellSshConfigPath(id)] : [])] - : launcher.subshellArtifacts(id, sshPane); + const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) // And the generated MCP config (no secrets, but nothing to leave behind). try { unlinkSync(subshellMcpConfigPath(id)); diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 9cfcdaac1..9235d30da 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -1,6 +1,4 @@ -import { rmSync } from "node:fs"; import { homedir as osHomedir } from "node:os"; -import { dirname } from "node:path"; import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; // The attention kinds are imported from the reporter that SPEAKS them rather // than restated here: the old local `Extract`, widened once to @@ -39,9 +37,9 @@ import { waitSentinel, } from "@/services/nodes/pane-exec.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; -import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS, @@ -691,51 +689,27 @@ export class SubshellsService extends BaseService { * or on any enrolled node, since both reach this plane the same way. * * Thin by design: the route has already established that this is the - * subshell's own key, and the manager owns the one death transition the - * sweep also runs through — so nothing here decides anything, it only - * carries the timestamp. + * subshell's own key, and the manager owns the one death transition. The + * row read below decides nothing either — it only feeds the shared LOCAL + * ssh config sweep (its own guard skips every other row shape); the death + * transition itself carries just the timestamp. * * @param id - the subshell whose pane exited * @param exitCode - tmux's `#{pane_dead_status}`, null when it could not be read */ async reportExit(id: string, exitCode: number | null): Promise { - await this.#sweepLocalSshConfig(id); + // The read is part of the best-effort sweep too: a throwing row read costs + // a log line, never the death transition below (the pre-helper code had the + // read inside its try for exactly this). + sweepLocalSshDir( + await this.repos.subshells.findById(id).catch((err: unknown) => { + logger.withError(err).warn(`ssh config sweep skipped for pane ${id}; the row read failed`); + return null; + }), + ); await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } - /** - * The LOCAL half of the ssh config lifecycle (spec 2026-10-07 decision 4; - * plan-2 handoff 2): when the dying pane is a LOCAL ssh pane, best-effort - * `rm -rf` of `/ssh/` — the directory the - * LocalLauncher wrote the rendered config into, the dirname of the one - * byte-derived path the launchers and the sweep agree on. - * - * Deliberately keyed on the row's `ssh` column plus its `local` node and - * fired BEFORE the manager's death transition, and it runs even when the - * row is already retired: a terminate kills the pane, the tmux `pane-died` - * hook races the retire stamp, and whichever order they land in the config - * must be gone once the report has been heard (Task 9's e2e asserts exactly - * that after terminate). Agent rows are skipped by rule, not by accident — - * their config lives on the NODE's disk and the agent's own exit watcher - * unlinks it; removing a directory named after a remote pane off THIS - * server would be this host deleting a file that is not its (the delete - * path's per-node launcher doctrine, same reason). - * - * Best-effort by shape: any failure (missing dir, an id outside the path - * guard, a refusing filesystem) costs one log line and nothing else — the - * death transition must land regardless, and a stale config in a dir whose - * name is a dead pane's id is garbage, not a hazard. - */ - async #sweepLocalSshConfig(id: string): Promise { - try { - const row = await this.repos.subshells.findById(id); - if (!row || row.nodeId !== LOCAL_NODE_ID || row.ssh === null) return; - rmSync(dirname(subshellSshConfigPath(id)), { recursive: true, force: true }); - } catch (err) { - logger.withError(err).warn(`ssh config sweep failed for local pane ${id}; the dir ages out with the delete path`); - } - } - /** * Screens for the subshells a viewer asked to see, filtered to those they * actually may (spec 2026-09-19 §4.4). @@ -1462,6 +1436,13 @@ export class SubshellsService extends BaseService { async terminateSubshell(viewerId: string, id: string, actor: GuardActor): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); await this.#manager.terminateSubshell(row.userId, id); + // AFTER the kill, the same sweep the dying hook runs — and it cannot be + // left TO the hook: terminate revokes the pane's token synchronously with + // the kill, so the `pane-died` report can arrive 401 and never sweep + // (e2e spec 22). The hand that killed removes the dir; the hook, whenever + // it lands, stays the second sure thing (its sweep is idempotent). The + // helper's guard no-ops this for every non-local-ssh row. + sweepLocalSshDir(row); return { ok: true }; } diff --git a/packages/backend-errors/src/strip-ansi.ts b/packages/backend-errors/src/strip-ansi.ts index 5fa7c1d4f..572cdeaf6 100644 --- a/packages/backend-errors/src/strip-ansi.ts +++ b/packages/backend-errors/src/strip-ansi.ts @@ -4,9 +4,9 @@ * output, so the two consumers must agree on what counts as escape noise. * * Strips CSI sequences (`ESC [ ... final-byte`, including DEC private modes - * like `ESC [ ? 1049 h`), OSC title sequences (`ESC ] ... BEL`), and carriage - * returns. Safe for arbitrary terminal output; exported as a pure function so - * it can be unit-tested. + * like `ESC [ ? 1049 h`), OSC sequences (`ESC ] ...` ended by either BEL or + * ST = `ESC \`), and carriage returns. Safe for arbitrary terminal output; + * exported as a pure function so it can be unit-tested. */ /** diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index cbadd52e9..005b14084 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -68,9 +68,9 @@ function assertRenderable(snapshot: SshConnectionSnapshotWire): void { */ const MANDATORY_POLICY: readonly [string, string][] = [ // first connect records the key in the machine's own known_hosts; a CHANGED - // key is refused by OpenSSH itself (spec §9: accept-new is the M1 posture) + // key is refused by OpenSSH itself (spec 2026-10-07 §9: accept-new is the M1 posture) ["StrictHostKeyChecking", "accept-new"], - // no agent or X11 forwarding (the §2 "no agent/X11 forwarding" sentence) + // no agent or X11 forwarding (spec 2026-10-07 §5.2: forwards refused by name, never reappearing) ["ForwardAgent", "no"], ["ForwardX11", "no"], ["ForwardX11Trusted", "no"], @@ -78,13 +78,13 @@ const MANDATORY_POLICY: readonly [string, string][] = [ ["ClearAllForwardings", "yes"], ["Tunnel", "no"], ["PermitRemoteOpen", "none"], - // no local commands on any branch (§2 "no local commands") + // no local commands on any branch (spec 2026-10-07 §5.2: remote/local commands refused by name) ["PermitLocalCommand", "no"], - // the command contract owns the remote side; no imported remote command / env (§2 "Do not import RemoteCommand, SendEnv, SetEnv") + // the command contract owns the remote side; no imported remote command / env (spec 2026-10-07 §5.2: env and command sends refused) ["RemoteCommand", "none"], - // no escape menu — `~` is a local shell (§2 "no SSH escape commands") + // no escape menu; `~` is a local shell (spec 2026-10-07 §5.2: escape sends refused) ["EscapeChar", "none"], - // no ambient control sockets or multiplexing (§2's explicit clause) + // no ambient control sockets or multiplexing (spec 2026-10-07 §5.2: the render carries only approved facts) ["ControlMaster", "no"], ["ControlPath", "none"], ["GSSAPIAuthentication", "no"], @@ -122,7 +122,7 @@ function configPathValue(path: string): string { /** One config line for each known-hosts ref the snapshot names; absent names render nothing. */ function knownHostsLines(files: string[]): string[] { // absent renders nothing: ssh's own default `~/.ssh/known_hosts` is the M1 - // trust store (§9) - the tier's product wrote /dev/null because its + // trust store (spec 2026-10-07 §9) - the tier's product wrote /dev/null because its // BatchMode posture made silence fail closed; here silence IS the policy. if (files.length === 0) return []; return files.map((f) => ` UserKnownHostsFile ${configPathValue(f)}`); @@ -141,7 +141,7 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): st assertRenderable(snapshot); const lines = [ "# Subshell managed SSH config - GENERATED, do not edit.", - "# Mandatory runtime policy for the connection and every ProxyJump hop (§2).", + "# Mandatory runtime policy for the connection and every ProxyJump hop (spec 2026-10-07 §5.2).", "Host *", ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${value}`), ...knownHostsLines(snapshot.knownHostsFiles), @@ -155,8 +155,8 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): st /** * Build ssh's option argv for one snapshot: EVERYTHING after the binary * EXCEPT the trailing `-- host` tail ({@link sshDestinationToken} carries - * that one entry). `-F ` first — the caller composed the path - * ({@link buildSshConfigPath}) and owns the write — then `-p` ALWAYS (the + * that one entry). `-F ` first (the caller composed the path with + * {@link buildSshConfigPath} and owns the write), then `-p` ALWAYS (the * port is a resolved fact, never ssh's guess), `-l` only when the snapshot * names a user, and `HostKeyAlias`/`ProxyJump` only when set. No `-tt`: this * pane allocates its PTY through tmux and carries no remote command, so From 01aa8909625a87da7e19919a77927a9e71d212b1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:22:41 -0700 Subject: [PATCH 039/170] fix(node): sweep orphan per-pane ssh dirs; sweep ssh dirs on maintenance kills too --- .../src/__tests__/ssh-dir-retention.test.ts | 164 ++++++++++++++++++ apps/node/agent/src/daemon.ts | 21 +++ apps/node/agent/src/ssh-dir-retention.ts | 133 ++++++++++++++ .../subshells-terminate-ssh-sweep.test.ts | 50 +++++- .../src/services/subshell-manager.service.ts | 24 ++- .../src/ssh/__tests__/ssh-discover.test.ts | 2 +- 6 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 apps/node/agent/src/__tests__/ssh-dir-retention.test.ts create mode 100644 apps/node/agent/src/ssh-dir-retention.ts diff --git a/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts new file mode 100644 index 000000000..ada1cec3a --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + existsSync, + lutimesSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ORPHAN_SSH_DIR_MIN_AGE_MS, sweepOrphanSshDirs } from "../ssh-dir-retention.js"; +import type { SubshellMeta, SubshellMetaStore } from "../subshell-meta.js"; + +const HOUR_MS = 3_600_000; + +// Dir names are uuid-shaped: `isSubshellId` gates the name shape, so the +// tests use ids that pass it and stray names that cannot (same discipline as +// the pane-log retention tests beside this file). +const ID_TRACKED = "aaaaaaaa-0000-4000-8000-000000000001"; +const ID_YOUNG = "bbbbbbbb-0000-4000-8000-000000000002"; +const ID_AGED = "cccccccc-0000-4000-8000-000000000003"; +const ID_NESTED = "dddddddd-0000-4000-8000-000000000004"; +const ID_SYMLINK = "eeeeeeee-0000-4000-8000-000000000005"; +const ID_OUTSIDE = "ffffffff-0000-4000-8000-000000000006"; + +function meta(id: string): SubshellMeta { + return { + subshellId: id, + cwd: "/tmp/whatever", + socket: "sock1", + harnessId: "ssh", + name: id, + startedAt: new Date(0).toISOString(), + }; +} + +let dataDir: string; +let sshDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "subshell-ssh-sweep-")); + sshDir = join(dataDir, "ssh"); + mkdirSync(sshDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +/** Create `/` (with a `config` file, the pane's real shape), backdated `ageMs`. */ +function makePaneDir(id: string, ageMs: number): string { + const dir = join(sshDir, id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (Date.now() - ageMs) / 1000; + utimesSync(dir, t, t); + return dir; +} + +/** One sweep against the temp dir with a fake meta store (the retention tests' style). */ +async function sweep(ids: string[], nowMs = Date.now()) { + return await sweepOrphanSshDirs( + dataDir, + { list: async () => ids.map(meta) } as unknown as Pick, + nowMs, + ); +} + +describe("sweepOrphanSshDirs", () => { + it("keeps the dir of an id PRESENT in meta, however aged (live/queued panes are never garbage)", async () => { + const dir = makePaneDir(ID_TRACKED, 30 * 24 * HOUR_MS); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, "config"))).toBe(true); + }); + + it("keeps an orphan younger than the hour floor (the execLaunch write-before-meta-record window)", async () => { + const dir = makePaneDir(ID_YOUNG, ORPHAN_SSH_DIR_MIN_AGE_MS / 60); // one minute old, untracked + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + }); + + it("removes an aged orphan (the delete-after-death leak this sweep exists for)", async () => { + const dir = makePaneDir(ID_AGED, HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); // DIR gone, not just the config file + }); + + it("removes a validly-named orphan dir NESTED JUNK AND ALL (the rm is recursive)", async () => { + const dir = join(sshDir, ID_NESTED); + mkdirSync(join(dir, "nested", "deeper"), { recursive: true }); + writeFileSync(join(dir, "nested", "deeper", "junk"), "x"); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + utimesSync(dir, t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); + + it("leaves non-id names untouched (stray shapes are nobody's to remove)", async () => { + // Names the name-gate cannot match: dots, and letters outside hex. + // (An "a/b" child name cannot exist; the nested case is a parent dir that + // is itself not id-shaped, kept with everything under it.) + const strays = ["..config", "cfg", `${ID_AGED}.log`, "ssh-host-config"]; + for (const name of strays) mkdirSync(join(sshDir, name), { recursive: true }); + mkdirSync(join(sshDir, "cfg", "b"), { recursive: true }); + const aged = makePaneDir(ID_AGED, HOUR_MS + 60_000); // the one entry that IS eligible + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + for (const name of strays) utimesSync(join(sshDir, name), t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(aged)).toBe(false); + for (const name of strays) expect(existsSync(join(sshDir, name))).toBe(true); + expect(existsSync(join(sshDir, "cfg", "b"))).toBe(true); + }); + + it("the name-gate is case-insensitive hex (an UPPER-CASE id shape is eligible, guard truth)", async () => { + // `isNodeSubshellId` accepts [0-9a-fA-F-]: uppercase is NOT a stray shape, + // and the sweep's rule is the guard's rule, pinned rather than assumed. + const upper = makePaneDir("AAAAAAAA-0000-4000-8000-000000000001", HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(upper)).toBe(false); + }); + + it("does not follow an id-named SYMLINK at a real dir elsewhere (lstat gate, never rm through it)", async () => { + const outside = join(dataDir, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "keep"), "not ssh garbage"); + symlinkSync(outside, join(sshDir, ID_SYMLINK)); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + lutimesSync(join(sshDir, ID_SYMLINK), t, t); // age the LINK (utimesSync would follow it to the target) + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(outside)).toBe(true); // the target survives, contents and all + expect(existsSync(join(outside, "keep"))).toBe(true); + expect(existsSync(join(sshDir, ID_SYMLINK))).toBe(true); // and the link itself is left, not unlinked through + }); + + it("an absent ssh dir is a silent no-op (a node that never hosted an ssh pane)", async () => { + rmSync(sshDir, { recursive: true, force: true }); + expect(await sweep([])).toEqual({ removed: 0 }); + }); + + it("a tracked id does not shield another id's aged orphan (the gate is per-name)", async () => { + const tracked = makePaneDir(ID_TRACKED, HOUR_MS + 60_000); + const orphan = makePaneDir(ID_OUTSIDE, HOUR_MS + 60_000); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 1 }); + expect(existsSync(tracked)).toBe(true); + expect(existsSync(orphan)).toBe(false); + }); + + it("the age floor is exclusive: YOUNGER keeps, exactly-one-hour-old goes", async () => { + const now = Date.now(); + // mtime pinned to EXACTLY `now - 1h`: the guard is "younger than an hour", + // so this boundary row is eligible (only strict youth is kept). + const dir = join(sshDir, ID_AGED); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (now - ORPHAN_SSH_DIR_MIN_AGE_MS) / 1000; + utimesSync(dir, t, t); + expect(await sweep([], now)).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); +}); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index bfd1cfc11..a2e9fc255 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -40,6 +40,7 @@ import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; +import { sweepOrphanSshDirs } from "./ssh-dir-retention.js"; import { SubshellMetaStore } from "./subshell-meta.js"; import { completeUpdate, revertAfterRefusal } from "./update.js"; import { NODE_VERSION } from "./version.js"; @@ -584,6 +585,26 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); retentionTimer.unref?.(); } + // Orphaned per-pane ssh config dirs (spec 2026-10-07 decision 4's + // machine-side half): `remove_paths` unlinks FILES, so a delete-after- + // death removed `//config` and left the DIR behind forever. The + // agent drops those dirs here — boot pass plus the same hourly beat as the + // retention timer above, unref'd and fire-and-forget like it (and like the + // transfer sweep), because a wedged fs probe must not cost the node its + // first connection. Deliberately NOT inside the `forever` gate above it: + // the pane-log keep-forever pair is an operator choice about TRANSCRIPTS, + // and it says nothing about a dead pane's empty config dir — that sweep's + // only grace is the one-hour launch-window floor inside the pass itself. + const sweepSshDirs = (): Promise<{ removed: number }> => sweepOrphanSshDirs(config.dataDir, ctx.meta, Date.now()); + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + const sshDirSweepTimer = setInterval(() => { + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); + sshDirSweepTimer.unref?.(); // SERIAL command executor (spec §3.4): verified commands queue here so pane // effects land in arrival order across the whole daemon life — surviving // reconnects. A command verified pre-`seqTracker.reset` executing post-reset diff --git a/apps/node/agent/src/ssh-dir-retention.ts b/apps/node/agent/src/ssh-dir-retention.ts new file mode 100644 index 000000000..b63309a3f --- /dev/null +++ b/apps/node/agent/src/ssh-dir-retention.ts @@ -0,0 +1,133 @@ +import { lstat, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { log } from "./log.js"; +import { pathAllowed } from "./path-policy.js"; +import { isSubshellId, type SubshellMetaStore } from "./subshell-meta.js"; + +/** + * Node-side sweep of orphaned per-pane ssh config dirs — the agent's own + * pass over `/ssh/` (spec 2026-10-07 ssh-anywhere, decision 4's + * machine-side half). + * + * **Why this exists on the node at all.** An ssh pane's generated config is + * a DIRECTORY (`/ssh//config`), but the plane-commanded + * `remove_paths` executor unlinks FILES only — so a delete-after-death on an + * AGENT node removed the config and left the empty per-pane dir forever + * (the local twin of that leak was closed plane-side in the same wave; this + * is the machine's copy of the rule, same doctrine as + * `pane-log-retention.ts`). Nothing else ever names these paths: the id is + * retired, the row is gone, and the dir outlives every fact that explains it. + * + * The rules, each inherited from the pane-log sweep's doctrines: + * + * - **Only immediate children of `/ssh` whose name passes + * `isSubshellId`.** Nothing else in the tree is ours; a name that is not + * a pane id is nobody's to remove. The regex also makes the join unable + * to escape the dir (no `/`, no `..`, no uppercase), and `pathAllowed` + * re-checks anyway — the `remove_paths` rule, which is what refuses a + * symlinked `ssh` parent pointing outside the data dir. + * - **Never follow a symlink** (`lstat` first: a leaf whose name looks like + * an id is skipped with a log line, and a `rm -rf` never runs through it). + * - **A tracked pane's dir survives** whatever its mtime says: `meta.list()` + * is the record of live AND queued panes (the exit watcher forgets only on + * death), so a running pane's config is untouchable even mid-restart. + * - **Age floor, not a retention window:** a dir younger than one hour is + * kept. This guards the `execLaunch` write-before-meta-record window — a + * pane that launched seconds ago has a dir and no meta yet, and the + * tracked-pane rule alone could not save it. Unlike the pane-log sweep + * there is no operator window here and no keep-forever pair: a dead pane's + * empty config dir is pure garbage, and one hour is the grace, not a + * policy. That is also why the daemon schedules this OUTSIDE the log + * sweep's `forever` gate. + * - **Total by construction:** every fs failure costs one log line and the + * pass continues; an absent `ssh` dir is a silent no-op (a node that has + * never hosted an ssh pane). + */ + +const HOUR_MS = 3_600_000; + +/** Younger than this, the dir is kept: the launch write→meta-record grace. */ +export const ORPHAN_SSH_DIR_MIN_AGE_MS = HOUR_MS; + +/** The dir the sweep walks; exported so the daemon, tests and the plane-side twin name it from one place. */ +export function sshDirsDir(dataDir: string): string { + return join(dataDir, "ssh"); +} + +/** + * Remove per-pane ssh config dirs no pane owns anymore. + * + * @param dataDir - the node's data dir; the sweep touches only `/ssh/`. + * @param meta - the launch records; `list()` IS "live or queued here" — a + * recorded id's dir is never garbage. (Narrowed to `list` exactly as + * `pane-log-retention.ts` narrows it, so a fake-meta test needs no store.) + * @param nowMs - epoch-ms clock; the age floor is judged against THIS, so a + * test pins the boundary without sleeping. + * @returns the number of dirs removed. Never rejects on fs failures (only a + * throwing `meta.list` can reject, same posture as the pane-log sweep, and + * the daemon's `.catch` covers that). + */ +export async function sweepOrphanSshDirs( + dataDir: string, + meta: Pick, + nowMs: number, +): Promise<{ removed: number }> { + const dir = sshDirsDir(dataDir); + let names: string[]; + try { + names = await readdir(dir); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + log(`ssh dir sweep: could not read ${dir}: ${err instanceof Error ? err.message : String(err)}`); + } + return { removed: 0 }; + } + const candidates = names.filter((n) => isSubshellId(n)); + if (candidates.length === 0) return { removed: 0 }; // nothing id-shaped — skip even the meta read + + // The tracked set FIRST: live AND queued panes are never garbage, whatever + // their dir says. A record with a dead socket (node was offline for the + // death) keeps the dir until the death report lands — the NEXT pass then + // ages it out; unknown-tracked is kept, not guessed. + const tracked = new Set((await meta.list()).map((m) => m.subshellId)); + + let removed = 0; + for (const name of candidates) { + if (tracked.has(name)) continue; + const path = join(dir, name); + let st; + try { + st = await lstat(path); + } catch (err) { + // A dir that vanished under us is the sweep's job done by someone else. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + log(`ssh dir sweep: could not stat ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + continue; + } + if (st.isSymbolicLink()) { + // An id-named symlink is not ours to follow: log and leave it (a + // hostile or accidental link out of the tree must not be `rm -rf`ed + // through — the pane-log sweep's symlink-leaf refusal, made explicit). + log(`ssh dir sweep: ${name} is a symlink; not following it`); + continue; + } + if (st.mtimeMs > nowMs - ORPHAN_SSH_DIR_MIN_AGE_MS) continue; // the launch write→meta grace + if (!(await pathAllowed(path, [dataDir]))) { + // The name gate makes this unreachable for a plain child; it is the + // belt for a symlinked `ssh` PARENT (realpath lands outside the data + // dir), which a recursive delete must never follow. + log(`ssh dir sweep: ${path} failed the path policy; left in place`); + continue; + } + try { + await rm(path, { recursive: true, force: true }); + removed += 1; + } catch (err) { + log(`ssh dir sweep: could not remove ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (removed > 0) log(`ssh dir sweep: removed ${removed} orphaned config dir(s)`); + return { removed }; +} diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts index 0b670e578..c79f85832 100644 --- a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -5,9 +5,11 @@ import { join } from "node:path"; import { buildSshConfigPath } from "@internal/pane-runtime"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; @@ -16,10 +18,14 @@ import { getLogger } from "@/utils/logger.js"; * e2e spec-22 finding): terminate revokes the pane's token synchronously with * the kill, so the tmux `pane-died` hook's report can arrive 401 and its sweep * never runs. The terminate verb therefore sweeps itself, through the SAME - * {@link sweepLocalSshDir} the exit report calls (one function, no drift). + * {@link sweepLocalSshDir} the exit report calls (one function, no drift) — + * and so does the maintenance kill, {@link SubshellManagerService.terminateForMaintenance}, + * which reaches the same teardown through the manager (no REST verb involved) + * and raced the identical way: a maintenance window used to leave every local + * ssh config dir behind. * * The rows here carry no tmux socket: the manager's kill is skipped, which is - * the point — the sweep is the verb's own act after the teardown, not the + * the point — the sweep is the killer's own act after the teardown, not the * pane's last breath. */ @@ -95,3 +101,43 @@ describe("the terminate verb sweeps the LOCAL ssh config dir", () => { rmSync(localDir(id), { recursive: true, force: true }); }); }); + +/** + * The MAINTENANCE kill reaches the same teardown through the manager with no + * REST verb in the loop (the maintenance window and lockdown both call + * `terminateForMaintenance` per row), so the verb's sweep never runs for it. + * Same harness as the verb cases above: no tmux socket, so the kill is + * skipped and the sweep is the act under test. + */ +describe("the maintenance kill sweeps the LOCAL ssh config dir", () => { + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(getRequestlessContext().db), + audit: async () => {}, + notify: async () => {}, + }); + + it("terminateForMaintenance on a local ssh row removes /ssh/", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const after = await subshellsRepo.findById(id); + expect(after?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminateForMaintenance on an AGENT ssh row sweeps nothing on this host", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-maint", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 32f2070a1..cb43cd248 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -1012,12 +1012,12 @@ export class SubshellManagerService { * Terminate one subshell because its NODE entered maintenance * (spec 2026-09-14 §5.2). * - * {@link terminateSubshell} plus a push, and the push is the whole reason - * this exists as a second method rather than a flag: that path is - * deliberately silent because the operator clicked it, and here they did - * not. A node owner's window stops subshells belonging to everyone the node - * was shared with — people who cannot see the node, did not act, and would - * otherwise find a dead pane with no account of why. + * {@link terminateSubshell} plus a push and the local ssh config sweep, and + * the push is the whole reason this exists as a second method rather than a + * flag: that path is deliberately silent because the operator clicked it, + * and here they did not. A node owner's window stops subshells belonging to + * everyone the node was shared with — people who cannot see the node, did + * not act, and would otherwise find a dead pane with no account of why. * * The row's OWN `userId` is passed through, never an actor's: the terminate * path is owner-keyed and would silently skip every row but the caller's. @@ -1035,10 +1035,20 @@ export class SubshellManagerService { * the claim too, which is right: nothing was running to stop, and its owner * heard about that death when it happened. * + * The sweep runs whether or not this call claimed the transition (it is an + * idempotent `rm -rf`, and the helper's guard no-ops every row that is not + * a LOCAL ssh pane): the kill inside {@link terminateSubshell} revokes the + * pane's token at the same moment, so the dying hook's report can arrive + * 401 and sweep nothing — exactly the race the terminate verb documented + * (e2e spec 22). The hand that kills removes the dir; without this, a + * maintenance window left every local ssh config dir behind. + * * @param row - the running row to stop, freshly read */ async terminateForMaintenance(row: SubshellTable): Promise { - if (await this.terminateSubshell(row.userId, row.id)) void this.#notify(row.id, "maintenance"); + const stopped = await this.terminateSubshell(row.userId, row.id); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + if (stopped) void this.#notify(row.id, "maintenance"); } /** diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts index db45fab8c..2631e3636 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts @@ -125,7 +125,7 @@ describe("discoverSshAliases", () => { expect(found.truncated).toBe(true); }); - it("sees Match exec in the walk (the local-execution signal §2 refuses on)", () => { + it("sees Match exec in the walk (the local-execution signal spec 2026-10-07 §5.2 refuses by name at resolve)", () => { const config = fixtureConfig("matchexec", ["Match exec echo hi", " User x", "Host m-alias"].join("\n")); const walk = walkSshConfig(config, root); expect(walk.matchExecSeen).toBe(true); From 43b21f5bcd0a350f8c6e85f76a65a4d376ccc3d4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:23:42 -0700 Subject: [PATCH 040/170] chore(changeset): the ssh launcher tier (protocol 17; server before nodes) --- .changeset/ssh-launcher-tier.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/ssh-launcher-tier.md diff --git a/.changeset/ssh-launcher-tier.md b/.changeset/ssh-launcher-tier.md new file mode 100644 index 000000000..3ca206127 --- /dev/null +++ b/.changeset/ssh-launcher-tier.md @@ -0,0 +1,25 @@ +--- +"@internal/server": minor +"@internal/node": minor +"@internal/subshell-protocol": minor +"@internal/pane-runtime": minor +--- + +SSH launcher tier: an enabled node can open interactive SSH-terminal panes at any +sshd host. `GET /api/ssh/aliases` and `POST /api/ssh/resolve` answer alias discovery +and `ssh -G` normalization on the connecting machine; `POST /api/ssh/launch` composes +the pane from the approved snapshot (rendered `-F` config, every-hop confinement +policy, host keys trusted `accept-new` in the connecting machine's own +`known_hosts`); saved/recent hosts and a default connecting machine live on the +plane keyed by the resolved destination. SSH-terminal panes accept input from their +owner alone (sharees view). The per-node `ssh_enabled` gate from the previous tier +refuses every one of these doors until the owner turns it on. + +Deployment: update the server before the nodes. The link gate is exact-match at +protocol 17, so a lagging agent is held (offline for every purpose but update) +until it crosses the bump on the update command. Nothing changes for anyone until +a node's gate is enabled. + +Known limitation: keystrokes typed during the initial ssh handshake can be +swallowed before the remote shell reaches its prompt; the pane is fully usable +once connected. From aa3e4ca862d4ab3832b20d713c9239267b5f0678 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:42:57 -0700 Subject: [PATCH 041/170] fix(ci): the End-to-end job installs OpenSSH so spec 22 runs, not skips The step apt-installed missing TOOL names, but ssh/ssh-keygen/sshd are binaries shipped by openssh-client/openssh-server, so spec 22's fixture skipped in CI: the ssh proof never ran. Map each missing binary to its package and install the deduped set, assert an sshd binary exists post-install so a package rename fails loudly instead of skipping quietly, and make the spec's header sentence match the step. Also teach package-releases' expected list about @subshell-ai/plugin-ssh, the non-private workspace the ssh tier added (CI runs test:scripts; the branch was red there). Whole-branch review of feat/ssh-anywhere-2, finding 1. --- .github/workflows/test.yml | 47 ++++++++++++++++++---- e2e/tests/22-ssh-terminal.spec.ts | 4 +- scripts/__tests__/package-releases.test.ts | 1 + 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a841b00a..69b422634 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -927,16 +927,49 @@ jobs: - name: Trust the bind-mounted workspace run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - # The session-lifecycle spec spawns real tmux sessions (stub harness). - - name: Ensure tmux and jq + # Spec 12 (session lifecycle) spawns real tmux sessions (stub harness); + # spec 22 (ssh terminal) drives a REAL sshd plus an ssh client. Neither + # OpenSSH binary is in the builder image; installing here rather than + # in the image keeps the shared image lean. The container job runs as + # root, so apt-get needs no sudo. Tools map to PACKAGES, not names: + # "ssh" is not an apt package (openssh-client ships ssh + ssh-keygen; + # openssh-server ships sshd), so the missing binaries are translated + # before the deduped set is installed. + - name: Ensure tmux, jq, and OpenSSH run: | set -euo pipefail - missing=() - for tool in tmux jq; do command -v "$tool" >/dev/null || missing+=("$tool"); done - if [ ${#missing[@]} -gt 0 ]; then - apt-get update && apt-get install -y --no-install-recommends "${missing[@]}" + pkg_for() { + case "$1" in + tmux) echo tmux ;; + jq) echo jq ;; + ssh|ssh-keygen) echo openssh-client ;; + sshd) echo openssh-server ;; + esac + } + missing_pkgs=() + for tool in tmux jq ssh ssh-keygen sshd; do + command -v "$tool" >/dev/null && continue + pkg="$(pkg_for "$tool")" + for p in "${missing_pkgs[@]+"${missing_pkgs[@]}"}"; do + [ "$p" = "$pkg" ] && continue 2 + done + missing_pkgs+=("$pkg") + done + if [ ${#missing_pkgs[@]} -gt 0 ]; then + apt-get update && apt-get install -y --no-install-recommends "${missing_pkgs[@]}" + fi + tmux -V && jq --version && ssh -V + # sshd -V prints usage and exits nonzero on some builds, so the + # presence proof is the binary: on PATH, else the package's + # documented location (the fixture probes /usr/sbin too). + command -v sshd || ls -l /usr/sbin/sshd + # Loud post-install assertion: an apt package rename would + # otherwise surface only as spec 22 skipping, i.e. the proof + # silently not running. + if ! command -v sshd >/dev/null && [ ! -x /usr/sbin/sshd ]; then + echo "::error::openssh-server present in no sshd location (PATH or /usr/sbin/sshd)" + exit 1 fi - tmux -V && jq --version # tsdown/rolldown run under NODE, not bun — their bin shebang is # `#!/usr/bin/env node` — so the repo has always had a Node requirement diff --git a/e2e/tests/22-ssh-terminal.spec.ts b/e2e/tests/22-ssh-terminal.spec.ts index bdaf4e7b3..5ce5a4f0a 100644 --- a/e2e/tests/22-ssh-terminal.spec.ts +++ b/e2e/tests/22-ssh-terminal.spec.ts @@ -36,7 +36,9 @@ import { shortTmuxBase } from "../stack"; * Binaries: ssh / ssh-keygen / sshd must run unprivileged here. The fixture's * own gate (`missingSshBins`) decides; a host without them SKIPS LOUDLY (the * skip names the missing binaries), it never fails and never fakes a pass. - * CI's builder image has openssh-server installed for exactly this spec. + * The CI e2e job installs openssh-client + openssh-server at runtime (the + * "Ensure tmux, jq, and OpenSSH" step of test.yml); hosts without the + * binaries still skip loudly rather than fake a pass. */ const ROOT = path.join(import.meta.dirname, "..", ".."); diff --git a/scripts/__tests__/package-releases.test.ts b/scripts/__tests__/package-releases.test.ts index 32a9209b2..1bf70bfa6 100644 --- a/scripts/__tests__/package-releases.test.ts +++ b/scripts/__tests__/package-releases.test.ts @@ -15,6 +15,7 @@ describe("publishedPackages", () => { "@subshell-ai/plugin-netbird", "@subshell-ai/plugin-opencode", "@subshell-ai/plugin-pi", + "@subshell-ai/plugin-ssh", "@subshell-ai/plugin-tailscale", "@subshell-ai/plugin-terminal", ]); From a5930ddf407096ed7c99b709d9b51dfdb533ca67 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 22:43:02 -0700 Subject: [PATCH 042/170] docs: tier-2 comments tell the truth about paths, teardown points, and terminal-equivalence The ssh launch frame's ssh member DOES carry the plane's derived configPath; the invariant is that the receiving end byte-checks it against its own derivation (agent commands/launch.ts, LocalLauncher in-process), which is what the render and compose comments now say. The local ssh sweep names four teardown points, not three (reportExit, terminate, terminateForMaintenance, delete). The agent sweep's id gate is hex+hyphen (uppercase hex included), not a no-uppercase rule. The saved-host destination doc drops its Socratic residue for a declarative spelling. The ssh plugin records the whole-branch review's terminal-equivalence ruling: a preset-composed ssh launch without the ssh column is reachable on a gate-off node on purpose; what the gate reserves are the subsystem's privileges that path cannot reach. Fixture comment names the live spec (22-ssh-terminal). Whole-branch review of feat/ssh-anywhere-2, findings 2-5. --- apps/node/agent/src/ssh-dir-retention.ts | 6 ++++-- .../api/src/db/types/ssh-saved-hosts.db-types.ts | 6 +++--- .../server/api/src/services/ssh-launch.service.ts | 15 ++++++++++++--- e2e/fixtures/sshd.ts | 2 +- packages/pane-runtime/src/ssh/ssh-render.ts | 9 +++++++-- packages/plugins/ssh/src/index.ts | 11 +++++++++++ 6 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/node/agent/src/ssh-dir-retention.ts b/apps/node/agent/src/ssh-dir-retention.ts index b63309a3f..05b7b846a 100644 --- a/apps/node/agent/src/ssh-dir-retention.ts +++ b/apps/node/agent/src/ssh-dir-retention.ts @@ -22,8 +22,10 @@ import { isSubshellId, type SubshellMetaStore } from "./subshell-meta.js"; * * - **Only immediate children of `/ssh` whose name passes * `isSubshellId`.** Nothing else in the tree is ours; a name that is not - * a pane id is nobody's to remove. The regex also makes the join unable - * to escape the dir (no `/`, no `..`, no uppercase), and `pathAllowed` + * a pane id is nobody's to remove. The gate is hex digits and hyphens + * only (`/^[0-9a-fA-F-]{1,64}$/`, uppercase hex included), so a name + * carries no separator and no dot: the join cannot escape the dir, and + * `pathAllowed` * re-checks anyway — the `remove_paths` rule, which is what refuses a * symlinked `ssh` parent pointing outside the data dir. * - **Never follow a symlink** (`lstat` first: a leaf whose name looks like diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts index 23251f460..12cf3c107 100644 --- a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -11,9 +11,9 @@ export interface SshSavedHostTable { /** Owning user id (FK to users, ON DELETE CASCADE) */ ownerUserId: string; /** - * canonical `user@host:port` (user part = the resolved snapshot's effective - * user, or "" default spelled as the connecting account? NO: `host:port` - * with an empty user prefix when the snapshot's user is null). The spelling + * The canonical key: `user@host:port` for a resolved snapshot that carries + * a user, `host:port` (no user prefix) when the snapshot's user is null, + * the latter meaning the connecting account's own default. The spelling * rule is {@link sshCanonicalDestination}; both writer and reader list * import it, and it has no other home. Unique per owner * (idx_ssh_saved_hosts_owner_destination). diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 4cca84df0..5bc53f977 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -105,8 +105,13 @@ export function isWireSafeSshDestination(value: string): boolean { /** * Everything the ssh launch needs, composed from an APPROVED snapshot, the * target machine's dataDir, and the new pane's id (spec 2026-10-07 decision - * 4: the path is DERIVED from the two facts each end already holds; it never - * travels the wire). Handoff 4's exact tail: every option token, then `--`, + * 4: the path is DERIVED from the two facts each end already holds). The + * derived path rides the launch frame's ssh member, but only as a CLAIM the + * other end never trusts: `apps/node/agent/src/commands/launch.ts` re-derives + * it from its own dataDir and the pane id and refuses unless the frame's + * `configPath` is byte-equal (the LocalLauncher byte-checks in-process), so + * naming the path on the wire grants no write the machine did not choose. + * Handoff 4's exact tail: every option token, then `--`, * then the bare host; `SSH_AUTH_SOCK` joins the pane env only when the * snapshot names a socket (decision 3's scoped exception). * @@ -143,7 +148,7 @@ export function composeSshLaunch(args: { * Best-effort `rm -rf` of the per-pane ssh config dir a LOCAL ssh pane wrote * on this disk: `/ssh/` — the dirname of the * byte-derived config path the LocalLauncher wrote into. ONE function, called - * by all three teardown points, so no copy can drift: + * by all four teardown points, so no copy can drift: * * - the pane's own death report (the service's `reportExit`), which fires even * when the row is already retired: a terminate kills the pane, the tmux @@ -153,6 +158,10 @@ export function composeSshLaunch(args: { * synchronously with the kill, so the dying hook's report can arrive 401 * and run no sweep at all (e2e spec 22) — the hand that kills must also * remove the dir; + * - the maintenance kill (the manager's `terminateForMaintenance`), which + * reaches the manager's own kill directly, never passing through the + * service verb above: without its own sweep a maintenance window left + * every local ssh config dir behind; * - the delete path: the row is gone before the sweep could ever ride the * exit hook, and the artifacts list must not carry the config path either, * because removing the file and leaving the DIR behind is not a removal. diff --git a/e2e/fixtures/sshd.ts b/e2e/fixtures/sshd.ts index f9c00e87b..0c77599f9 100644 --- a/e2e/fixtures/sshd.ts +++ b/e2e/fixtures/sshd.ts @@ -141,7 +141,7 @@ export async function startSshFixture(root: string, options: { envPath?: string // node: in a container with no USER/LOGNAME env bun answers "unknown" while // sshd, matching the uid not the env, logs the real account ("root"). // uid 0 is root on every supported OS; otherwise env, then the passwd - // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-runtime) so + // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-terminal) so // the daemon's own `connectingAccount` says the same thing everywhere. const user = process.getuid?.() === 0 ? "root" : (process.env.USER ?? process.env.LOGNAME ?? userInfo().username); const home = path.join(root, "home"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index 005b14084..e5421062d 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -199,8 +199,13 @@ export function sshDestinationToken(snapshot: SshConnectionSnapshotWire): string /** * The derived per-pane config path is composed here so plane and node agree - * byte-for-byte from the two facts each already holds (dataDir, subshellId) - - * no path ever travels the wire. The refusals are impossible-state guards at + * byte-for-byte from the two facts each already holds (dataDir, subshellId). + * The path DOES ride the launch frame (the ssh member's `configPath`), but + * as a CLAIM the receiving end never trusts: the agent's `commands/launch.ts` + * re-derives it from its own dataDir and the pane id and refuses a + * byte-mismatch (the LocalLauncher runs the same check in-process), so a + * hostile plane naming any path still cannot point the config write outside + * this machine's derivation. The refusals are impossible-state guards at * composition sites (matching the codebase's path-composition doctrine): a * relative dataDir or an id outside `/^[a-zA-Z0-9_-]{1,64}$/` THROWS rather * than composing a path that escapes the directory. diff --git a/packages/plugins/ssh/src/index.ts b/packages/plugins/ssh/src/index.ts index 2324685d5..bbe12d452 100644 --- a/packages/plugins/ssh/src/index.ts +++ b/packages/plugins/ssh/src/index.ts @@ -47,6 +47,17 @@ const createPlugin: HarnessPluginFactory = (_host: PluginHost): SubshellPlugin = // feature. capabilities: (): PluginCapability[] => [], + // Terminal-equivalence ruling (whole-branch review, accepted): a + // preset-composed ssh launch WITHOUT the ssh column (plain create route + + // crafted preset flags) is reachable on a gate-off node deliberately, + // because it is exactly terminal-equivalent: `nodeCanLaunchOn` already + // grants arbitrary argv via preset flags to anyone who may launch there, + // so the gate-off machine offers nothing this path can reach that an + // ordinary terminal pane does not. What the SSH gate reserves is the + // SUBSYSTEM's privileges: the rendered `-F` config, the exported + // `SSH_AUTH_SOCK`, resolve/discovery, and the owner-only-input kind, none + // of which a preset-flag launch carries. Do not "fix" this: closing it + // would mean taking preset flags off the terminal path too. buildCommand(input: BuildCommandInput): string[] { const { binary, preset, extraFlags } = input; // No name flag: ssh has no session-title notion, so the pane title is From fde9aca4edacfebc7fb950ec829cced11a7c5d2e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:04:26 -0700 Subject: [PATCH 043/170] fix(api): the ssh resolve/launch route descriptions carry the Match exec disclosure --- apps/server/api/src/api/ssh/launch.route.ts | 2 +- apps/server/api/src/api/ssh/resolve.route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts index 4cbfdfad6..9e4cca3b8 100644 --- a/apps/server/api/src/api/ssh/launch.route.ts +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -74,7 +74,7 @@ export const sshLaunchRoute = new Elysia() operationId: "sshLaunchPane", tags: ["ssh"], description: - "Opens an interactive SSH pane from the connecting machine to one approved destination. Owner-only and off by default; a destination whose resolution refused answers 422 carrying the outcome and launches nothing", + "Opens an interactive SSH pane from the connecting machine to one approved destination. Owner-only and off by default; a destination whose resolution refused answers 422 carrying the outcome and launches nothing. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", }, }, ); diff --git a/apps/server/api/src/api/ssh/resolve.route.ts b/apps/server/api/src/api/ssh/resolve.route.ts index 33fd5f5bb..47ff7ef71 100644 --- a/apps/server/api/src/api/ssh/resolve.route.ts +++ b/apps/server/api/src/api/ssh/resolve.route.ts @@ -48,7 +48,7 @@ export const sshResolveRoute = new Elysia() operationId: "sshResolveDestination", tags: ["ssh"], description: - "Resolves one destination token into the approved connection snapshot (or the named refusal, which is a successful answer to read). Runs on the connecting machine; nothing is launched or stored by this call", + "Resolves one destination token into the approved connection snapshot (or the named refusal, which is a successful answer to read). Runs on the connecting machine; nothing is launched or stored by this call. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", }, }, ); From 5508ad5d6cac197297a6acde48f6f77a5bc7c56d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:04:28 -0700 Subject: [PATCH 044/170] docs(ssh): comments tell the truth; sshDirsDir goes file-private; plan points at real sites The snapshot column is never serialized, the launch answer included. The disclosure rides the plane's route descriptions now, not a claimed past. The local artifacts doc names what actually sweeps the dir. The inversion attribution says the plane builds argv and the node resolves the binary. Two quoted spec sentences that name no spec are now plain substance. --- apps/node/agent/src/commands/ssh-aliases.ts | 18 ++++++++++-------- apps/node/agent/src/commands/ssh-shared.ts | 7 ++++--- apps/node/agent/src/ssh-dir-retention.ts | 4 ++-- .../api/src/db/types/subshells.db-types.ts | 4 ++-- .../api/src/services/nodes/local-launcher.ts | 16 +++++++++------- .../2026-10-07-ssh-anywhere-2-launcher.md | 17 ++++++++--------- packages/pane-runtime/src/ssh/ssh-render.ts | 4 ++-- 7 files changed, 37 insertions(+), 33 deletions(-) diff --git a/apps/node/agent/src/commands/ssh-aliases.ts b/apps/node/agent/src/commands/ssh-aliases.ts index 2b24da470..50091fae8 100644 --- a/apps/node/agent/src/commands/ssh-aliases.ts +++ b/apps/node/agent/src/commands/ssh-aliases.ts @@ -9,12 +9,13 @@ import { connectingHomeDir, resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared * 2026-10-07 §5/§7): * * - `ssh_discover_aliases` — bounded config parse answering NAMES only - * (§2: "Discovery returns names, not config file contents"). The answer is + * (discovery answers names, never config file contents). The answer is * run through the frozen wire validator on the way out. * - `ssh_resolve_config` — `ssh -G` evaluation of one alias through * pane-runtime's `resolveSshAliasConfig`, plus this machine's connecting - * account name for the review card ("review resolved destination AND - * connecting OS account"). A refusal is a SUCCESSFUL answer (the outcome + * account name on the outcome's `connectingAccount`: the review step + * needs the connecting OS account named alongside the resolved + * destination. A refusal is a SUCCESSFUL answer (the outcome * rides `accepted:false` in the data) so the human reads WHICH setting * blocked; only a missing ssh binary is a command failure. * @@ -24,11 +25,12 @@ import { connectingHomeDir, resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared * spawn — regardless of what the plane believes about it. The mirror is * fail-closed, so absent and unreadable refuse exactly like off. * - * The disclosure §2 demands before resolution runs — that a trusted - * `Match exec` can execute locally during `ssh -G` — belongs to the - * human-facing surface that dispatches it: in this tier, the plane's launch - * route (`/api/ssh/launch`, the SSH-launch API task) is where the disclosure - * rides, never the node. + * The disclosure the resolve step owes the human (the engine's note in + * pane-runtime `ssh-resolve.ts`: a `Match exec` the bounded config walk + * cannot see still executes locally under `ssh -G`) belongs to the + * human-facing surface, never the node: in this tier it rides the plane's + * resolve and launch route descriptions (`/api/ssh/resolve`, + * `/api/ssh/launch`), and the launcher UI copy ships with Plan 3. */ /** Does this machine permit SSH right now? (the one question every arm asks first) */ diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts index d7dd1b1b2..b6d895798 100644 --- a/apps/node/agent/src/commands/ssh-shared.ts +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -6,9 +6,10 @@ import { findBinary } from "@internal/pane-runtime"; * * The one question every SSH executor asks first is WHERE the ssh binary is, * and every answer must come from the node's own lookup ladder at this - * instant — never a path shipped over the wire (§2's inversion posture: the - * plane approves the destination, the machine owns the argv construction - * including which binary executes it). `SUBSHELL_SSH_PATH` is the operator + * instant — never a path shipped over the wire (inversion spec §5: the argv + * is built on the control plane, the binary is resolved here; the plane + * approves the destination and composes the command, this machine answers + * which ssh(1) executes it). `SUBSHELL_SSH_PATH` is the operator * override seam (the `CLAUDE_PATH` posture), and the knownPaths list is the * union of OpenSSH install locations on the supported platforms (Linux/macOS) * — PATH first through the ladder, these as the fallbacks it names. diff --git a/apps/node/agent/src/ssh-dir-retention.ts b/apps/node/agent/src/ssh-dir-retention.ts index 05b7b846a..7ab02eac1 100644 --- a/apps/node/agent/src/ssh-dir-retention.ts +++ b/apps/node/agent/src/ssh-dir-retention.ts @@ -51,8 +51,8 @@ const HOUR_MS = 3_600_000; /** Younger than this, the dir is kept: the launch write→meta-record grace. */ export const ORPHAN_SSH_DIR_MIN_AGE_MS = HOUR_MS; -/** The dir the sweep walks; exported so the daemon, tests and the plane-side twin name it from one place. */ -export function sshDirsDir(dataDir: string): string { +/** The dir the sweep walks. File-private: every consumer goes through {@link sweepOrphanSshDirs}, which names it itself. */ +function sshDirsDir(dataDir: string): string { return join(dataDir, "ssh"); } diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index a351af3e9..aba033df6 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -81,8 +81,8 @@ export interface SubshellTable { /** * an SSH-terminal pane carries its approved snapshot here; the value's * presence is the owner-only-input rule's trigger (spec §5.4). JSON-encoded - * `SshConnectionSnapshotWire`; never serialized to clients except the - * launch's own answer. See migration 0048. + * `SshConnectionSnapshotWire`; never serialized to clients, the launch + * answer included. See migration 0048. */ ssh: string | null; /** diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 26a340eee..6dd9d0b0c 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -412,13 +412,15 @@ export class LocalLauncher implements NodeLauncher { * and remote rows alike. * * `sshPane` is the ROW's kind fact: only the caller that holds the row can - * say whether this pane owns a config to sweep. Every non-ssh caller gets - * the pre-ssh list unchanged. A natural death removes the whole per-pane - * ssh dir on a NODE (the agent's exit sweep); locally the death paths the - * control plane runs do not, so the delete sweep unlinks the config here - * and the empty dir is the residue `subshellArtifacts` deliberately does - * not name (removeArtifacts unlinks FILES; deleting a directory would be a - * new power on a path list that can also carry caller-supplied entries). + * say whether this pane owns a config. Every non-ssh caller gets the + * pre-ssh list unchanged. The ssh arm serves the launcher seam: on an + * AGENT row the node launcher's twin arm adds the config FILE path to the + * machine's `remove_paths` list, and the dir itself is the machine's to + * take (the agent's exit sweep, then the orphan sweep in + * `ssh-dir-retention.ts`). A LOCAL ssh row never reaches this list: the + * manager's local branch names `logPath` directly and lets + * `sweepLocalSshDir` rm-rf the whole per-pane dir on every control-plane + * teardown path (unlinking the file and leaving the dir is not a removal). */ subshellArtifacts(id: string, sshPane = false): string[] { return sshPane ? [this.logPath(id), subshellSshConfigPath(id)] : [this.logPath(id)]; diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md index 636d17345..4133418b5 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md @@ -57,9 +57,9 @@ apps/server/api/src/ services/nodes/remote-launcher.ts # MODIFY: ship ssh block on the frame services/nodes/node-launcher.ts # MODIFY: LaunchPlan.ssh api/ssh/ # CREATE: index + aliases/resolve/launch/saved-hosts routes - api/subshells.ts # MODIFY: requireInputAccess owner-only rule - services/subshells.service.ts # MODIFY: execInTerminal + resolvePaneInputAccess rule - ws/live-subshell.ts # MODIFY: input frame owner check + lib/ssh-pane-access.ts # CREATE: the one ssh-input predicate + services/subshells.service.ts # MODIFY: sendSubshellInput + execInTerminal owner-only rule + ws/attach-resolve.ts # MODIFY: resolveAttach canInput ssh carve-out commands/__tests__/backup-cli-subprocess.test.ts # MODIFY: fixture strip-set (0047+0048) services/backups/database.ts # MODIFY: LATEST_BACKUP_MIGRATION docs/security.md # MODIFY §10: ssh.launch @@ -440,7 +440,7 @@ PATCH /api/ssh/preferences {defaultNodeId: string|null} 4. On `accepted:true`: the outcome's snapshot re-validated (the RPC wrapper already ran `parseNodeSshResolveOutcome`), then compose: `configPath = buildSshConfigPath(targetDataDir, newId)`; `fileContent = renderSshConfigContents(snapshot)`; `flags = sshOptionTokens(snapshot)` plus `-F configPath` FIRST; launch via `createSubshell({ userId, harnessId: "ssh", presetId: null, workingDir: `, name, nodeId })` with the two new plumbing params `ssh: { configPath, fileContent, snapshot }` and `presetFlags: flags`, both added to `createSubshell` in this task (see "manager plumbing" below). subshellEnv gains `SSH_AUTH_SOCK: snapshot.authAgentSocket` when non-null; `restartOnExit` forced off; row `ssh` = `JSON.stringify(snapshot)`. 5. Upsert the saved-host row (recency) and audit `ssh.launch` `{nodeId, destination, subshellId}` - AFTER a successful launch, never before (a refusal writes nothing to that trail beyond the pane-create trail the manager already handles... actually: audIT the launch even when it later fails to spawn? No: audit on success only, matching `subshell.create`'s own posture - name this in the code comment). -**Manager plumbing (`subshell-manager.service.ts createSubshell`)**: extend the input object with `ssh?: { configPath: string; fileContent: string; snapshot: SshConnectionSnapshotWire }` and `presetFlags?: string[]`. Behavior: when `presetFlags` present, the launch preset is `{...EMPTY_PRESET, flags: presetFlags}` (fresh object - EMPTY_PRESET is a shared constant, never mutate); when `ssh` present, pass `ssh` into the `LaunchPlan` (both launcher impls), pass `SSH_AUTH_SOCK` merge into `subshellEnv` AT THE CALL SITE (the ssh service computes the merged env and passes it through createSubshell's existing env surface - if createSubshell does not accept env additions, this is the one place it gains a `extraPaneEnv?: Record` param; READ the function first and take the minimal honest plumbing), and write `ssh: JSON.stringify(...)` into the row insert. Prompt-typing: ssh launches never carry a prompt (route refuses `prompt` on launch bodies). +**Manager plumbing (`subshell-manager.service.ts createSubshell`)**: extend the input object with `ssh?: { configPath: string; fileContent: string; snapshot: SshConnectionSnapshotWire }` and `presetFlags?: string[]`. Behavior: when `presetFlags` present, the launch preset is `{...EMPTY_PRESET, flags: presetFlags}` (fresh object - EMPTY_PRESET is a shared constant, never mutate); when `ssh` present, pass `ssh` into the `LaunchPlan` (both launcher impls), pass `SSH_AUTH_SOCK` merge into `subshellEnv` AT THE CALL SITE (the ssh service computes the merged env and passes it through createSubshell's existing env surface - if createSubshell does not accept env additions, this is the one place it gains a `extraPaneEnv?: Record` param; READ the function first and take the minimal honest plumbing), and write `ssh: JSON.stringify(...)` into the row insert. Prompt-typing: ssh launches never carry a prompt (the body schema carries no prompt field, Elysia strips unknowns; an ssh launch types nothing). - [ ] Step 1: the route/service tests (matrix above, faked `sendCommand` + faked node facts; assert gate order: gate-before-RPC (a node with the row off never receives a command); held-before-resolve; refusal-in-the-data 200 shape for resolve vs 422 for launch; saved-hosts list never carries another owner's rows; delete foreign -> 404; default node id set/clear.) - [ ] Step 2: fail; implement repository (`touch(destination, nodeId, alias?)` upsert-refresh, `markSaved`, `listSaved/listRecent(limit 20)`, `remove`), rpc wrappers (`sshDiscover(sshNodeId)` / `sshResolve(...)` -> validated plain objects, throwing a named `SshRpcError` on a frame error), service, routes, mount; docs/security lines. @@ -452,10 +452,9 @@ PATCH /api/ssh/preferences {defaultNodeId: string|null} ### Task 8: Owner-only input for ssh panes (REST + MCP + WS) **Files:** -- Modify: `apps/server/api/src/api/subshells.ts` `requireInputAccess` (add after the share check: `if (row.ssh !== null && (actor === "subshell-key" || row.userId !== auth.user.id)) throw new HTTPResponse(...403 {code: SUBSHELL_SHARE_VIEW_ONLY-ish or a new code SSH_OWNER_INPUT_ONLY}...)` - reuse the error shape pattern at :237 with a new code name; ALSO the input route's response map near `:327` lists response types - add the new one (this is the input route gaining a key; watch the treaty consumers, rebuild). -- Modify: `apps/server/api/src/services/subshells.service.ts` `execInTerminal` (~`:958`) and `resolvePaneInputAccess` (~`:1708`) - the SAME rule; put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (route, exec, WS data) - "one question, one place." -- Modify: `apps/server/api/src/ws/live-subshell.ts` input branch (~`:370`): compute from the same predicate (the WS resolves a cookie identity; live is human-only already, `bearerActor` false). -- Test: extend `apps/server/api/src/api/subshells/__tests__/subshell-input-live.test.ts` and the exec test home with: ssh row + edit grantee -> 403 (code named), ssh row + owner -> 200, ordinary row + edit grantee -> unchanged 200, ssh row + the pane's OWN key -> 403 (decision 5). +- Modify: `apps/server/api/src/services/subshells.service.ts` `sendSubshellInput` (the REST input door, and the MCP door through it: add the owner-only rule after the share check as a named 403 code `SSH_OWNER_INPUT_ONLY`; the input route's response map lists response types - add the new code there; watch the treaty consumers, rebuild) and `execInTerminal` (the SAME rule; exec TYPES, so the carve-out precedes the running checks) - put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (input, exec, WS data) - "one question, one place." +- Modify: `apps/server/api/src/ws/attach-resolve.ts` `resolveAttach`: the `canInput` flag every input frame honors is computed from the same predicate (the WS resolves a cookie identity; live is human-only already, `bearerActor` false). +- Test: extend `apps/server/api/src/api/subshells/__tests__/subshell-input-route.test.ts` and the exec test home with: ssh row + edit grantee -> 403 (code named), ssh row + owner -> 200, ordinary row + edit grantee -> unchanged 200, ssh row + the pane's OWN key -> 403 (decision 5). - [ ] Steps 1-4: failing tests -> implement -> green -> commit `"feat(api): ssh panes take input from their owner alone (spec §5.4)"` @@ -463,7 +462,7 @@ PATCH /api/ssh/preferences {defaultNodeId: string|null} ### Task 9: e2e - the Milestone 1 proof -**File:** Create `e2e/tests/22-ssh-terminal.spec.ts`. Read first: `e2e/tests/17-nodes-e2e.spec.ts` (backend boot + admin), `e2e/fixtures/sshd.ts` (exports - it already spawns a loopback sshd + keys + a config alias; reuse, do not rebuild), one existing spec's cleanup posture. +**File:** Create `e2e/tests/22-ssh-terminal.spec.ts`. Read first: `e2e/tests/12-nodes.spec.ts` (backend boot + admin), `e2e/fixtures/sshd.ts` (exports - it already spawns a loopback sshd + keys + a config alias; reuse, do not rebuild), one existing spec's cleanup posture. **Scenario (all local node, no agent link):** boot the e2e backend with `HOME=` and `SSH_AUTH_SOCK=` in its env ONLY within this spec (start `ssh-agent` + `ssh-add` via Bun.spawn at spec start; write `/.ssh/config` naming the fixture sshd under alias `e2e-dest`; the sshd fixture's own key trust lands in `/.ssh/known_hosts` - check the fixture's exports for what it already provisions, and extend the FIXTURE (not the spec) if it does not, so the next spec inherits a truthful helper). Steps: register admin; `PUT /api/nodes/local/ssh-enabled {on:true}`; `GET /api/ssh/aliases?node=local` contains `e2e-dest`; `POST /api/ssh/launch {node:"local", destination:"e2e-dest"}` -> 201; poll the pane's log through the existing capture/read route for the sshd fixture's shell banner/marker (run a command by TYPING owner input: `echo SSH-PANE-OK`); assert the log contains it. Then: second user with `edit` grant -> input 403 code SSH_OWNER_INPUT_ONLY; owner terminate; assert the config dir is gone (`/ssh/` absent) via fs. diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index e5421062d..f0087d197 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -111,8 +111,8 @@ function hopToken(hop: SshHopWire): string { * backslash escapes are the quoting OpenSSH's strdelim honors for * file-valued keywords; the escape is applied before the wrap so the value * is byte-exact after the parser's unquote. Plain paths stay unquoted: the - * rendered file is byte-stable in the snapshot (a dedup record's config is - * re-read by digest), and quoting every path would churn bytes for no gain. + * rendering is byte-stable for a given snapshot, and quoting every path + * would churn bytes for no gain. */ function configPathValue(path: string): string { if (!/[\s"\\]/.test(path)) return path; From 19b81f27b8291b54b9a0910caf151ab92547ade2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:05:26 -0700 Subject: [PATCH 045/170] docs(plans): Task 8's pointer records what actually shipped (no response-map key gained) --- docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md index 4133418b5..4c1aa100b 100644 --- a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md @@ -452,7 +452,7 @@ PATCH /api/ssh/preferences {defaultNodeId: string|null} ### Task 8: Owner-only input for ssh panes (REST + MCP + WS) **Files:** -- Modify: `apps/server/api/src/services/subshells.service.ts` `sendSubshellInput` (the REST input door, and the MCP door through it: add the owner-only rule after the share check as a named 403 code `SSH_OWNER_INPUT_ONLY`; the input route's response map lists response types - add the new code there; watch the treaty consumers, rebuild) and `execInTerminal` (the SAME rule; exec TYPES, so the carve-out precedes the running checks) - put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (input, exec, WS data) - "one question, one place." +- Modify: `apps/server/api/src/services/subshells.service.ts` `sendSubshellInput` (the REST input door, and the MCP door through it: add the owner-only rule after the share check as a named 403 code `SSH_OWNER_INPUT_ONLY`; the input route's response map lists response types - add the new code there; watch the treaty consumers, rebuild) and `execInTerminal` (the SAME rule; exec TYPES, so the carve-out precedes the running checks) - put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (input, exec, WS data) - "one question, one place." (As shipped, the predicate serves the two service verbs plus `resolveAttach`'s single `canInput`; the input route gained NO response-map key because 403 was already declared.) - Modify: `apps/server/api/src/ws/attach-resolve.ts` `resolveAttach`: the `canInput` flag every input frame honors is computed from the same predicate (the WS resolves a cookie identity; live is human-only already, `bearerActor` false). - Test: extend `apps/server/api/src/api/subshells/__tests__/subshell-input-route.test.ts` and the exec test home with: ssh row + edit grantee -> 403 (code named), ssh row + owner -> 200, ordinary row + edit grantee -> unchanged 200, ssh row + the pane's OWN key -> 403 (decision 5). From 0e719f1d15b9a1cffa1132a8a49da6a83aee34fb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:10:21 -0700 Subject: [PATCH 046/170] =?UTF-8?q?docs(ssh):=20the=20last=20bare=20=C2=A7?= =?UTF-8?q?2=20cites=20name=20their=20real=20sources;=20the=20canonical-de?= =?UTF-8?q?stination=20doc=20sheds=20its=20reader=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts | 4 ++-- packages/pane-runtime/src/ssh/ssh-discover.ts | 6 +++--- packages/pane-runtime/src/ssh/ssh-resolve.ts | 8 ++++---- packages/pane-runtime/src/ssh/ssh-spawn.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts index 12cf3c107..1070089f5 100644 --- a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -14,8 +14,8 @@ export interface SshSavedHostTable { * The canonical key: `user@host:port` for a resolved snapshot that carries * a user, `host:port` (no user prefix) when the snapshot's user is null, * the latter meaning the connecting account's own default. The spelling - * rule is {@link sshCanonicalDestination}; both writer and reader list - * import it, and it has no other home. Unique per owner + * rule is {@link sshCanonicalDestination}; both writers import it; the list reads back + * the stored strings. It has no other home. Unique per owner * (idx_ssh_saved_hosts_owner_destination). */ destination: string; diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts index 42cb47a4b..8c577c541 100644 --- a/packages/pane-runtime/src/ssh/ssh-discover.ts +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -50,7 +50,7 @@ export interface SshConfigWalk { hostBlocks: SshHostBlock[]; /** Directives seen before any `Host` line (the implicit-global context). */ globalDirectives: { keyword: string; value: string }[]; - /** A `Match exec` line was seen anywhere in the walk (a local-execution signal §2 refuses on). */ + /** A `Match exec` line was seen anywhere in the walk (the local-execution signal that resolution refuses on). */ matchExecSeen: boolean; /** The config files that were actually read, walk order (cycle detection is done by the walk itself). */ files: string[]; @@ -281,7 +281,7 @@ export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalk return; } if (active.has(real)) { - walk.includeCycle = true; // a file under its own includer: the §2 fact + walk.includeCycle = true; // a file under its own includer: the disclosure fact the discovery answer carries return; } if (seen.has(real)) return; // a second ordinary include: dedupe, not a cycle @@ -319,7 +319,7 @@ export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalk } } } else if (keyword === "match") { - // `Match exec …` runs a command locally (the §2 execution fact); the + // `Match exec …` runs a command locally (the execution fact resolution refuses on); the // criteria keyword pair is what matters, not the command. const criteria = arg.toLowerCase(); if (/(^|\s)exec(\s|$)/.test(criteria)) walk.matchExecSeen = true; diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index e9cd5d390..58c36f3e8 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -108,7 +108,7 @@ function setWhereAny(g: GLine[], keywords: readonly string[], unconfigured: read * versions (`LocalForward` vs the `locallyforward` echo, likewise remote) — * accepting both spellings means a version drift can only ever make us * REFUSE on a present directive, never miss one. Missing a forward would be - * the silent-omission defect §2 names. + * the silent-omission defect (spec 2026-10-07 §5.2). */ const FORWARD_LOCAL_KEYWORDS = ["localforward", "locallyforward"]; const FORWARD_REMOTE_KEYWORDS = ["remoteforward", "remotelyforward"]; @@ -159,8 +159,8 @@ function blockMatchesAlias(block: SshHostBlock, alias: string): boolean { /** * Resolve one alias on this machine into the approved snapshot, or a refusal - * naming WHY (§2: "refuse with a named limitation rather than silently - * changing connection semantics"). A refusal is an ACCEPTED command answer + * naming WHY (ssh-config's refusal-by-name rule: "refuse with a named limitation + * rather than silently changing connection semantics"). A refusal is an ACCEPTED command answer * (it rides `accepted:false`, in-the-data) so the human can read which * setting blocked and go edit the config. * @@ -254,7 +254,7 @@ export async function resolveSshAliasConfig( // Two blocks setting CONFLICTING destination facts cannot be reduced to // one route a human reviewed (first-obtained-wins makes -G deterministic, // but the ORDER that decided it is config-invisible noise the reviewer - // would never catch) — §2's config_ambiguous. One HostName plus one Port + // would never catch) — the grammar's config_ambiguous code. One HostName plus one Port // is NOT a conflict (measured on 10.2p1: the shared-set spelling refused // every `HostName x` + `Port n` block, the ordinary config shape) — each // family is counted on its own, and only a family with two different diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts index 44baaeebf..b2e394dcd 100644 --- a/packages/pane-runtime/src/ssh/ssh-spawn.ts +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -38,7 +38,7 @@ export interface SshProcessResult { * * @param argv - the command; argv[0] must be an absolute path — the callers * resolve `ssh` through the binary ladder BEFORE calling, and nothing here - * consults PATH to pick an executable (the §2 "explicit argv" rule). + * consults PATH to pick an executable (the explicit-argv rule of this engine's contract). * @param env - the COMPLETE child environment; no inheritance, by design * @param timeoutMs - hard deadline; the child is terminated at it */ From f568564b744e94d3f3488372d7c6017efc47b9fb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:48:03 -0700 Subject: [PATCH 047/170] fix(ssh): a clean-machine launch defaults to Terminal, not the ssh plugin The ssh plugin is type terminal, its binary is nearly always present, and the catalog arrives id-sorted: ssh stood before terminal and the last-resort usable[0] defaulted a first launch (onboarding wizard, every launch dialog, mobile New) to a bare ssh that exits without a destination. CI e2e 15-onboarding caught it; both picker rules now carry a plain-shell tier between the agent tier and the anything-usable fallback. Mirrored by hand on mobile per the rule's own comment. Spec 22's death-sweep poll gets its own doubled budget (it overran 60s once on a contended runner). Focused: web compat + full web suite (2466), mobile suite (126), e2e 15 and 22 green on this host. --- .changeset/ssh-launcher-tier.md | 7 ++++- .../src/lib/__tests__/agent-default.test.ts | 13 +++++++++ apps/client/mobile/src/lib/agent-default.ts | 18 ++++++++++-- .../src/lib/__tests__/subshell-compat.test.ts | 19 ++++++++++++ apps/server/web/src/lib/subshell-compat.tsx | 29 +++++++++++++++---- e2e/tests/22-ssh-terminal.spec.ts | 8 ++++- 6 files changed, 83 insertions(+), 11 deletions(-) diff --git a/.changeset/ssh-launcher-tier.md b/.changeset/ssh-launcher-tier.md index 3ca206127..a27ecc337 100644 --- a/.changeset/ssh-launcher-tier.md +++ b/.changeset/ssh-launcher-tier.md @@ -3,6 +3,8 @@ "@internal/node": minor "@internal/subshell-protocol": minor "@internal/pane-runtime": minor +"@internal/server-web": minor +"@internal/mobile": minor --- SSH launcher tier: an enabled node can open interactive SSH-terminal panes at any @@ -13,7 +15,10 @@ policy, host keys trusted `accept-new` in the connecting machine's own `known_hosts`); saved/recent hosts and a default connecting machine live on the plane keyed by the resolved destination. SSH-terminal panes accept input from their owner alone (sharees view). The per-node `ssh_enabled` gate from the previous tier -refuses every one of these doors until the owner turns it on. +refuses every one of these doors until the owner turns it on. A launch form with no +prior choice still defaults to the plain Terminal rather than to SSH, whose bare +launch has no destination to open; SSH stays pickable, and a preset names its own +agent. Deployment: update the server before the nodes. The link gate is exact-match at protocol 17, so a lagging agent is held (offline for every purpose but update) diff --git a/apps/client/mobile/src/lib/__tests__/agent-default.test.ts b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts index 113962a0b..b434cf050 100644 --- a/apps/client/mobile/src/lib/__tests__/agent-default.test.ts +++ b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts @@ -47,6 +47,19 @@ describe("defaultAgentId", () => { expect(defaultAgentId([TERMINAL, CLAUDE, CODEX], ALL_INSTALLED, null)).toBe("claude-code"); }); + it("a second terminal-type plugin does not steal the shell default", () => { + // Web mirror (CI e2e 2026-10-08): the catalog arrives id-sorted, so + // `ssh` (also type terminal, binary nearly always present) stands before + // `terminal`; the last resort must still land on the plain shell, which + // runs presetless, not on a bare `ssh` that exits without a host. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + expect(defaultAgentId([SSH, TERMINAL], ALL_INSTALLED, null)).toBe("terminal"); + // A declared agent still outranks the shell tier. + expect(defaultAgentId([SSH, TERMINAL, CLAUDE], ALL_INSTALLED, null)).toBe("claude-code"); + // The shell tier is a preference, not a filter: ssh alone still fills. + expect(defaultAgentId([SSH], ALL_INSTALLED, null)).toBe("ssh"); + }); + it("a missing type (older server) is still pickable, but no longer outranks a declared agent", () => { // The middle tier reads `=== "agent-harness"` rather than // `!== "terminal"` now, because a third type exists (network) and an diff --git a/apps/client/mobile/src/lib/agent-default.ts b/apps/client/mobile/src/lib/agent-default.ts index b5232b3e1..b22b80629 100644 --- a/apps/client/mobile/src/lib/agent-default.ts +++ b/apps/client/mobile/src/lib/agent-default.ts @@ -16,8 +16,8 @@ import type { SubshellView } from "@/types/subshell"; * stays the backstop for the race. * * The order: the harness of the user's most recent subshell while usable → - * the first usable plugin whose type IS `agent-harness` → anything usable → - * null. + * the first usable plugin whose type IS `agent-harness` → the plain shell + * (id `terminal`) → anything usable → null. * * That middle tier is a positive test, not the old `!== "terminal"`. The * exclusion was right while two types existed and became wrong the moment a @@ -26,6 +26,13 @@ import type { SubshellView } from "@/types/subshell"; * longer wins that tier; it still reaches the "anything usable" fallback, so * nothing becomes unpickable. * + * The shell tier is the ssh plugin's doing, mirrored from web (CI e2e + * 2026-10-08): `ssh` is also type terminal and its binary is nearly always + * present, and the catalog arrives id-sorted, so anything-usable would land + * on a bare `ssh` that exits without a host. The plain shell runs presetless, + * so it is the fallback; it stays a preference, not a filter, so an + * ssh-only catalog still fills. + * * @param plugins - the instance catalog (`GET /api/plugins`) * @param nodeHarnessInstalled - whether the SELECTED node reports this * harness's binary installed (caller passes `true` when the node carries no @@ -49,7 +56,12 @@ export function defaultAgentId( if (recent && usable(recent)) return recent.id; } const usableAgents = plugins.filter(usable); - return usableAgents.find((p) => p.type === "agent-harness")?.id ?? usableAgents[0]?.id ?? null; + return ( + usableAgents.find((p) => p.type === "agent-harness")?.id ?? + usableAgents.find((p) => p.id === "terminal")?.id ?? + usableAgents[0]?.id ?? + null + ); } /** diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts index dd1b92cc0..ebd06f1cb 100644 --- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts @@ -222,6 +222,25 @@ describe("defaultAgentId", () => { const plugins = [plugin({ id: "claude-code", name: "Claude Code", installed: false }), TERM]; expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); }); + it("a second terminal-type plugin does not steal the shell default", () => { + // The clean-machine regression (CI e2e 2026-10-08): the ssh plugin is + // type `terminal`, its binary exists on nearly every host, and the + // catalog arrives id-sorted, so `ssh` stands BEFORE `terminal` and the + // old last-resort `usable[0]` defaulted a first launch to a bare `ssh` + // that exits without a destination. The plain shell stays the fallback. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + const plugins = [SSH, TERM]; + expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); + // And it stays so under an agent catalog too: the agent tier still wins. + const withAgent = [SSH, TERM, CLAUDE]; + expect(defaultAgentId(buildAgentOptions(withAgent, null), withAgent, null)).toBe("claude-code"); + }); + it("an ssh-only catalog still defaults to ssh", () => { + // The shell tier is a preference, not a filter: when the plain shell is + // not usable, the only pickable plugin must still fill the field. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + expect(defaultAgentId(buildAgentOptions([SSH], null), [SSH], null)).toBe("ssh"); + }); it("null when nothing is usable — and on an empty catalog", () => { const plugins = [plugin({ id: "x", name: "X", installed: false })]; expect(defaultAgentId(buildAgentOptions(plugins, HERE), plugins, null)).toBeNull(); diff --git a/apps/server/web/src/lib/subshell-compat.tsx b/apps/server/web/src/lib/subshell-compat.tsx index 893a5b243..8f772e7a3 100644 --- a/apps/server/web/src/lib/subshell-compat.tsx +++ b/apps/server/web/src/lib/subshell-compat.tsx @@ -101,12 +101,23 @@ export function buildAgentOptions(plugins: readonly LaunchAgent[], node: Node | ); } +/** The built-in plain shell, by plugin id; the fallback tier's favorite. */ +const TERMINAL_PLUGIN_ID = "terminal"; + /** * The agent the picker should hold when the user has not chosen one: the most * recent subshell's agent when it is still usable, else the first usable - * NON-terminal agent (a shell is a fallback, not the headline), else anything - * usable, else null (nothing to fill — the picklist greys everything and the - * dead-end hints carry the story). + * NON-terminal agent (a shell is a fallback, not the headline), else the + * plain shell, else anything usable, else null (nothing to fill — the + * picklist greys everything and the dead-end hints carry the story). + * + * The shell tier is not paranoia about order; it is the ssh plugin (CI e2e + * 2026-10-08): `ssh` is type `terminal`, its binary is on nearly every host, + * and the catalog arrives id-sorted, so on a clean machine `usable[0]` was + * SSH, not Terminal — a first launch defaulted to a bare `ssh` that exits + * without a destination. The wizard's own subtitle names the fallback ("A + * plain terminal is always available with nothing to install"), so the rule + * reads its id, the same named plugin. * * @param options - output of {@link buildAgentOptions} for the current pair * @param plugins - the plugin rows, for the terminal lookup by id @@ -125,9 +136,15 @@ export function defaultAgentId( // wrong the moment a third did: a network plugin is not a slower agent, and // "not a terminal" would have made one the headline default. An absent // `type` (a payload older than the field) therefore no longer WINS the - // first tier — it still reaches the `usable[0]` fallback below, so nothing - // becomes unpickable, it just stops outranking a declared agent. - return (usable.find((o) => typeById.get(o.value) === "agent-harness") ?? usable[0])?.value ?? null; + // first tier — it still reaches the fallbacks below, so nothing becomes + // unpickable, it just stops outranking a declared agent. + return ( + ( + usable.find((o) => typeById.get(o.value) === "agent-harness") ?? + usable.find((o) => o.value === TERMINAL_PLUGIN_ID) ?? + usable[0] + )?.value ?? null + ); } /** diff --git a/e2e/tests/22-ssh-terminal.spec.ts b/e2e/tests/22-ssh-terminal.spec.ts index 5ce5a4f0a..f4c5a7349 100644 --- a/e2e/tests/22-ssh-terminal.spec.ts +++ b/e2e/tests/22-ssh-terminal.spec.ts @@ -48,6 +48,12 @@ const ORIGIN = `http://127.0.0.1:${PORTS.ssh}`; /** Boot + sshd + agent + a real tmux ssh pane: budgets on spec 12's scale. */ const READY_TIMEOUT = 60_000; const SPAWN_TIMEOUT = 60_000; +// The death sweep is a longer chain than a boot: typed `exit` -> the ssh +// process ends -> the tmux pane-died hook -> reportExit -> the server's local +// sweep rm. On a contended CI runner that whole chain overran the 60s boot +// budget once and passed on retry, so it gets its own, doubled. It never +// weakens the assertion: the dir must still be gone. +const SWEEP_TIMEOUT = 120_000; const missing = missingSshBins(); test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join(", ")}) - spec 22 needs a real sshd`); @@ -406,7 +412,7 @@ test("ssh pane: gated launch to the fixture sshd, owner input echoes from the re // observable because the spec passed the path in. const exitLine = await admin.post(`/api/subshells/${subshellId}/input`, { data: { text: "exit" } }); expect(exitLine.ok(), await exitLine.text()).toBe(true); - await pollUntil(`ssh config dir ${sshDir} outlived the pane's own exit`, () => !existsSync(sshDir), READY_TIMEOUT); + await pollUntil(`ssh config dir ${sshDir} outlived the pane's own exit`, () => !existsSync(sshDir), SWEEP_TIMEOUT); // Retire the parked row (terminate accepts a dead pane, delete removes // it; the delete path's unlink-only artifact list has nothing left to From 5360ddfb86455fe1f555eb1d79c5955b5327ed7f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:26:39 -0700 Subject: [PATCH 048/170] docs(ssh): reviewer - the plugins param feeds the agent-harness TYPE lookup The shell tier added in 62980d6f reads its id off options, so the @param plugins line no longer means 'terminal lookup by id'; corrected to say what it now feeds (the typeById agent-harness map). Mirror parity: mobile's rule cited subshell-compat.ts (it is .tsx) and spelled the shell id as an inline literal where web uses a named constant; both now match. No behavior change. Focused: web compat 32, mobile agent-default 16; biome + verify-types clean. --- apps/client/mobile/src/lib/agent-default.ts | 8 ++++++-- apps/server/web/src/lib/subshell-compat.tsx | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/client/mobile/src/lib/agent-default.ts b/apps/client/mobile/src/lib/agent-default.ts index b22b80629..1ee2ba82c 100644 --- a/apps/client/mobile/src/lib/agent-default.ts +++ b/apps/client/mobile/src/lib/agent-default.ts @@ -1,10 +1,14 @@ import type { PluginView } from "@/types/plugin"; import type { SubshellView } from "@/types/subshell"; +/** The built-in plain shell, by plugin id; the shell tier's favorite. Name + * matches the web mirror's constant of the same spelling. */ +const TERMINAL_PLUGIN_ID = "terminal"; + /** * The New screen's default-agent rule (spec 2026-09-13 §5) — the mobile * mirror of `defaultAgentId` in - * `apps/server/web/src/lib/subshell-compat.ts` (its call site there is + * `apps/server/web/src/lib/subshell-compat.tsx` (its call site there is * `apps/server/web/src/components/subshell-picker/new-subshell-form.tsx`), * kept in step by hand (the repo convention for this screen; change one, * change both). Pure so the rule is testable without a device. @@ -58,7 +62,7 @@ export function defaultAgentId( const usableAgents = plugins.filter(usable); return ( usableAgents.find((p) => p.type === "agent-harness")?.id ?? - usableAgents.find((p) => p.id === "terminal")?.id ?? + usableAgents.find((p) => p.id === TERMINAL_PLUGIN_ID)?.id ?? usableAgents[0]?.id ?? null ); diff --git a/apps/server/web/src/lib/subshell-compat.tsx b/apps/server/web/src/lib/subshell-compat.tsx index 8f772e7a3..3d773ee47 100644 --- a/apps/server/web/src/lib/subshell-compat.tsx +++ b/apps/server/web/src/lib/subshell-compat.tsx @@ -120,7 +120,8 @@ const TERMINAL_PLUGIN_ID = "terminal"; * reads its id, the same named plugin. * * @param options - output of {@link buildAgentOptions} for the current pair - * @param plugins - the plugin rows, for the terminal lookup by id + * @param plugins - the plugin rows, for the agent-harness type lookup (the + * shell tier reads its id off `options`, not these rows) * @param recentHarnessId - harnessId of the user's most recent subshell, null when none */ export function defaultAgentId( From faee0f3a614a4142583327ebfa367880c8b37d17 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:41:27 -0700 Subject: [PATCH 049/170] fix(ssh): sweep the local ssh config dir in the shared death transition Natural-death cleanup hinged on a single best-effort signal: the tmux pane-died hook firing reportExit, which was the only path that removed /ssh/ for a local ssh pane. When the hook was lost (a dropped exec, a 401), the 60s reconcile still parked the row dead but never swept, so the config dir leaked until a manual teardown. CI e2e spec 22 flaked exactly here - on all retries - while the same code passed elsewhere, because the sweep had no deterministic driver. sweepLocalSshDir now sits in #applyDeath, the ONE place a running row is stamped alive->dead, beside previewCacheDrop and the tmux-server reap - the pane's artifacts die with the pane, whichever hand first notices. Guarded on !restarted and by the helper (local AND ssh). The exit-report sweep in subshells.service stays for the hook-after-retire race, where applySelfReportedExit returns early and never reaches #applyDeath: the two sweeps cover two different orderings, not one job. Focused: manager-ssh death-sweep (+2 new cases), exit/terminate sweep, remote/ service/maintenance/restart suites, full server 4082 pass. --- .../__tests__/subshell-manager-ssh.test.ts | 61 +++++++++++++++++++ .../src/services/subshell-manager.service.ts | 9 +++ .../api/src/services/subshells.service.ts | 8 +++ 3 files changed, 78 insertions(+) diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts index 942f534f0..5ec7a8007 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -184,6 +184,67 @@ describe("createSubshell ssh plumbing", () => { }); }); +describe("a death transition sweeps the LOCAL ssh config dir (#applyDeath)", () => { + // The exit REPORT path (service.reportExit) sweeps on its own; this covers the + // gap it cannot: a `pane-died` hook LOST entirely, so no report ever fires and + // only the 60 s reconcile learns the process is gone. The reconcile drives the + // SAME shared `#applyDeath` that `applySelfReportedExit` flows into, so calling + // the manager directly (bypassing the service's sweep) proves the transition + // itself removes the dir. CI e2e spec 22 flaked when this was the only missing + // sweep: natural death whose hook was lost left /ssh/ on disk. + function localDir(id: string): string { + return join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + } + + it("a running LOCAL ssh pane going dead removes its config dir", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 0, new Date().toISOString()); + expect(existsSync(localDir(id))).toBe(false); // #applyDeath, not the service, removed it + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); + }); + + it("an AGENT ssh pane going dead sweeps NOTHING on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: "node-remote-death", + ssh: JSON.stringify(SNAPSHOT), + }); + // A dir carrying the pane's id on THIS host's disk must survive: it is not + // this plane's to remove. #applyDeath's sweep guard is what declines it. + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 1, new Date().toISOString()); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); + describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { it("a LOCAL row with a snapshot gets its config DIR swept — whatever its harness id claims", async () => { const id = crypto.randomUUID(); diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index cb43cd248..32659c9fa 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -1616,6 +1616,15 @@ export class SubshellManagerService { // "Never" means opted out OR the backoff limit was exhausted (the // sweep gives up at that count, so the bearer would linger otherwise). const restarted = await this.maybeAutoRestart(fresh); + // The ssh config dir is a pane artifact like the preview cache and the idle + // tmux server below: it dies with the pane, HERE, in the ONE shared + // transition. Doing it only from the tmux `pane-died` hook's self-report + // left a gap — the hook is best-effort, and a report lost to a 401 or a + // dropped exec left the dir behind until a manual teardown; the 60 s + // reconcile reaches this same line whenever the process is gone, so this is + // the deterministic sweep. Guarded on `!restarted` (never clobber a live + // re-render) and by the helper itself (local ∧ ssh; every other row no-ops). + if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must // not linger. Revoke failures here must not abort the sweep for the diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 9235d30da..a3af12539 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -694,6 +694,14 @@ export class SubshellsService extends BaseService { * ssh config sweep (its own guard skips every other row shape); the death * transition itself carries just the timestamp. * + * The sweep here is DELIBERATELY unconditional, not deferred to the + * transition below: `applySelfReportedExit` returns early when the row is + * already retired, and the hook-vs-retire race (a terminate kills the pane, + * its `pane-died` report lands after the row is gone) must still remove the + * config. The complementary gap — a `pane-died` hook lost entirely, so no + * report ever reaches here — is closed by the sweep in the shared + * `#applyDeath`, which the 60 s reconcile drives off the process being gone. + * * @param id - the subshell whose pane exited * @param exitCode - tmux's `#{pane_dead_status}`, null when it could not be read */ From a6dcbd4dc16e36eea1a48884b3ba6657109935e2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:46:00 -0700 Subject: [PATCH 050/170] fix(ssh): close the last two ssh-config leak paths and correct the sweep docs Reviewer follow-ups to e08c863f: - the reconcile no-socket fast path stamps alive->dead directly, bypassing #applyDeath, so it now repeats the (guarded) sweep - no alive->dead path misses the dir; the #applyDeath doc no longer claims to be the ONE such place (it never was: this branch existed before, unreachable for real ssh rows but present in fixtures). - the create rollback (launch threw after writing the config) never swept, so a half-built local /ssh/ leaked to manual delete - now retired with the row, guard no-ops remote/non-ssh creates. - sweepLocalSshDir's helper docblock said 'all four teardown points'; it names the death transition and the create rollback now that they call it too. - test: the agent-row death case asserts the transition landed (alive=0) so the sweep's decline is proven exercised, not skipped; file doc lists the new describe. Full server suite 4082 pass; verify-types + biome clean. --- .../__tests__/subshell-manager-ssh.test.ts | 9 +++++ .../api/src/services/ssh-launch.service.ts | 8 ++++- .../src/services/subshell-manager.service.ts | 36 +++++++++++++------ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts index 5ec7a8007..569f2365b 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -33,6 +33,11 @@ import { SubshellManagerService } from "@/services/subshell-manager.service.js"; * `row.harnessId === "ssh"` — a hand-built row that names the ssh harness * without a snapshot gets no config cleanup, and a snapshot on any harness * row gets it. + * + * The third describe pins the DEATH-transition sweep: a local ssh pane going + * dead through the shared `#applyDeath` removes its config dir even when the + * exit report's own sweep never fired (the lost-`pane-died`-hook case), and an + * agent row going dead there sweeps nothing on this host. */ const OWNER = "u-ssh-plumb"; @@ -240,6 +245,10 @@ describe("a death transition sweeps the LOCAL ssh config dir (#applyDeath)", () mkdirSync(localDir(id), { recursive: true }); const { manager } = mkManager(); await manager.applySelfReportedExit(id, 1, new Date().toISOString()); + // The death DID land through #applyDeath (proving the sweep's decline was + // exercised, not skipped because the transition never ran) yet the agent + // row's dir stands on this host. + expect((await subshellsRepo.findById(id))?.alive).toBe(0); expect(existsSync(localDir(id))).toBe(true); rmSync(localDir(id), { recursive: true, force: true }); }); diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 5bc53f977..ca2d9840d 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -148,12 +148,18 @@ export function composeSshLaunch(args: { * Best-effort `rm -rf` of the per-pane ssh config dir a LOCAL ssh pane wrote * on this disk: `/ssh/` — the dirname of the * byte-derived config path the LocalLauncher wrote into. ONE function, called - * by all four teardown points, so no copy can drift: + * from every site where such a pane stops existing, so no copy can drift: * + * - the shared death transition (the manager's `#applyDeath`), the DETERMINISTIC + * driver: the 60 s reconcile reaches it whenever the process is gone, so a + * lost `pane-died` hook can no longer leave the dir behind (e2e spec 22's + * intermittent miss); * - the pane's own death report (the service's `reportExit`), which fires even * when the row is already retired: a terminate kills the pane, the tmux * `pane-died` hook races the retire stamp, and whichever order they land in * the config must be gone once the report has been heard; + * - the create rollback: a LOCAL ssh launch that wrote its config then threw + * past the spawn retires the row here, and the half-built dir must go with it; * - the terminate verb, AFTER the kill: terminate revokes the pane's token * synchronously with the kill, so the dying hook's report can arrive 401 * and run no sweep at all (e2e spec 22) — the hand that kills must also diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 32659c9fa..daea235e1 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -562,6 +562,12 @@ export class SubshellManagerService { } catch { // kill is best-effort; the row + token rollback below must still run } + // A LOCAL ssh launch writes its config inside `launch`, before the spawn + // that threw here — so retiring the row must retire the dir too, or a + // half-built `/ssh/` leaks until a manual delete. The guard + // no-ops every non-local / non-ssh create; `targetNode` keeps a remote + // pane's dir on ITS node's disk untouched. + sweepLocalSshDir({ id, nodeId: targetNode, ssh: ssh ? JSON.stringify(ssh.snapshot) : null }); await this.#subshells.markTerminated(id, new Date().toISOString()); await this.#revokeTokenOrUnlink(id); publishLive({ kind: "subshell.changed", id }); @@ -1432,6 +1438,13 @@ export class SubshellManagerService { publishLive({ kind: "subshell.changed", id: row.id }); logger.info(`subshell process absent (no socket): ${row.id}`); await this.#notifyDeath(row); + // This no-socket branch stamps alive→dead directly rather than + // through #applyDeath, so the ssh-config sweep that lives there must + // be repeated here (guard no-ops every non-local / non-ssh row). A + // real ssh pane always carries a socket at create, so this is the + // fixture-shaped belt that keeps "whichever hand first notices + // removes the dir" true for EVERY alive→dead stamp, not most. + sweepLocalSshDir(row); } } continue; @@ -1567,16 +1580,19 @@ export class SubshellManagerService { } /** - * The shared death transition — the ONE place a running row is stamped - * alive→dead. Every entry point that learns of a death calls this: the - * sweep (local tmux probe or the agent `probe`/`exit` census), an agent - * `exit` event, and the `subshells_report` reconnect census. Idempotent by - * construction: the stamp + death push fire only while the FRESH row is - * still `running` with `alive: 1`, and a row a restart owns - * (`restartInFlight`) is off-limits. After the (possible) transition the - * auto-restart ladder runs for parked rows, and a subshell that will never - * come back — opted out or backoff-exhausted — has its bearer revoked here. - * The cached preview dies with the pane. + * The shared death transition — where a probe- or report-driven death is + * stamped alive→dead AND the pane's artifacts (preview cache, ssh config dir, + * idle tmux server) are cleared. Every entry point that learns a LIVE pane is + * gone calls this: the sweep (local tmux probe or the agent `probe`/`exit` + * census), an agent `exit` event, and the `subshells_report` reconnect census. + * (The reconcile's no-socket fast path — a row that cannot be alive — stamps + * directly rather than routing here, and repeats the ssh sweep so no + * alive→dead path misses it.) Idempotent by construction: the stamp + death + * push fire only while the FRESH row is still `running` with `alive: 1`, and a + * row a restart owns (`restartInFlight`) is off-limits. After the (possible) + * transition the auto-restart ladder runs for parked rows, and a subshell that + * will never come back — opted out or backoff-exhausted — has its bearer + * revoked here. * @param row - the row as the caller saw it; only its id must still be true * @param opts.exitCode - the observed pane exit code (null = never seen) * @param opts.endedAt - ISO stamp for the death (the agent's clock for remote events) From 91a31595916bedd26c5f73f149c9cc367848a768 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:47:41 -0700 Subject: [PATCH 051/170] test(ssh): cover the reconcile no-socket sweep The reviewer's optional follow-up: the no-socket alive->dead fast path stamps directly (bypasses #applyDeath) yet must still sweep a local ssh dir. A dedicated user keeps reconcile() off every other test's socketed rows, and the no-socket branch precedes any tmux probe. Closes the last untested edge of the death-sweep work. --- .../__tests__/subshell-manager-ssh.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts index 569f2365b..ed35602ea 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -252,6 +252,34 @@ describe("a death transition sweeps the LOCAL ssh config dir (#applyDeath)", () expect(existsSync(localDir(id))).toBe(true); rmSync(localDir(id), { recursive: true, force: true }); }); + + it("the reconcile no-socket path sweeps too (it stamps alive->dead without #applyDeath)", async () => { + // The one alive->dead stamp that does NOT funnel through #applyDeath: a row + // with no socket cannot be alive, so the sweep fast-path retires it directly. + // It must still remove a local ssh pane's config dir, or the "whichever hand + // first notices removes it" guarantee has a hole. A dedicated user keeps + // reconcile() from touching any other test's (socketed, real-tmux) rows. + const id = crypto.randomUUID(); + created.push(id); + const user = "u-ssh-nosocket"; + await subshellsRepo.create({ + id, + userId: user, + harnessId: "ssh", + name: "no-socket local ssh", + workingDir: tmpdir(), + tmuxSocket: null, // -> the no-socket branch, before any tmux probe + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.reconcile(user); + expect(existsSync(localDir(id))).toBe(false); + expect((await subshellsRepo.findById(id))?.alive).toBe(0); + }); }); describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { From 41fd6899b58a3bf5d97d3b6ff571357dcbe424d4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 02:23:47 -0700 Subject: [PATCH 052/170] docs(protocol): retire two stale ssh-frame comments the launcher made false Whole-branch review Minor: ssh-config still said the resolve command's node-side dispatch 'is not wired on this tier' (the launcher tier wired it), and ssh-frames said 'protocol 17 never shipped' (this branch ships 17) plus pointed at a brokered-session frames file that exists nowhere in this lineage (those commands are the M2 sealed-relay milestone). Truth now; same comments-tell-the-truth class the branch has zeroed. Comments only. Protocol suite 372 pass; biome + prose clean. --- packages/subshell-protocol/src/ssh-config.ts | 9 +++++---- packages/subshell-protocol/src/ssh-frames.ts | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts index 1fe88695d..4ce8a511b 100644 --- a/packages/subshell-protocol/src/ssh-config.ts +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -7,10 +7,11 @@ * here is a passthrough: the human-facing resolution step produces this shape * once, and the NODE re-validates it with {@link * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot (of the - * destination product's four, only the resolve command survives; its node-side - * dispatch is not wired on this tier). The plane approving a snapshot is not - * the load-bearing defense - this validator refusing a forbidden member is, on - * the machine that will actually run ssh. + * destination product's four, only the resolve command survives, and the + * launcher tier wires its node-side dispatch in + * `apps/node/agent/src/commands/ssh-aliases.ts`). The plane approving a + * snapshot is not the load-bearing defense - this validator refusing a forbidden + * member is, on the machine that will actually run ssh. * * The eight absent-forbidden members (`proxyCommand`, `forwards`, `tunnels`, * `localCommands`, `remoteCommand`, `sendEnv`, `setEnv`, `escapes`) are typed diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index e71c91a73..da22d140a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -7,9 +7,11 @@ * * The rest of the family the earlier command table named (`ssh_test_ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ - * launch`, `ssh_input_control`) retired with the destination execution - * product; protocol 17 never shipped, so their `type` arms are deleted, not - * refused. The brokered-session commands live in `ssh-session-frames.ts`. + * launch`, `ssh_input_control`) retired with that destination-execution + * product, which never shipped; their `type` arms are therefore deleted, not + * refused. The brokered-session commands are NOT defined on this branch — they + * arrive with the sealed agent-relay milestone (M2); no file names them here yet + * (`ssh-limits.ts` carries their limits ahead of the frames, as it declares). * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact From f7379a3f47588eb35cb8537821f14c4717f9d4f3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 02:33:37 -0700 Subject: [PATCH 053/170] test(backups): give the cancel-to-404 poll a wall deadline Not an ssh change: the #318 backup test 'restricts download jobs to their owner and cancels temporary output' polls a fixed ~1s for the async job-cleanup to retire the record. CI's throttled slice ran it at ~1.8s and it flaked twice across this stack (#336 rerun, #337 shard 1) while passing elsewhere - a runner-speed-dependent budget. Swapped the fixed attempt-count for a wall deadline that exits the instant 404 lands. Pre-existing flake, surfaced here, same 'expose it, fix it' call as the web fetch-reset earlier this branch. --- .../api/src/api/__tests__/backups-route.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/api/src/api/__tests__/backups-route.test.ts b/apps/server/api/src/api/__tests__/backups-route.test.ts index 00982ee1c..386732e6f 100644 --- a/apps/server/api/src/api/__tests__/backups-route.test.ts +++ b/apps/server/api/src/api/__tests__/backups-route.test.ts @@ -287,10 +287,18 @@ describe("admin archive endpoints", () => { (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token, { method: "DELETE" }))).status, ).toBe(200); expect((await app.fetch(authedRequest(`/api/admin/backups/download/${id}`, admin.token))).status).toBe(409); + // The cancel retires the job record on the async cleanup path, NOT inside + // the DELETE response, so the 404 arrives a beat later. CI's throttled + // 0.4-CPU slice starved the old fixed 100x10ms (~1s) budget past ~1.8s and + // this test flaked twice across the ssh stack (#336, #337) while passing on + // rerun - a runner-speed-dependent budget, which is never deterministic. A + // wall deadline is: it exits the instant 404 lands and only spends the extra + // room when cleanup is genuinely slow. let status = 200; - for (let attempt = 0; attempt < 100 && status !== 404; attempt++) { + const goneBy = Date.now() + 8_000; + while (status !== 404 && Date.now() < goneBy) { status = (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token))).status; - if (status !== 404) await Bun.sleep(10); + if (status !== 404) await Bun.sleep(20); } expect(status).toBe(404); }); From e74530304aeb2c3d64f04858f059f3eba6be898d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:12:27 -0700 Subject: [PATCH 054/170] docs(plans): Plan 3 - the destination-first UI (access downgrade + Connect page + view-only proof) --- ...-10-08-ssh-anywhere-3-destination-first.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-ssh-anywhere-3-destination-first.md diff --git a/docs/superpowers/plans/2026-10-08-ssh-anywhere-3-destination-first.md b/docs/superpowers/plans/2026-10-08-ssh-anywhere-3-destination-first.md new file mode 100644 index 000000000..2334eb995 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ssh-anywhere-3-destination-first.md @@ -0,0 +1,125 @@ +# SSH Anywhere - Plan 3: The Destination-First UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Milestone 1's human half: a `Connect` page that leads with the destination question ("where do you want to work?"), feeds itself from the chosen machine's aliases plus saved and recent hosts, launches the SSH pane with one button, names the gate remedy in copy, and shows an SSH-terminal pane as view-only to everyone but its owner. + +**Architecture:** The server already owns every rule (tier 2's `/api/ssh` surface, the owner-only input predicate, the WS `canInput` stamp). This tier adds ONE server behavior - the per-viewer `access` in subshell views downgrades to `view` for non-owners of an ssh pane, so every client's existing `access === "view"` posture (input disabled, read-only terminal) becomes truthful for free - and then thin UI: a `use-ssh` hook set following `use-prompts`, a `/connect` route following the launch-form patterns, a SearchableSelect destination field, and the disclosure copy spec §11 demands. + +**Tech Stack:** Bun + TypeScript, Elysia (`t` schemas with descriptions), TanStack Router (file-based) + React Query, Tailwind with the design-system role tokens, `@internal/node-admin`'s `apiFetch`/`apiPost`, Playwright e2e. + +## Global Constraints + +- Work ONLY in `/home/theo/projects/wt-ssh-anywhere-3` (branch `feat/ssh-anywhere-3`). Never touch other checkouts. +- Tests: `env -u SHELLOPTS -u BASHOPTS bun test `; check the reported file count. +- No U+2014/U+2013 anywhere (`bun run lint:prose` scans shipped strings and docs; UI copy too). +- Design system (`docs/design-system.md` enforced by `bun run lint:design`): role tokens only (`text-label font-strong`, `text-detail` via its spellings in that doc), **UI explanations at most two sentences, no em dashes in copy**, required input gold `*` + gold caption when empty, hard errors `text-destructive` red (`fieldErrorToned` from `src/lib/form.ts:88` reads the split), 4px grid, no 12px. +- Terminology is exactly three nouns (spec §11): **destination**, **connecting machine**, **key source**. Key source does not appear in this tier's UI (M2); do not invent it. +- The disclosure obligation (tier 1's engine note, satisfied server-side by the route descriptions in fe125346): the UI copy must say, before the button, in at most two sentences, that resolving runs `ssh -G` on the connecting machine and a hidden `Match exec` can run a local command. +- Route-level: `/api/ssh` response shapes are already typed by `apps/server/api/src/api/ssh/ssh-views.ts` - the SPA's local view types must mirror them field-for-field (the SPA's house style: local `lib/*.ts` types + `apiFetch`, mirroring `lib/prompts.ts`). +- Focused verification while iterating; the full `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` at Task 6 only. `bunx turbo build` whenever `apps/server/api` schemas changed (treaty/dist consumers) and before e2e (it serves the web dist - memory). +- Commit as theo@suteki.nu; push only at Task 6. + +## File Structure (map) + +``` +apps/server/api/src/api/models.ts # MODIFY: SubshellSchema gains ssh: boolean +apps/server/api/src/services/subshells.service.ts # MODIFY: viewer-relative access downgrade for ssh panes (ONE helper, both view paths) +apps/server/api/src/api/subshells/__tests__/... # MODIFY: view-access tests extended +apps/server/web/src/lib/ssh.ts # CREATE: view types mirroring ssh-views.ts + copy constants +apps/server/web/src/hooks/use-ssh.ts # CREATE: queries/mutations (aliases/saved-hosts/preferences) + useLaunchSsh +apps/server/web/src/components/connect/connect-panel.tsx # CREATE: the destination-first flow +apps/server/web/src/components/connect/saved-hosts-section.tsx # CREATE: saved/recent ledger (delete + save toggle) +apps/server/web/src/routes/connect.tsx # CREATE: createFileRoute("/connect") +apps/server/web/src/components/sidebar/sidebar-nav.ts # MODIFY: top-level Connect entry (unique icon, icons test) +apps/server/web/src/components/subshell-terminal.tsx # MODIFY: SSH badge + view-only sentence for non-owners +apps/server/web/docs/connect-page.md # CREATE: the 3-sentence surface doc +packages/node-admin/src/types/node.ts # MODIFY: sshEnabled? on the shared Node type IF the panel needs it (it does: gate display) +e2e/tests/23-connect-flow.spec.ts # CREATE: the Playwright proof +.changeset/ssh-destination-first-ui.md # CREATE at Task 6 +``` + +## Decisions this plan has already made (do not relitigate) + +1. **Access downgrade, not a new flag:** `SubshellView.access` is the client's single truth (terminal: `readOnly = access === "view"`, subshell-terminal.tsx:567). For an ssh row, a non-owner whose resolved access is `edit` receives `view` in the VIEW layer only; every enforcement seam (REST/exec/WS/nudge) is already server-side from tier 2 and unchanged. `SubshellSchema` gains `ssh: boolean` (derived `row.ssh !== null`) so the terminal can label the pane; the snapshot itself stays never-serialized (its db-types doc). +2. **One page, three steps, §11's order:** destination field first (SearchableSelect fed by saved + recent + the machine's aliases + free text), machine follow-up as a disclosure line ("Connect from …") defaulted to the preference; NO key-source affordance (M2). With no default machine set and more than one SSH-enabled machine, the machine disclosure is required-with-gold-asterisk, never silently chosen (spec §7). With exactly one usable machine, it is pre-selected honestly and still shown. +3. **Gate-off is a named remedy, never a hidden option:** machines whose `sshEnabled` is false stay VISIBLE in the machine disclosure but disabled with a `reason` (the SearchableSelect `ComboboxOption.disabled/reason` fields), and the panel copy names the remedy: "SSH is off on . Ask to enable it." (owner display name from the node row's fields the SPA already reads; for `local`, "You can enable it in Server Settings" is wrong - admins only: use "An admin can enable SSH on this machine in its settings." - pick by `isAdmin` if the client already knows it; otherwise the generic sentence). +4. **Launch is one POST:** `{node, destination}`; the 422 `outcome` refusal renders the blocked settings verbatim in red under the field; SSH_GATE_OFF 403 renders the remedy; 409 held/offline name their own cause. Success navigates to `/subshells/$id` exactly like `launch-subshell-dialog.tsx:49-55`. +5. **Remembering a host is automatic recency + explicit save:** every successful launch already touches the row (tier 2); the panel offers "Remember this destination" (PUT) and the saved section lists saved + recent with delete (DELETE) and the default-machine PATCH ("Use by default" radio-ish control on the machine disclosure). +6. **The disclosure copy (exact, shipped verbatim, under the destination field, `text-detail`):** "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves." - two sentences, no em dashes, matching the route descriptions' substance. + +--- + +### Task 1: Server - `ssh` on the view + the non-owner access downgrade + +**Files:** Modify `apps/server/api/src/api/models.ts` (SubshellSchema beside `access` at :64), `apps/server/api/src/services/subshells.service.ts` (the list view ~:655-675 and the single-row view used by GET detail + launch), new tests in the existing view test home (`grep -l "access" apps/server/api/src/api/subshells/__tests__/` - extend the file that asserts the access stamping; if none, add `subshell-view-ssh.test.ts`). + +**Interfaces:** +- Produces: `SubshellView.ssh: boolean`; and a helper `effectiveViewerAccess(row, access): Access` (module-private is fine, exported for test) that maps `access === "edit" && row.ssh !== null && row.userId !== viewerId` to `"view"` - owner and non-edit stay as resolved. Both view paths (list + get) pass through it. MCP `list_subshells`/detail ride the same views: one change, both doors. +- Consumes: the row's `ssh` column (migration 0048). + +- [ ] Step 1: failing tests - a row with `ssh` JSON + an edit grantee's GET detail + GET list see `access: "view"` and `ssh: true`; the owner sees `access: "owner"`, `ssh: true`; an ordinary pane's edit grantee is UNCHANGED (`access: "edit"`); a view grantee was already `view`. +- [ ] Step 2: run, watch fail (unknown key ssh / access unchanged). +- [ ] Step 3: implement `ssh: t.Boolean({description: "True when this pane is an SSH-terminal pane: only its owner can type into it."})` in SubshellSchema + mapper `ssh: row.ssh !== null` (locate `toSubshellView` at subshell-manager.service.ts:2158 - the schema lives in models.ts; keep schema and mapper edits paired); the access helper applied at the two sites. +- [ ] Step 4: focused tests green; `bunx turbo build` (treaty consumers); `bunx turbo verify-types --filter=@internal/server`; biome; commit `"feat(api): ssh panes read as view-only in subshell views for everyone but their owner"`. + +--- + +### Task 2: SPA plumbing - types, hooks, nav entry + +**Files:** Create `apps/server/web/src/lib/ssh.ts`, `apps/server/web/src/hooks/use-ssh.ts`; modify `apps/server/web/src/components/sidebar/sidebar-nav.ts` (top-level entry after "Subshells": `{ to: "/connect", label: "Connect", icon: Cable }` - pick a lucide icon NOT already used in the nav (the icons test pins global uniqueness; read `components/__tests__/sidebar-nav.test.ts` and the current icon set first)); extend the Node type the SPA reads (grep `sshEnabled` in packages/node-admin/src/types/node.ts - add the boolean the node view already serializes, with the same comment class as its siblings). + +**Interfaces:** +- Produces (`lib/ssh.ts`): `SshSavedHost { id, destination, alias, nodeId, savedAt, lastConnectAt }`, `SshSavedHostsView { saved: SshSavedHost[]; recent: SshSavedHost[]; defaultNodeId: string | null }`, `SshAliasesView { aliases: string[]; includeCycle: boolean; truncated: boolean }`, `SshResolveOutcome` mirroring ssh-views' union, `SshLaunchResponse { subshell: { id: string } }` (only what the panel consumes); the refusal copy strings are NOT here (they are built in the panel from codes). +- Produces (`hooks/use-ssh.ts`, all following `use-prompts.ts` exactly): `useSshSavedHosts()` (key `["ssh-saved-hosts"]`), `useSshAliases(nodeId: string | null)` (key `["ssh-aliases", nodeId]`, `enabled: nodeId !== null`), `useSaveSshHost()`, `useDeleteSshHost()`, `useSshPreferences()` mutation, `useLaunchSsh()` - mutations via `apiPost`/`apiFetch`-POST helper the file uses; launch invalidates `["ssh-saved-hosts"]` (recency moved) only. +- Consumes: `/api/ssh/*` routes (task inventory: GET aliases?node=, GET saved-hosts, PUT saved-hosts, DELETE saved-hosts/:id, PATCH preferences, POST launch). + +- [ ] Step 1: extend the nav test (failing: no Connect entry) + a small type-shape compile test if the lib has a sibling pattern (check `lib/__tests__` style; a plain `tsc`-covered type file needs no runtime test - do not invent vacuous ones). +- [ ] Step 2: implement; nav icon uniqueness green; `env -u SHELLOPTS -u BASHOPTS bun test apps/server/web/src/components/__tests__/sidebar-nav.test.ts`; biome; verify-types @internal/server-web; commit `"feat(web): ssh surface plumbing - types, hooks, and the Connect entry"`. + +--- + +### Task 3: The /connect page + +**Files:** Create `apps/server/web/src/routes/connect.tsx`, `components/connect/connect-panel.tsx`, `components/connect/saved-hosts-section.tsx`; co-located `__tests__` (jsdom component tests - follow the existing `components/__tests__/` style; check what render helper they use). + +**Behavior contract (each a test):** +1. Machine disclosure: options from `useNodes()` filtered to `launchable` (reuse `launchableNodes` from `lib/subshell-compat` exactly as new-subshell-form.tsx does), each option `disabled: !n.sshEnabled` with `reason: "SSH is off on this machine"`; the default from `defaultNodeId`; unset preference + several enabled machines => required (gold `*`, `fieldErrorToned` gold caption when untouched-submit); exactly one => pre-selected. +2. Destination field: `SearchableSelect` (ui/combobox.tsx; props as the launch form uses them) grouped: Saved (`label = alias ?? destination`), Recent, `From 's config` (aliases, fetched only while a machine with the gate ON is picked); free text allowed = the field accepts a typed host not in any list (how: the combobox's input value as candidate - mirror how the working-directory field takes free text, read directory-picker-input.tsx's posture); selecting an item keeps destination semantics client-side ONLY for display, the SERVER resolves (no pre-resolve step; §11's "the common case just opens"). +3. Connect button: `useLaunchSsh` POST; loading state is the button's own disabled+label swap (design-system rule: no spinners elsewhere? check the launch dialog's precedent and match it); on 201 `navigate({ to: "/subshells/$id", params: { id } })`. +4. Refusals: 422 -> red line: `Config needs settings Subshell does not run: ${settings.join(", ")}. Edit them on the connecting machine and retry.`; 403 SSH_GATE_OFF -> the named-remedy sentence (decision 3); 409 -> the held/offline sentence; each maps from the ApiErrorResponse `code` (read the launch route's real response map in apps/server/api/src/api/ssh/launch.route.ts and name every branch). +5. Disclosure (decision 6's exact two sentences) under the destination field, `text-detail`. +6. "Remember this destination" checkbox, unchecked by default (every launch is already recent server-side; remembering is the explicit act). Label: "Remember this destination"; on a successful launch with it checked, PUT saved-hosts `{node, destination, alias?}` where alias is sent only when the pick came from the machine's config list (an alias token), per §7's alias-is-display rule. +7. Saved section: two lists (Remembered, Recent) rendered as label-over-detail lines, each remembered row a delete button; recent rows a save (star?) button; empty states are one short sentence each ("No destinations yet. Pick a machine to see its aliases."). +8. Route + a11y: `createFileRoute("/connect")`, page heading role `heading`, no dialog titles involved; all copy passes the two-sentence + no-em-dash scan (`bun run lint:prose` + the UI strings). + +- [ ] Steps: tests-first per brief (render the panel with mocked hooks - follow existing component-test mocking style), implement, focused green, biome, `bun run lint:design` clean (it's a pre-push check but run it now), verify-types, commit `"feat(web): the destination-first Connect page"`. + +--- + +### Task 4: The terminal's ssh affordances + +**Files:** Modify `apps/server/web/src/components/subshell-terminal.tsx` (+ its test file if one asserts the header). + +**Contract:** when `subshell.ssh === true`: (a) a small "SSH" badge/label in the pane header row (role `detail`, matching the node/badges posture the header already renders - read the header JSX first); (b) for `access === "view"` AND `ssh`: the header detail line reads "SSH sessions take input from their owner only." (one sentence, `text-detail`); non-ssh view grantees keep today's silence (do NOT restate the general view rule here; out of scope creep); (c) owner: nothing extra (the pane works as today). + +- [ ] Steps: failing component assertions, implement, focused green, biome, commit `"feat(web): ssh panes label themselves and tell viewers why typing is off"`. + +--- + +### Task 5: e2e - the Playwright proof + +**File:** Create `e2e/tests/23-connect-flow.spec.ts`. Read first: the SPA-driving specs (grep routes `/new` or nodes pages in e2e/tests for the login/navigate helpers) and reuse spec 22's fixture recipe (`e2e/fixtures/sshd.ts`) with the SAME isolated backend boot so the alias exists. + +**Scenario:** admin cookie session; `PUT /api/nodes/local/ssh-enabled on`; UI: navigate to `/connect`, machine shows `local`, destination picker shows the fixture alias among "From local's config"; pick it, Connect; assert redirect to `/subshells/` and the terminal shows the pane (reuse spec 22's sshd "Starting session" settle before typing `echo CONNECT-OK`); assert the log. Then a second user with an EDIT share (REST) opens the SAME pane URL: the terminal renders read-only, the SSH detail line names the rule, typing does nothing (log unchanged; the REST 403 is tier 2's, this proves the client posture). Then toggle the gate back off via REST, reload `/connect`: `local` is disabled with the reason, the remedy sentence appears when it is selected anyway (design the exact interaction so this is testable). + +- [ ] Steps: write, run ONLY this spec (`bun run test:e2e 23-connect`), iterate; remember `bunx turbo build` first (e2e serves web dist - memory); commit `"test(e2e): connect flow end to end - destination-first launch and the view-only pane"`. + +--- + +### Task 6: Boundary - docs, verification, PR, review loop + +- [ ] `apps/server/web/docs/connect-page.md`: the 3-sentence class doc (what leads, what the machine disclosure is, what refusals say), matching `launch-form-picker.md`'s voice. +- [ ] Full trio + prose + tests; `bunx turbo build` fresh; changeset `@internal/server` minor + `@internal/server-web` minor; push; `gh pr create --base feat/ssh-anywhere-2`; no auto-merge. +- [ ] Whole-branch review (opus) rounds until zero C/I/M; fix waves folded; final head pushed. +- [ ] Copy the review-disposition list into `$(git rev-parse --git-path sdd)/progress.md` as you go. From d92f23b52bd5149ad2ea705ed9f31ed808955525 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:16:36 -0700 Subject: [PATCH 055/170] feat(api): ssh panes read as view-only in subshell views for everyone but their owner --- apps/server/api/src/api/models.ts | 3 + .../__tests__/subshell-view-ssh.test.ts | 169 ++++++++++++++++++ .../__tests__/subshell-manager-view.test.ts | 11 ++ .../src/services/subshell-manager.service.ts | 5 + .../api/src/services/subshells.service.ts | 32 +++- e2e/tests/22-ssh-terminal.spec.ts | 11 +- 6 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index 863f0e6ce..a2b33a908 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -66,6 +66,9 @@ export const SubshellSchema = t.Object({ access: t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], { description: "Caller's effective access to this subshell (viewer-relative; never 'none' on a returned row)", }), + ssh: t.Boolean({ + description: "True when this pane is an SSH-terminal pane: only its owner can type into it.", + }), nodeOffline: t.Boolean({ description: "True when the subshell's node is disconnected. The subshell may still be running there. Always false for subshells on the server host.", diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts new file mode 100644 index 000000000..64bede2ab --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { effectiveViewerAccess } from "@/services/subshells.service.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * The ssh view truth (spec 2026-10-07 §5.4, plan 3 decision 1): an ssh pane + * reads as `view` + `ssh: true` to EVERY non-owner in both view paths (GET + * detail and GET list, the same two doors the MCP `get_subshell`/ + * `list_subshells` read through), while enforcement itself stays where tier 2 + * put it (`paneInputAllowed` at the input doors). Ordinary panes are + * untouched: an `edit` grantee still reads `edit`. Owner and `view` pass + * through unchanged. The downgrade must never widen anything: it keys on the + * snapshot column's PRESENCE, not its content. + */ +describe("ssh panes in subshell views: the non-owner access downgrade", () => { + const pw = "ssh-view-1"; + const aliceEmail = `sv-alice-${crypto.randomUUID()}@subshell.local`; + const bobEmail = `sv-bob-${crypto.randomUUID()}@subshell.local`; + const carolEmail = `sv-carol-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `sv-admin-${crypto.randomUUID()}@subshell.local`; + let aliceId: string; + let bobId: string; + let carolId: string; + let aliceCookie: string; + let bobCookie: string; + let carolCookie: string; + const created: string[] = []; + + async function mkUser(email: string, role: "user" | "admin"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + /** Alice owns every fixture row; `ssh` rides the row exactly as launch writes it. */ + async function ownSubshell(id: string, ssh: string | null = null): Promise { + created.push(id); + await new SubshellsRepository(db).create({ + id, + userId: aliceId, + presetId: "p", + harnessId: "ssh", + name: id, + workingDir: "/tmp", + tmuxSocket: null, + ssh, + }); + } + + beforeAll(async () => { + await setupAuthTables(); + aliceId = await mkUser(aliceEmail, "user"); + bobId = await mkUser(bobEmail, "user"); + carolId = await mkUser(carolEmail, "user"); + await mkUser(adminEmail, "admin"); // signed in per test (mirrors the sharing matrix file) + aliceCookie = await signIn(aliceEmail, pw); + bobCookie = await signIn(bobEmail, pw); + carolCookie = await signIn(carolEmail, pw); + + // The snapshot content is irrelevant to the rule (presence is the kind + // fact); this one is valid JSON so the fixture also reads plausibly. + await ownSubshell("s_ssh", JSON.stringify({ destination: "dev.example.com", node: "local" })); + await ownSubshell("s_plain"); // ordinary pane: ssh NULL + const shares = new SubshellSharesRepository(db); + await shares.replaceForSubshell( + "s_ssh", + [ + { granteeUserId: bobId, permission: "edit" }, + { granteeUserId: carolId, permission: "view" }, + ], + aliceId, + ); + await shares.replaceForSubshell("s_plain", [{ granteeUserId: bobId, permission: "edit" }], aliceId); + }); + + afterAll(async () => { + for (const id of created) await db.deleteFrom("subshells").where("id", "=", id).execute(); + for (const email of [aliceEmail, bobEmail, carolEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + function req(method: string, path: string, cookie: string) { + return subshellRoutes.fetch( + new Request(`http://localhost:3080/api/subshells${path}`, { + method, + headers: { cookie: `better-auth.session_token=${cookie}` }, + }), + ); + } + async function get(path: string, cookie: string) { + return (await (await req("GET", path, cookie)).json()) as { access: string; ssh: boolean }; + } + + it("an edit grantee GETs an ssh pane as view-only: access 'view', ssh true (detail path)", async () => { + const got = await get("/s_ssh", bobCookie); + expect(got.access).toBe("view"); + expect(got.ssh).toBe(true); + }); + + it("the owner keeps access 'owner' and sees ssh true", async () => { + const got = await get("/s_ssh", aliceCookie); + expect(got.access).toBe("owner"); + expect(got.ssh).toBe(true); + }); + + it("a view grantee is unchanged (already view), and still learns ssh", async () => { + const got = await get("/s_ssh", carolCookie); + expect(got.access).toBe("view"); + expect(got.ssh).toBe(true); + }); + + it("an ordinary pane is untouched: the edit grantee still reads access 'edit', ssh false", async () => { + const got = await get("/s_plain", bobCookie); + expect(got.access).toBe("edit"); + expect(got.ssh).toBe(false); + }); + + it("the admin's effective edit on a foreign ssh pane reads as view too (the boost is not ownership)", async () => { + const cookie = await signIn(adminEmail, pw); + try { + const sshRow = await get("/s_ssh", cookie); + expect(sshRow.access).toBe("view"); + expect(sshRow.ssh).toBe(true); + // Same admin, ordinary pane: still the resolved `edit` (pinning that the + // downgrade is keyed to the ssh column, not to the viewer's role). + const plain = await get("/s_plain", cookie); + expect(plain.access).toBe("edit"); + expect(plain.ssh).toBe(false); + } finally { + await deleteUserByEmailOrId(adminEmail); + } + }); + + it("the LIST path downgrades the same way (one helper, both doors; the MCP list rides this route)", async () => { + const rows = (await (await req("GET", "", bobCookie)).json()) as { + id: string; + access: string; + ssh: boolean; + }[]; + const byId = new Map(rows.map((r) => [r.id, r])); + expect(byId.get("s_ssh")).toMatchObject({ access: "view", ssh: true }); + expect(byId.get("s_plain")).toMatchObject({ access: "edit", ssh: false }); + }); +}); + +describe("effectiveViewerAccess (the downgrade helper, exported for test)", () => { + const sshRow = { ssh: "x", userId: "owner-1" }; // presence keys the rule: content never parsed + const plainRow = { ssh: null, userId: "owner-1" }; + + it("downgrades edit to view on an ssh row", () => { + expect(effectiveViewerAccess(sshRow, "edit")).toBe("view"); + }); + it("leaves owner and view as resolved on an ssh row", () => { + expect(effectiveViewerAccess(sshRow, "owner")).toBe("owner"); + expect(effectiveViewerAccess(sshRow, "view")).toBe("view"); + }); + it("leaves every level as resolved on an ordinary row", () => { + expect(effectiveViewerAccess(plainRow, "edit")).toBe("edit"); + expect(effectiveViewerAccess(plainRow, "owner")).toBe("owner"); + expect(effectiveViewerAccess(plainRow, "view")).toBe("view"); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts index 13c7f0b01..f173845b0 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts @@ -31,6 +31,7 @@ function row(overrides: Partial[0]> = {}) { waitingSince: null, lastPushUrgency: null, crossAgent: 0, + ssh: null, ...overrides, } satisfies Parameters[0]; } @@ -51,6 +52,16 @@ describe("toSubshellView notification fields", () => { expect(toSubshellView(row({ crossAgent: 0 }), "running").crossAgent).toBe(false); }); + it("maps the ssh snapshot column to a presence boolean and never echoes the snapshot (spec 2026-10-07)", () => { + // The rule keys on PRESENCE, not parseability: an unparseable string is + // still an ssh pane (validation belongs to the launch boundary). + expect(toSubshellView(row({ ssh: "x" }), "running").ssh).toBe(true); + const mapped = toSubshellView(row({ ssh: '{"destination":"dev.example.com"}' }), "running"); + expect(mapped.ssh).toBe(true); + expect(JSON.stringify(mapped)).not.toContain("dev.example.com"); + expect(toSubshellView(row(), "running").ssh).toBe(false); + }); + it("defaults access to 'owner' and honours an explicit override (sharing, spec 2026-08-31)", () => { // A returned view is always visible to someone, so it never carries "none". expect(toSubshellView(row(), "running").access).toBe("owner"); diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index daea235e1..1c6b7fa51 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -2204,6 +2204,8 @@ export function toSubshellView( waitingSince: string | null; lastPushUrgency: number | null; crossAgent: number; + /** The ssh snapshot column (migration 0048); read for presence only, never parsed here. */ + ssh: string | null; }, status: string, /** The subshell's current screen, bottom-first-trimmed; empty when not running. */ @@ -2262,6 +2264,9 @@ export function toSubshellView( // (spec 2026-09-23) — what turns the rail's dot into a bell. unseenPush: row.lastPushUrgency !== null, access, + // The pane's KIND fact: the ssh snapshot column's PRESENCE (migration + // 0048). The snapshot itself stays never-serialized (its db-types doc). + ssh: row.ssh !== null, // Agent node unreachable right now (see the param doc) — the UI's // "node offline" chip; false for every local subshell. nodeOffline, diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index a3af12539..b345ccae8 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -56,6 +56,30 @@ type SubshellView = NonNullable, + access: Exclude, +): Exclude { + if (access === "edit" && row.ssh !== null) return "view"; + return access; +} + /** A sharing grant as returned to the client, with the grantee label resolved. */ interface SubshellShareView { /** Share row id */ @@ -671,7 +695,7 @@ export class SubshellsService extends BaseService { const accessBy = new Map>(); for (const row of rows) { const access = resolveSubshellAccess(viewerId, row.userId, sharesBy.get(row.id) ?? []); - accessBy.set(row.id, access === "none" ? "view" : access); + accessBy.set(row.id, effectiveViewerAccess(row, access === "none" ? "view" : access)); } const views = await this.#manager.toViews(rows, opts); return views.map((view) => ({ @@ -854,11 +878,13 @@ export class SubshellsService extends BaseService { if (!subshell) throw new SubshellError("not_found", "Subshell not found"); // One extra read on a single-row path: the gate resolves access without // handing back the grants it looked at, and the disclosure warning needs - // the audience, not just the caller's own level. + // the audience, not just the caller's own level. The gate's resolver hands + // back the RAW level; the ssh view downgrade (plan 3 decision 1) rides the + // same helper the list applies, so both doors answer alike. const shares = (await this.repos.subshellShares.listForSubshells([id])).get(id) ?? []; return { ...subshell, - access: access === "none" ? "view" : access, + access: effectiveViewerAccess(row, access === "none" ? "view" : access), sharedWithViewer: access !== "owner" && shares.some((share) => share.granteeUserId === null || share.granteeUserId === viewerId), ...shareExposure(shares), diff --git a/e2e/tests/22-ssh-terminal.spec.ts b/e2e/tests/22-ssh-terminal.spec.ts index f4c5a7349..065503f06 100644 --- a/e2e/tests/22-ssh-terminal.spec.ts +++ b/e2e/tests/22-ssh-terminal.spec.ts @@ -378,11 +378,16 @@ test("ssh pane: gated launch to the fixture sshd, owner input echoes from the re data: { email: memberEmail, password: MEMBER_PASSWORD }, }); expect(memberSignIn.ok(), await memberSignIn.text()).toBe(true); - // The grant is real: the member sees the pane at `edit` (the refusal below - // must be the SSH rule, not an invisibility or a missing grant). + // The grant is real: the member can read the pane (a stranger gets a 404, + // never the 403 below). And the VIEW says what the doors say: on an ssh + // pane every non-owner reads `view` + `ssh: true` (spec 2026-10-07 §5.4, + // plan 3 decision 1); enforcement itself is the refusal below, keyed to + // the SSH rule, not to invisibility or a missing grant. const memberView = await member.get(`/api/subshells/${subshellId}`); expect(memberView.ok(), await memberView.text()).toBe(true); - expect(((await memberView.json()) as { access: string }).access).toBe("edit"); + const memberSubshell = (await memberView.json()) as { access: string; ssh: boolean }; + expect(memberSubshell.access).toBe("view"); + expect(memberSubshell.ssh).toBe(true); const refused = await member.post(`/api/subshells/${subshellId}/input`, { data: { text: `echo NOT-YOUR-PANE-${nonce}` }, From 80ef59fc679d28481f4b27c6034ea879a7e932d1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:24:07 -0700 Subject: [PATCH 056/170] feat(web): ssh surface plumbing - types, hooks, and the Connect entry --- .../components/__tests__/app-sidebar.test.ts | 8 +- .../__tests__/new-subshell-form.test.tsx | 1 + .../components/__tests__/node-row.test.tsx | 1 + .../components/__tests__/sidebar-nav.test.ts | 3 +- .../__tests__/subshell-card.test.tsx | 1 + .../__tests__/node-harness-card.test.tsx | 1 + .../nodes/__tests__/node-list-row.test.tsx | 1 + .../nodes/__tests__/node-table.test.tsx | 1 + .../nodes/__tests__/node-update-card.test.tsx | 1 + .../web/src/components/sidebar/sidebar-nav.ts | 5 + .../__tests__/new-subshell-form.test.tsx | 1 + .../__tests__/no-launch-targets.test.tsx | 1 + .../__tests__/use-node-harnesses.test.tsx | 1 + .../src/hooks/__tests__/use-nodes.test.tsx | 1 + .../web/src/hooks/__tests__/use-ssh.test.tsx | 128 ++++++++++++++ apps/server/web/src/hooks/use-ssh.ts | 108 ++++++++++++ apps/server/web/src/lib/__tests__/api.test.ts | 7 + .../src/lib/__tests__/subshell-compat.test.ts | 1 + apps/server/web/src/lib/ssh.ts | 157 ++++++++++++++++++ .../routes/__tests__/nodes-detail.test.tsx | 1 + .../routes/__tests__/nodes-page-gate.test.tsx | 1 + .../src/routes/__tests__/nodes-tabs.test.tsx | 1 + .../src/__tests__/node-allowed-dirs.test.tsx | 1 + .../__tests__/node-maintenance-card.test.tsx | 1 + .../src/__tests__/node-runtime-card.test.tsx | 1 + .../src/__tests__/node-service-card.test.tsx | 1 + packages/node-admin/src/lib/api.ts | 4 + packages/node-admin/src/types/node.ts | 10 ++ 28 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 apps/server/web/src/hooks/__tests__/use-ssh.test.tsx create mode 100644 apps/server/web/src/hooks/use-ssh.ts create mode 100644 apps/server/web/src/lib/ssh.ts diff --git a/apps/server/web/src/components/__tests__/app-sidebar.test.ts b/apps/server/web/src/components/__tests__/app-sidebar.test.ts index 8fd469941..b1fbef7fe 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar.test.ts +++ b/apps/server/web/src/components/__tests__/app-sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { ServerCog, Settings, SlidersHorizontal } from "lucide-react"; +import { Cable, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav"; describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { @@ -20,6 +20,12 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { expect(entries.filter(isNavGroup).find((g) => g.id === "server-settings")?.icon).toBe(ServerCog); }); + it("Connect takes Cable, beside Subshells as a primary action", () => { + // The Connect page is a top-level action (spec 2026-10-07 §7), not a + // settings page; Cable was free of the rail when it landed. + expect(items.find((i) => i.to === "/connect")?.icon).toBe(Cable); + }); + it("no two visible icons are the same — group headers included", () => { // Icons are how the collapsed rail names a page, so a duplicate there is // two rows that read as one. The group's own icon counts: it sits in the diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index 9e1fc8c93..5bb7f16a7 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -55,6 +55,7 @@ function node(overrides: Partial): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/node-row.test.tsx b/apps/server/web/src/components/__tests__/node-row.test.tsx index 7b2d51b51..f30afc40d 100644 --- a/apps/server/web/src/components/__tests__/node-row.test.tsx +++ b/apps/server/web/src/components/__tests__/node-row.test.tsx @@ -29,6 +29,7 @@ const BASE: Node = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts index 2138424ed..37a79cec2 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -36,13 +36,14 @@ describe("visibleNavItems", () => { for (const flag of [false, undefined]) { const paths = visibleNavItems(flag).map((i) => i.to); for (const page of GROUP_PAGES) expect(paths).not.toContain(page); - expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts"]); + expect(paths).toEqual(["/", "/connect", "/workspaces", "/nodes", "/presets", "/prompts"]); } }); it("gives an admin every group page, flattened in rail order", () => { expect(visibleNavItems(true).map((i) => i.to)).toEqual([ "/", + "/connect", "/workspaces", "/nodes", "/presets", diff --git a/apps/server/web/src/components/__tests__/subshell-card.test.tsx b/apps/server/web/src/components/__tests__/subshell-card.test.tsx index fb8abf740..65e223466 100644 --- a/apps/server/web/src/components/__tests__/subshell-card.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-card.test.tsx @@ -46,6 +46,7 @@ function agent(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx index 33ca7c658..fd28b3c1b 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx @@ -104,6 +104,7 @@ function view(over: Partial): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx index 7799cc453..465af89b7 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx @@ -35,6 +35,7 @@ function node(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx index f7796c21f..0b80031b8 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx @@ -28,6 +28,7 @@ function node(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx index f44e3e7ba..57aa2193c 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx @@ -35,6 +35,7 @@ function node(over: Partial = {}): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/sidebar/sidebar-nav.ts b/apps/server/web/src/components/sidebar/sidebar-nav.ts index 5d95cbcf0..946af9fb8 100644 --- a/apps/server/web/src/components/sidebar/sidebar-nav.ts +++ b/apps/server/web/src/components/sidebar/sidebar-nav.ts @@ -1,6 +1,7 @@ import { Activity, ArrowUpCircle, + Cable, DatabaseBackup, KeyRound, LayoutDashboard, @@ -57,6 +58,10 @@ const NAV_ENTRIES: NavEntry[] = [ // Terminal, like the empty subshells box — subshells are terminal harnesses, // not a grid (the grid icon belongs to the tiles/list view toggle). { to: "/", label: "Subshells", icon: TerminalSquare }, + // A primary action, so it sits above the objects it creates, not under + // Settings (spec 2026-10-07 §7). Cable names the link being opened; the + // icons test pins global uniqueness. + { to: "/connect", label: "Connect", icon: Cable }, { to: "/workspaces", label: "Workspaces", icon: LayoutDashboard, short: "Wksp" }, // Personal settings (spec 2026-10-07 §A): the pages this viewer always owns, // grouped the way the admin's Server Settings group is. No gate: every diff --git a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx index 44ae5f17b..e2bef6da1 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx @@ -193,6 +193,7 @@ const BOX = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx index 351f7fb79..60f367fac 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx @@ -56,6 +56,7 @@ function node(overrides: Partial): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx b/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx index 3839a5387..181047db7 100644 --- a/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx @@ -38,6 +38,7 @@ const NODE: NodeDetail = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx b/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx index 0665a93c4..eac2e457e 100644 --- a/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx @@ -40,6 +40,7 @@ const NODE: Node = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx b/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx new file mode 100644 index 000000000..e48096316 --- /dev/null +++ b/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { useLaunchSsh, useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; + +/** + * The keys and URLs this file states, asserted on the WIRE the way + * `use-instance-plugins.test.tsx` asserts its invalidations — a hook that + * stops refreshing cannot pass by invalidating some other key. + * + * Three facts: the ledger fetches once under `["ssh-saved-hosts"]`; aliases + * are per-machine and gated (a null picker asks the node nothing); and a + * successful launch refetches the ledger ONLY — the recency row moved, the + * machine's config did not. + */ +interface Call { + method: string; + url: string; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +function mockFetch(overrides: Record Response> = {}) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ method, url: url.pathname + url.search }); + const custom = overrides[`${method} ${url.pathname}`]; + return Promise.resolve(custom ? custom() : json({})); + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; +} + +function makeWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { client, wrapper }; +} + +const savedHosts = { saved: [], recent: [], defaultNodeId: null }; + +afterEach(cleanup); + +describe("useSshSavedHosts", () => { + it("fetches the ledger once at /api/ssh/saved-hosts", async () => { + const { calls, restore } = mockFetch({ "GET /api/ssh/saved-hosts": () => json(savedHosts) }); + try { + const { wrapper } = makeWrapper(); + const { result } = renderHook(() => useSshSavedHosts(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(calls.filter((c) => c.url === "/api/ssh/saved-hosts")).toEqual([ + { method: "GET", url: "/api/ssh/saved-hosts" }, + ]); + } finally { + restore(); + } + }); +}); + +describe("useSshAliases", () => { + it("asks nothing while no machine is chosen", async () => { + const { calls, restore } = mockFetch(); + try { + const { wrapper } = makeWrapper(); + const { result } = renderHook(() => useSshAliases(null), { wrapper }); + // `enabled: false` parks the query in its idle state — never fetching. + await new Promise((r) => setTimeout(r, 20)); + expect(result.current.fetchStatus).toBe("idle"); + expect(calls.filter((c) => c.url.startsWith("/api/ssh/aliases"))).toEqual([]); + } finally { + restore(); + } + }); + + it("names the machine on the wire when one is", async () => { + const { calls, restore } = mockFetch({ + "GET /api/ssh/aliases": () => json({ aliases: ["box"], includeCycle: false, truncated: false }), + }); + try { + const { wrapper } = makeWrapper(); + const { result } = renderHook(() => useSshAliases("n1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(calls).toEqual([{ method: "GET", url: "/api/ssh/aliases?node=n1" }]); + } finally { + restore(); + } + }); +}); + +describe("useLaunchSsh", () => { + it("refetches the ledger and nothing else on success", async () => { + const { calls, restore } = mockFetch({ + "GET /api/ssh/saved-hosts": () => json(savedHosts), + "GET /api/ssh/aliases": () => json({ aliases: [], includeCycle: false, truncated: false }), + "POST /api/ssh/launch": () => json({ subshell: { id: "s1" } }, 201), + }); + try { + const { wrapper } = makeWrapper(); + // Both lists mounted as observers: only the ledger may move. + const { result } = renderHook( + () => ({ launch: useLaunchSsh(), hosts: useSshSavedHosts(), aliases: useSshAliases("n1") }), + { + wrapper, + }, + ); + await waitFor(() => expect(result.current.hosts.isSuccess && result.current.aliases.isSuccess).toBe(true)); + const before = { ...counts(calls) }; + result.current.launch.mutate({ node: "n1", destination: "box" }); + await waitFor(() => expect(result.current.launch.isSuccess).toBe(true)); + await waitFor(() => expect(counts(calls).saved - before.saved).toBe(1)); + expect(counts(calls).aliases).toBe(before.aliases); + } finally { + restore(); + } + }); +}); + +function counts(calls: Call[]): { saved: number; aliases: number } { + return { + saved: calls.filter((c) => c.url === "/api/ssh/saved-hosts").length, + aliases: calls.filter((c) => c.url.startsWith("/api/ssh/aliases")).length, + }; +} diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts new file mode 100644 index 000000000..c688cae8b --- /dev/null +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -0,0 +1,108 @@ +import { apiFetch, apiPost } from "@internal/node-admin"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + SshAliasesView, + SshLaunchRequest, + SshLaunchResponse, + SshSavedHost, + SshSavedHostsView, + SshSaveHostRequest, +} from "@/lib/ssh"; + +/** + * The SSH launcher's data (spec 2026-10-07 §7), following `use-prompts.ts`: + * one key per list, one URL per key, mutations invalidate rather than patch. + * + * The saved-hosts view carries the ledger AND the default-machine preference, + * so every mutation that can move either one invalidates `["ssh-saved-hosts"]` + * and nothing else: a launch touched the recency row server-side, a save or + * delete moves the ledger, the preference PATCH moves `defaultNodeId`. The + * aliases are the MACHINE's config, which none of these acts edits, so no + * mutation invalidates them — the machine disclosure refetches them only when + * the chosen machine changes. + */ + +export const SSH_SAVED_HOSTS_QUERY_KEY = ["ssh-saved-hosts"] as const; +export const sshAliasesQueryKey = (nodeId: string) => ["ssh-aliases", nodeId] as const; + +/** The owner's saved + recent destinations and their default connecting machine. */ +export function useSshSavedHosts() { + return useQuery({ + queryKey: SSH_SAVED_HOSTS_QUERY_KEY, + queryFn: () => apiFetch("/api/ssh/saved-hosts"), + }); +} + +/** + * The machine's alias names for the destination picker. `enabled` gates on + * the caller actually having chosen a machine: an unresolved picker asks + * nothing, so a half-open panel never pings a node with `node=` empty. + */ +export function useSshAliases(nodeId: string | null) { + return useQuery({ + queryKey: sshAliasesQueryKey(nodeId ?? ""), + queryFn: () => apiFetch(`/api/ssh/aliases?node=${encodeURIComponent(nodeId ?? "")}`), + enabled: nodeId !== null, + }); +} + +/** Invalidate the ledger after an act that moved it (the one rule the mutations share). */ +function useInvalidateSavedHosts(): () => Promise { + const queryClient = useQueryClient(); + return () => queryClient.invalidateQueries({ queryKey: SSH_SAVED_HOSTS_QUERY_KEY }); +} + +/** + * `PUT /api/ssh/saved-hosts` — resolves the destination first, so a + * refusal-shaped outcome answers 422 `{outcome}` and stores nothing; the + * panel renders that, an `ApiError` with status 422. + */ +export function useSaveSshHost() { + const invalidate = useInvalidateSavedHosts(); + return useMutation({ + mutationFn: (draft: SshSaveHostRequest) => + apiFetch("/api/ssh/saved-hosts", { method: "PUT", body: JSON.stringify(draft) }), + onSuccess: () => void invalidate(), + }); +} + +/** `DELETE /api/ssh/saved-hosts/:id` — 204 no body; a foreign row is the same 404 as an absent one. */ +export function useDeleteSshHost() { + const invalidate = useInvalidateSavedHosts(); + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/ssh/saved-hosts/${encodeURIComponent(id)}`, { method: "DELETE" }), + onSuccess: () => void invalidate(), + }); +} + +/** + * `PATCH /api/ssh/preferences` — sets (or clears, with null) the default + * connecting machine. The answer echoes the stored id; the cache still + * refetches, because the SAME view carries the ledger and a stale preference + * there is the picker defaulting to the wrong machine. + */ +export function useSetDefaultNode() { + const invalidate = useInvalidateSavedHosts(); + return useMutation({ + mutationFn: (defaultNodeId: string | null) => + apiFetch<{ defaultNodeId: string | null }>("/api/ssh/preferences", { + method: "PATCH", + body: JSON.stringify({ defaultNodeId }), + }), + onSuccess: () => void invalidate(), + }); +} + +/** + * `POST /api/ssh/launch` `{node, destination, name?}` — one POST, one pane. + * Success invalidates the ledger only: the launch touched (or created) the + * destination's recency row server-side, while the machine's aliases are + * untouched by the act. + */ +export function useLaunchSsh() { + const invalidate = useInvalidateSavedHosts(); + return useMutation({ + mutationFn: (draft: SshLaunchRequest) => apiPost("/api/ssh/launch", draft), + onSuccess: () => void invalidate(), + }); +} diff --git a/apps/server/web/src/lib/__tests__/api.test.ts b/apps/server/web/src/lib/__tests__/api.test.ts index c8a984c08..a0401a3fd 100644 --- a/apps/server/web/src/lib/__tests__/api.test.ts +++ b/apps/server/web/src/lib/__tests__/api.test.ts @@ -27,6 +27,13 @@ describe("apiFetch error classes", () => { expect((err as ApiError).status).toBe(404); }); + it("204 No Content resolves undefined — a body-less success is not a parse failure", async () => { + // The ssh saved-host DELETE answers 204 with NO body (a 204 may not carry + // one); parsing one is a throw, so the helper must return before it tries. + globalThis.fetch = (async () => new Response(null, { status: 204 })) as unknown as typeof fetch; + await expect(apiFetch("/api/ssh/saved-hosts/x")).resolves.toBeUndefined(); + }); + it("isNetworkError matches only NetworkError (plain errors stay fail-fast)", () => { expect(isNetworkError(new NetworkError(new TypeError("x")))).toBe(true); expect(isNetworkError(new TypeError("x"))).toBe(false); diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts index ebd06f1cb..6f8c75f5f 100644 --- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts @@ -32,6 +32,7 @@ function node(overrides: Partial): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts new file mode 100644 index 000000000..c36040491 --- /dev/null +++ b/apps/server/web/src/lib/ssh.ts @@ -0,0 +1,157 @@ +/** + * The client shapes of the `/api/ssh` surface (spec 2026-10-07 §5–§7), the + * same mirror `lib/prompts.ts` keeps for the prompt library: local types that + * state the wire field for field, read through `apiFetch`. The definitions + * live beside the routes that answer them + * (`apps/server/api/src/api/ssh/ssh-views.ts`); the copy here restates that + * contract for the SPA, and the two are kept honest by review like every + * other mirror type in this app. + */ + +/** One ProxyJump hop as the frozen snapshot spells it. */ +export interface SshHop { + /** Hop hostname (bracketed IPv6 literal kept as given) */ + host: string; + /** Hop user; null means the connecting account's own default */ + user: string | null; + /** Hop port, resolved */ + port: number; +} + +/** + * The approved connection snapshot, exactly as the resolve/422 outcome + * serializes it. The always-null fields are load-bearing: a resolved value + * that the approved normalization cannot run makes the destination REFUSABLE, + * never renderable — so a field arriving non-null here is a server bug, and + * the `null` type is the wire's way of saying the panel never has to render + * one. + */ +export interface SshSnapshot { + /** The config token the human chose (display and review context only) */ + alias: string; + /** Resolved destination hostname */ + host: string; + /** Destination user; null means the connecting account's own default */ + user: string | null; + /** Destination port, resolved (1..65535) */ + port: number; + /** Identity file references (absolute paths on the connecting machine, never key contents) */ + identityFiles: string[]; + /** Certificate file references (paths only) */ + certificateFiles: string[]; + /** The authentication-agent socket to use, or null (runs with no agent) */ + authAgentSocket: string | null; + /** Known-hosts files to consult (paths only) */ + knownHostsFiles: string[]; + /** The name strict host checking looks up, or null */ + hostKeyAlias: string | null; + /** Bounded ProxyJump chain, outermost first */ + proxyJumps: SshHop[]; + /** Always null: a resolved ProxyCommand refuses the snapshot, never renders it */ + proxyCommand: null; + /** Always null: any resolved forwarding refuses the snapshot */ + forwards: null; + /** Always null: any resolved tunnel refuses the snapshot */ + tunnels: null; + /** Always null: any resolved local command refuses the snapshot */ + localCommands: null; + /** Always null: a resolved RemoteCommand refuses the snapshot */ + remoteCommand: null; + /** Always null: a resolved SendEnv refuses the snapshot */ + sendEnv: null; + /** Always null: a resolved SetEnv refuses the snapshot */ + setEnv: null; + /** Always null: live escape characters refuse the snapshot */ + escapes: null; +} + +/** + * `POST /api/ssh/resolve`'s answer, and the body of every 422 the launch and + * save endpoints answer: the refusal is IN THE DATA, so the panel renders it + * rather than catching it. `code` is an `SshErrorCode` name + * (unsupported_setting, config_missing, config_ambiguous, + * proxy_chain_too_long, host_key_unknown, …); `settings` names what blocked, + * empty when the code names the whole cause. + */ +export type SshResolveOutcome = + | { + /** The destination normalized cleanly into an approved snapshot */ + accepted: true; + snapshot: SshSnapshot; + /** The connecting account's OS user name, when the machine could report it (display only) */ + connectingAccount?: string; + } + | { + /** Resolution refused: the config needs more than the approved normalization can run */ + accepted: false; + code: string; + settings: string[]; + }; + +/** One saved/recency row as its owner sees it. */ +export interface SshSavedHost { + /** Row id (uuid) */ + id: string; + /** Canonical host:port (or user@host:port), the resolved destination as key */ + destination: string; + /** Display token (the alias typed or discovered); never a key */ + alias: string | null; + /** The connecting machine of the most recent launch to this destination */ + nodeId: string; + /** ISO 8601 the human saved it; null = recency-only row */ + savedAt: string | null; + /** ISO 8601 of the most recent launch to this destination */ + lastConnectAt: string; +} + +/** The `GET /api/ssh/saved-hosts` body: the destination field's feed and the machine preference. */ +export interface SshSavedHostsView { + /** Rows a human saved, newest save first (max 20) */ + saved: SshSavedHost[]; + /** Most-recently-connected destinations, saved or not, newest first (max 20) */ + recent: SshSavedHost[]; + /** + * The caller's default connecting machine, or null. A node deleted since + * reads back as the stored id; the client decides. + */ + defaultNodeId: string | null; +} + +/** The `GET /api/ssh/aliases?node=` body: names only, never config contents. */ +export interface SshAliasesView { + /** Usable alias NAMES (sorted, deduplicated, concrete names only), at most 500 */ + aliases: string[]; + /** An include cycle was detected; the list is what parsed before it */ + includeCycle: boolean; + /** The alias cap was hit; more exist */ + truncated: boolean; +} + +/** `PUT /api/ssh/saved-hosts` body: the machine gates the resolve, the alias is a display override. */ +export interface SshSaveHostRequest { + /** Machine to resolve the destination on (the gate owner) */ + node: string; + /** Destination token (1..253): an alias from the machine's config or a concrete host */ + destination: string; + /** Display label override; absent keeps the resolved snapshot's own alias */ + alias?: string; +} + +/** `POST /api/ssh/launch` body: name is the pane's display name, optional. */ +export interface SshLaunchRequest { + /** Connecting machine ('local' = the control-plane host) */ + node: string; + /** Destination token (1..253), resolved on the machine before anything launches */ + destination: string; + /** Pane display name */ + name?: string; +} + +/** + * The `POST /api/ssh/launch` 201 body. The wire carries the full subshell + * view; only the id is declared because only the id is consumed — success + * navigates to `/subshells/$id`, exactly like the launch dialog's create. + */ +export interface SshLaunchResponse { + subshell: { id: string }; +} diff --git a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx index 89f0563cb..5d3819259 100644 --- a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx @@ -39,6 +39,7 @@ function enrolledNode(overrides: Partial = {}): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx b/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx index 92c867167..a676e46e1 100644 --- a/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx @@ -151,6 +151,7 @@ describe("/nodes add-node gating", () => { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }, ], diff --git a/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx b/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx index 307dce4a0..ddb6938c4 100644 --- a/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx @@ -28,6 +28,7 @@ const NODE = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx b/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx index 3c380d3d8..eaea6ebfd 100644 --- a/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx +++ b/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx @@ -33,6 +33,7 @@ function node(over: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...over, }; diff --git a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx index 8ce12dc2d..9c91ec548 100644 --- a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx @@ -40,6 +40,7 @@ function node(overrides: Partial = {}): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/packages/node-admin/src/__tests__/node-runtime-card.test.tsx b/packages/node-admin/src/__tests__/node-runtime-card.test.tsx index 7420c17f3..03310153a 100644 --- a/packages/node-admin/src/__tests__/node-runtime-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-runtime-card.test.tsx @@ -33,6 +33,7 @@ const base: NodeDetail = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/packages/node-admin/src/__tests__/node-service-card.test.tsx b/packages/node-admin/src/__tests__/node-service-card.test.tsx index b377a3dfd..0f791e570 100644 --- a/packages/node-admin/src/__tests__/node-service-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-service-card.test.tsx @@ -48,6 +48,7 @@ const base: NodeDetail = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/packages/node-admin/src/lib/api.ts b/packages/node-admin/src/lib/api.ts index 4a559b7b9..32a9cb444 100644 --- a/packages/node-admin/src/lib/api.ts +++ b/packages/node-admin/src/lib/api.ts @@ -107,6 +107,10 @@ export async function apiFetch(path: string, init?: RequestInit): Promise const { message, code, errId } = parseErrorBody(body); throw new ApiError(res.status, message, { code, errId }); } + // 204 may not carry a body (a `Response` constructed with one throws), and + // `res.json()` on an empty one throws too. The ssh saved-host DELETE is the + // first route to answer it; a body-less success resolves undefined. + if (res.status === 204) return undefined as unknown as T; return (await res.json()) as T; } diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index 2ac496763..941035cdc 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -167,6 +167,16 @@ export interface Node { maintenanceAt: string | null; /** Which end declared it; null when it has never been declared */ maintenanceSource: MaintenanceSource | null; + /** + * Whether this machine may be used for Subshell SSH (spec 2026-10-07). + * + * Opt-in per node, off by default on every row including the control-plane + * host; the SSH gate refuses launches, discovery and saves here while false, + * whoever asks. The server's schema requires the boolean, so it rides every + * node view — the launcher's machine disclosure reads it to keep a gated-off + * machine visible but disabled, never hidden. + */ + sshEnabled: boolean; /** * This node is connected but REFUSED — held open for exactly one * command and offline for every other purpose (spec 2026-09-15 §5.3). From c43cb77014ddc2019102ae7a1dc144846bc45165 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:33:44 -0700 Subject: [PATCH 057/170] fix(node-admin): ApiError keeps the parsed failure body beside the sliced message The SSH launch route answers a refused resolve with a 422 {outcome} whose settings list can outgrow the 200-char display slice every ApiError message carries. parseErrorBody now hands the parsed JSON object to its callers, and apiFetch passes it through as err.body; the API : string is byte-identical, and legacy text bodies keep no body. --- .../node-admin/src/lib/__tests__/api.test.ts | 66 +++++++++++++++++++ packages/node-admin/src/lib/api.ts | 41 +++++++++--- 2 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 packages/node-admin/src/lib/__tests__/api.test.ts diff --git a/packages/node-admin/src/lib/__tests__/api.test.ts b/packages/node-admin/src/lib/__tests__/api.test.ts new file mode 100644 index 000000000..2caa14629 --- /dev/null +++ b/packages/node-admin/src/lib/__tests__/api.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { ApiError, apiFetch, parseErrorBody } from "../api"; + +/** + * `ApiError.body` (Task 3's un-truncation): the display message is a + * `API : ` string sliced to 200 chars, and a structured + * refusal body can be longer than the slice. The parsed JSON object must + * survive on `err.body` so a renderer (the SSH launcher's 422 `outcome`) + * reads the WHOLE answer; the message stays byte-identical, and a legacy + * text body carries no `body` at all. + */ +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("ApiError.body", () => { + it("keeps a 422-shaped outcome on err.body even when the message is sliced to 200 chars", async () => { + // The launch route answers the refused resolve with `{outcome}` — NOT the + // standard envelope — so `message` there is the raw JSON text and the + // slice lands mid-body. Many blocking settings push it past 200 chars. + const settings = Array.from({ length: 40 }, (_, i) => `BlockingSetting${i}`); + const body = { outcome: { accepted: false, code: "unsupported_setting", settings } }; + const raw = JSON.stringify(body); + expect(raw.length).toBeGreaterThan(200); // the slice is actually exercised + globalThis.fetch = (async () => new Response(raw, { status: 422 })) as unknown as typeof fetch; + const err = (await apiFetch("/api/ssh/launch", { method: "POST", body: "{}" }).catch( + (e: unknown) => e, + )) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(422); + // The display string is byte-identical to the old sliced form. + expect(err.message).toBe(`API 422: ${raw.slice(0, 200)}`); + // The whole refusal survived the slice: every setting is readable. + const outcome = (err.body as { outcome: { accepted: boolean; code: string; settings: string[] } }).outcome; + expect(outcome.accepted).toBe(false); + expect(outcome.settings).toEqual(settings); + }); + + it("carries the parsed envelope too (code/errId unchanged, body added)", async () => { + const body = { errId: "e1", code: "SSH_GATE_OFF", message: "Subshell SSH is off on this machine", statusCode: 403 }; + globalThis.fetch = (async () => new Response(JSON.stringify(body), { status: 403 })) as unknown as typeof fetch; + const err = (await apiFetch("/api/ssh/launch").catch((e: unknown) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(403); + expect(err.code).toBe("SSH_GATE_OFF"); + expect(err.errId).toBe("e1"); + expect(err.message).toBe("API 403: Subshell SSH is off on this machine"); + expect(err.body).toEqual(body); + }); + + it("leaves body undefined for a legacy plain-text failure (nothing to keep)", async () => { + globalThis.fetch = (async () => new Response("Bad Gateway", { status: 502 })) as unknown as typeof fetch; + const err = (await apiFetch("/api/anything").catch((e: unknown) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe("API 502: Bad Gateway"); + expect(err.body).toBeUndefined(); + }); + + it("parseErrorBody hands the object to every caller unchanged", () => { + const parsed = parseErrorBody(JSON.stringify({ outcome: { accepted: false } })); + expect(parsed.message).toContain("outcome"); // no `message` key: raw text stands + expect(parsed.body).toEqual({ outcome: { accepted: false } }); + expect(parseErrorBody("plain").body).toBeUndefined(); + }); +}); diff --git a/packages/node-admin/src/lib/api.ts b/packages/node-admin/src/lib/api.ts index 32a9cb444..dabf9d729 100644 --- a/packages/node-admin/src/lib/api.ts +++ b/packages/node-admin/src/lib/api.ts @@ -21,12 +21,22 @@ export class ApiError extends Error { * thing to quote when reporting a bug; undefined for legacy text failures. */ readonly errId?: string; - constructor(status: number, body: string, meta: { code?: string; errId?: string } = {}) { + /** + * The parsed JSON body itself when the failure carried one — the machine- + * readable payload the display message cannot hold: the message is the + * raw text sliced to 200 chars, while a structured refusal (the SSH 422 + * `{outcome}` with its `settings` list) can run longer than that. Readers + * that need the WHOLE answer (`err.body`) must not parse the message. + * Undefined for legacy plain-text bodies (nothing to keep). + */ + readonly body?: unknown; + constructor(status: number, body: string, meta: { code?: string; errId?: string; body?: unknown } = {}) { super(`API ${status}: ${body.slice(0, 200)}`); this.name = "ApiError"; this.status = status; this.code = meta.code; this.errId = meta.errId; + this.body = meta.body; } } @@ -59,10 +69,14 @@ export function isAbortError(err: unknown): boolean { } /** - * Splits a failed response body into the display message and the structured - * fields. Every backend failure now carries `{errId, code, message, statusCode}`; - * anything that is not that shape (a proxy error page, an older body) falls - * back to the raw text so the `API : ` copy never changes form. + * Splits a failed response body into the display message, the structured + * fields, and the parsed body itself. Every backend failure now carries + * `{errId, code, message, statusCode}`; anything that is not that shape (a + * proxy error page, an older body) falls back to the raw text so the + * `API : ` copy never changes form. Any JSON OBJECT — the + * envelope or not — returns its parsed form as `body`, so a caller who needs + * the whole answer (the SSH 422 `{outcome}`) reads `body`, never the + * length-limited message. */ /** * Exported for the one caller that cannot use {@link apiFetch}: the agent @@ -70,7 +84,12 @@ export function isAbortError(err: unknown): boolean { * arrives as an ordinary status code with this body, and it should surface as * the same `ApiError` every other call produces. */ -export function parseErrorBody(raw: string): { message: string; code?: string; errId?: string } { +export function parseErrorBody(raw: string): { + message: string; + code?: string; + errId?: string; + body?: unknown; +} { try { const parsed: unknown = JSON.parse(raw); if (parsed && typeof parsed === "object") { @@ -79,6 +98,10 @@ export function parseErrorBody(raw: string): { message: string; code?: string; e message: typeof message === "string" ? message : raw, code: typeof code === "string" ? code : undefined, errId: typeof errId === "string" ? errId : undefined, + // The whole parsed object rides along: `message` is a DISPLAY string + // sliced to 200 chars by ApiError, and a structured refusal body + // (the SSH 422 `{outcome}`) can be longer than the display allows. + body: parsed, }; } } catch { @@ -103,9 +126,9 @@ export async function apiFetch(path: string, init?: RequestInit): Promise throw new NetworkError(err); } if (!res.ok) { - const body = await res.text().catch(() => ""); - const { message, code, errId } = parseErrorBody(body); - throw new ApiError(res.status, message, { code, errId }); + const raw = await res.text().catch(() => ""); + const { message, code, errId, body } = parseErrorBody(raw); + throw new ApiError(res.status, message, { code, errId, body }); } // 204 may not carry a body (a `Response` constructed with one throws), and // `res.json()` on an empty one throws too. The ssh saved-host DELETE is the From 0d695f1dc6910e783d353ad64afd99b1b1fd3c2c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:48:32 -0700 Subject: [PATCH 058/170] feat(web): the destination-first Connect page /connect opens with the destination leading: saved rows, recent rows, the picked machine's config aliases, and a typed host the list mirrors as its own row (input-plus-panel posture - Base UI's held combobox cannot keep typed text across per-keystroke item swaps, the working-directory field learned this first). The connecting machine follows as a disclosure line; a gated-off machine stays visible and greyed with its reason, the default pre-selects honestly, and no preference plus several enabled machines is a required question with the gold star, never a silent pick. One POST opens the pane; refusals render on the field they belong to, the 422's blocked settings read verbatim from the parsed body (the message slice could cut them). The two-sentence Match exec disclosure sits under the destination before the button; Remember is the explicit PUT after success, the alias token riding only config-list picks. The ledger below lists remembered and recent rows with forget/remember acts, the recent copy reading once when a destination is already saved. --- apps/server/web/docs/connect-page.md | 17 + .../connect/__tests__/connect-panel.test.tsx | 478 ++++++++++++++++++ .../__tests__/destination-options.test.ts | 90 ++++ .../__tests__/saved-hosts-section.test.tsx | 137 +++++ .../connect/__tests__/ssh-refusal.test.ts | 136 +++++ .../src/components/connect/connect-panel.tsx | 252 +++++++++ .../components/connect/destination-field.tsx | 139 +++++ .../components/connect/destination-options.ts | 98 ++++ .../connect/saved-hosts-section.tsx | 111 ++++ .../web/src/components/connect/ssh-refusal.ts | 122 +++++ apps/server/web/src/routeTree.gen.ts | 21 + .../web/src/routes/__tests__/connect.test.tsx | 58 +++ apps/server/web/src/routes/connect.tsx | 24 + 13 files changed, 1683 insertions(+) create mode 100644 apps/server/web/docs/connect-page.md create mode 100644 apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx create mode 100644 apps/server/web/src/components/connect/__tests__/destination-options.test.ts create mode 100644 apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx create mode 100644 apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts create mode 100644 apps/server/web/src/components/connect/connect-panel.tsx create mode 100644 apps/server/web/src/components/connect/destination-field.tsx create mode 100644 apps/server/web/src/components/connect/destination-options.ts create mode 100644 apps/server/web/src/components/connect/saved-hosts-section.tsx create mode 100644 apps/server/web/src/components/connect/ssh-refusal.ts create mode 100644 apps/server/web/src/routes/__tests__/connect.test.tsx create mode 100644 apps/server/web/src/routes/connect.tsx diff --git a/apps/server/web/docs/connect-page.md b/apps/server/web/docs/connect-page.md new file mode 100644 index 000000000..da752b110 --- /dev/null +++ b/apps/server/web/docs/connect-page.md @@ -0,0 +1,17 @@ +# The Connect page + +Surface doc for `apps/server/web`, spec 2026-10-07 §7 (`/connect`, plan 3). + +The page asks one question first: a destination, chosen from the caller's +saved rows, their recent rows, and the picked machine's SSH config aliases - +or typed free, which the list mirrors as its own row until it is committed. +One POST launches: the connecting machine resolves the token on the human's +own SSH config, and the panel renders the server's refusal (the blocked +`settings` list included, read from the parsed body) rather than guessing. + +Two UI facts worth keeping deliberate: a machine whose SSH is off stays in +the disclosure greyed with its reason, never hidden (spec 2026-10-07 §4.3); +and the destination field is an input-plus-panel, not the shared combobox, +because Base UI's held input loses typed text when its items swap on every +keystroke (the posture is the working-directory field's, for the same +reason). diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx new file mode 100644 index 000000000..a984dbb7f --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -0,0 +1,478 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ConnectPanel, SSH_DISCLOSURE_COPY } from "@/components/connect/connect-panel"; + +/** + * The /connect behavior contract (plan 3 Task 3), pinned on the composed + * panel with the wire mocked the way `new-subshell-form.test.tsx` mocks it + * (fetch, not the hooks - the hooks are pinned by `use-ssh.test.tsx`). The + * refusal CODE matrix is pinned purely in `ssh-refusal.test.ts`; two arms run + * through the panel here to prove the wiring (the 422 out of the parsed body, + * the 409 under the machine field). + */ + +interface Call { + method: string; + url: string; + body?: unknown; +} + +function node(overrides: Partial): Node { + return { + id: "n", + name: "node", + kind: "agent", + os: null, + arch: null, + hostname: null, + status: "online", + lastSeenAt: null, + agentVersion: null, + protocolVersion: null, + access: "owner", + canManage: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...overrides, + }; +} + +const HOST_A = node({ id: "a1", name: "mac mini", sshEnabled: true }); +const HOST_B = node({ id: "b1", name: "studio", sshEnabled: false }); +const HOST_C = node({ id: "c1", name: "server room", sshEnabled: true }); + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +interface WireOpts { + nodes?: Node[]; + ledger?: unknown; + aliases?: string[]; + launch?: () => Response; +} + +/** Mock the whole /connect surface and record every call with its body. */ +function mockFetch(opts: WireOpts = {}) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const path = url.pathname; + calls.push({ + method, + url: path + url.search, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) as unknown } : {}), + }); + if (path === "/api/nodes") return Promise.resolve(json({ nodes: opts.nodes ?? [] })); + if (path === "/api/ssh/saved-hosts" && method === "GET") { + return Promise.resolve(json(opts.ledger ?? { saved: [], recent: [], defaultNodeId: null })); + } + if (path === "/api/ssh/saved-hosts" && method === "PUT") { + return Promise.resolve( + json({ id: "r1", destination: "x", alias: null, nodeId: "a1", savedAt: "t", lastConnectAt: "t" }), + ); + } + if (path === "/api/ssh/preferences") return Promise.resolve(json({ defaultNodeId: "a1" })); + if (path === "/api/ssh/aliases") { + return Promise.resolve(json({ aliases: opts.aliases ?? [], includeCycle: false, truncated: false })); + } + if (path === "/api/ssh/launch") { + return Promise.resolve(opts.launch ? opts.launch() : json({ subshell: { id: "s-1" } }, 201)); + } + return Promise.resolve(json({})); + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; +} + +interface Rendered { + calls: Call[]; + /** Current path: the navigation assertion reads the router state live. */ + pathname: () => string; + restore: () => void; +} + +async function renderPanel(opts: WireOpts): Promise { + const { calls, restore } = mockFetch(opts); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: ConnectPanel }); + const paneRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, paneRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + return { calls, pathname: () => router.state.location.pathname, restore }; +} + +/** Flush query/effect updates inside act() (the launch-form test idiom). */ +async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); +} + +/** Base UI opens its machine popup on the pointer path, not a bare click. */ +function openMachinePopup(): void { + const input = document.getElementById("connect-machine"); + if (!input) throw new Error("no machine field"); + act(() => { + for (const type of ["pointerdown", "pointerup"]) { + input.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerType: "mouse" })); + } + for (const type of ["mousedown", "mouseup", "click"]) { + input.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + }); +} + +function machineInput(): HTMLInputElement { + return screen.getByPlaceholderText("Choose a machine") as HTMLInputElement; +} + +function destinationInput(): HTMLInputElement { + return screen.getByPlaceholderText("Choose or type a destination") as HTMLInputElement; +} + +/** The destination field opens on focus (the working-directory posture). */ +function focusDestination(): void { + fireEvent.focus(destinationInput()); +} + +/** Commit a typed host through its mirror row (the free-text path, §7). */ +async function commitTyped(text: string): Promise { + focusDestination(); + await settle(); + fireEvent.change(destinationInput(), { target: { value: text } }); + await settle(); + fireEvent.click(await screen.findByRole("button", { name: text })); + await settle(); +} + +async function clickConnect(): Promise { + fireEvent.click(screen.getByRole("button", { name: /^Connect$/ })); + await settle(); +} + +afterEach(cleanup); + +describe("machine disclosure (contract 1)", () => { + it("keeps a gated-off machine visible, greyed, with the reason naming what is in the way", async () => { + const { pathname, restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); + try { + // HOST_A is the sole SSH-enabled machine, so it pre-selects honestly - + // and HOST_B still stands in the list, disabled. + openMachinePopup(); + await settle(); + const studio = await screen.findByRole("option", { name: /studio/ }); + expect(studio.getAttribute("data-disabled")).toBe(""); + expect(studio.textContent).toContain("SSH is off on this machine"); + expect(pathname()).toBe("/"); + } finally { + restore(); + } + }); + + it("pre-selects the stored default machine", async () => { + const { restore } = await renderPanel({ + nodes: [HOST_A, HOST_C], + ledger: { saved: [], recent: [], defaultNodeId: "c1" }, + }); + try { + await waitFor(() => expect(machineInput().value).toBe("server room")); + } finally { + restore(); + } + }); + + it("pre-selects the single SSH-enabled machine with the preference unset", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + } finally { + restore(); + } + }); + + it("several enabled machines and no preference is a required question: gold *, gold caption on the submit, no POST", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A, HOST_C] }); + try { + expect(machineInput().value).toBe(""); // never silently chosen + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).not.toBeNull(); // the gold star + await commitTyped("box.example"); + await clickConnect(); + const caption = screen.getByText("Choose a connecting machine first."); + expect(caption.className).toContain("text-amber-600"); // gold: nothing typed yet, not red + expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]); + } finally { + restore(); + } + }); +}); + +describe("destination field (contract 2)", () => { + it("feeds the grouped rows: Saved, Recent, and the picked machine's config", async () => { + const ledger = { + saved: [ + { + id: "s1", + destination: "web01.example.com:22", + alias: "web", + nodeId: "a1", + savedAt: "2026-10-01T00:00:00.000Z", + lastConnectAt: "2026-10-05T00:00:00.000Z", + }, + ], + recent: [ + { + id: "r1", + destination: "db.example.com:22", + alias: null, + nodeId: "a1", + savedAt: null, + lastConnectAt: "2026-10-06T00:00:00.000Z", + }, + ], + defaultNodeId: "a1", + }; + const { restore } = await renderPanel({ nodes: [HOST_A], ledger, aliases: ["workbox"] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + focusDestination(); + await settle(); + const popup = await waitFor(() => { + const el = document.getElementById("connect-destination-panel"); + if (!el) throw new Error("popup not open"); + return el; + }); + // Scoped to the popup: "Recent" is also the ledger's own heading below. + expect(within(popup).getByText("Saved")).toBeDefined(); + expect(within(popup).getByText("Recent")).toBeDefined(); + expect(within(popup).getByText("From mac mini's config")).toBeDefined(); + expect(within(popup).getByRole("button", { name: "web" })).toBeDefined(); + expect(within(popup).getByRole("button", { name: "workbox" })).toBeDefined(); + } finally { + restore(); + } + }); + + it("asks the machine for its aliases only once a gate-ON machine is picked", async () => { + const empty = await renderPanel({ nodes: [] }); + try { + // No machine at all: the picker asks nothing (a half-open panel must not + // ping a node with node= empty). + await settle(); + expect(empty.calls.filter((c) => c.url.startsWith("/api/ssh/aliases"))).toEqual([]); + } finally { + empty.restore(); + } + cleanup(); // one tree at a time: the second render must not stack queries + const picked = await renderPanel({ nodes: [HOST_A] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + expect(picked.calls.some((c) => c.url === "/api/ssh/aliases?node=a1")).toBe(true); + } finally { + picked.restore(); + } + }); + + it("accepts a host typed that no list carries: the mirror row commits it", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A] }); + try { + await commitTyped("box.example"); + expect(destinationInput().value).toBe("box.example"); // the held echo is what will launch + await clickConnect(); + const post = calls.find((c) => c.url === "/api/ssh/launch"); + expect(post?.body).toEqual({ node: "a1", destination: "box.example" }); + } finally { + restore(); + } + }); + + it("a config-alias pick launches the token, and the save sends it as the alias", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A], aliases: ["workbox"] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + focusDestination(); + await settle(); + fireEvent.click(await screen.findByRole("button", { name: "workbox" })); + await settle(); + fireEvent.click(screen.getByLabelText("Remember this destination")); + await clickConnect(); + expect(calls.find((c) => c.url === "/api/ssh/launch")?.body).toEqual({ node: "a1", destination: "workbox" }); + await waitFor(() => expect(calls.some((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toBe(true)); + const put = calls.find((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "workbox", alias: "workbox" }); + } finally { + restore(); + } + }); +}); + +describe("connect and the ledger (contracts 3, 6)", () => { + it("the button shows the process's own line while it runs and lands on the pane at 201", async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: unknown, _init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/ssh/launch") return gate.then(() => json({ subshell: { id: "s-9" } }, 201)); + if (url.pathname === "/api/nodes") return Promise.resolve(json({ nodes: [HOST_A] })); + if (url.pathname === "/api/ssh/saved-hosts") + return Promise.resolve(json({ saved: [], recent: [], defaultNodeId: null })); + return Promise.resolve(json({})); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/", component: ConnectPanel }), + createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + try { + await commitTyped("box.example"); + fireEvent.click(screen.getByRole("button", { name: /^Connect$/ })); + await settle(); + const button = screen.getByRole("button", { name: /Connecting/ }); // no spinner: the button owns the wait + expect((button as HTMLButtonElement).disabled).toBe(true); + release(); + await waitFor(() => expect(router.state.location.pathname).toBe("/subshells/s-9")); + } finally { + globalThis.fetch = realFetch; + } + }); + + it("Remember starts unchecked; a checked launch PUTs the saved row with no alias, unchecked PUTs nothing", async () => { + const off = await renderPanel({ nodes: [HOST_A] }); + try { + expect((screen.getByLabelText("Remember this destination") as HTMLInputElement).checked).toBe(false); + await commitTyped("box.example"); + await clickConnect(); + await settle(); + expect(off.calls.filter((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toEqual([]); + } finally { + off.restore(); + } + cleanup(); // one tree at a time + const on = await renderPanel({ nodes: [HOST_A] }); + try { + await commitTyped("box.example"); + fireEvent.click(screen.getByLabelText("Remember this destination")); + await clickConnect(); + await waitFor(() => + expect(on.calls.some((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toBe(true), + ); + const put = on.calls.find((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "box.example" }); + } finally { + on.restore(); + } + }); + + it("Use by default PATCHes the connecting-machine preference", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + fireEvent.click(screen.getByRole("button", { name: "Use by default" })); + await waitFor(() => + expect(calls.some((c) => c.method === "PATCH" && c.url === "/api/ssh/preferences")).toBe(true), + ); + expect(calls.find((c) => c.method === "PATCH")?.body).toEqual({ defaultNodeId: "a1" }); + } finally { + restore(); + } + }); +}); + +describe("refusals and disclosure (contracts 4, 5)", () => { + it("renders a 422 outcome's blocked settings verbatim in red, past the message slice", async () => { + const settings = ["ProxyCommand", "LocalForward", ...Array.from({ length: 30 }, (_, i) => `Setting${i}`)]; + const raw = JSON.stringify({ outcome: { accepted: false, code: "unsupported_setting", settings } }); + expect(raw.length).toBeGreaterThan(200); // the 200-char message slice is exercised + const { restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ outcome: { accepted: false, code: "unsupported_setting", settings } }, 422), + }); + try { + await commitTyped("web01"); + await clickConnect(); + const line = await screen.findByText( + (_text, el) => + el?.tagName === "P" && (el.textContent ?? "").startsWith("Config needs settings Subshell does not run:"), + ); + expect(line.className).toContain("text-destructive"); // a hard server refusal: red + expect(line.textContent).toContain("Setting29"); // the WHOLE list, not the 200-char slice + expect(line.textContent).toContain("Edit them on the connecting machine and retry."); + } finally { + restore(); + } + }); + + it("names the machine in its own 409 sentence, under the machine field", async () => { + const { restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ errId: "e1", code: "NODE_OFFLINE", message: "offline", statusCode: 409 }, 409), + }); + try { + await commitTyped("web01"); + await clickConnect(); + expect( + await screen.findByText("mac mini has no live connection right now. Bring its Subshell app online and retry."), + ).toBeDefined(); + } finally { + restore(); + } + }); + + it("ships the two-sentence disclosure under the destination field, wired as its description", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A] }); + try { + const line = await screen.findByText(SSH_DISCLOSURE_COPY); + expect(line.className).toContain("text-detail"); + expect(destinationInput().getAttribute("aria-describedby")).toBe("connect-destination-disclosure"); + // The exact sentences (decision 6), dash-free. + expect(SSH_DISCLOSURE_COPY).toBe( + "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves.", + ); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/destination-options.test.ts b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts new file mode 100644 index 000000000..99a3ff626 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import type { SshSavedHost } from "@/lib/ssh"; +import { buildDestinationOptions, configGroupLabel, RECENT_GROUP, SAVED_GROUP } from "../destination-options"; + +/** + * The destination field's list as pure data: the fed groups and their + * headers, the typed mirror row that makes the field accept a host no list + * holds, and the candidate map that keeps DISPLAY (an alias label) out of the + * WIRE (the destination token). The alias token rides only config rows - + * §7's alias-is-display rule. + */ +const row = (over: Partial): SshSavedHost => ({ + id: "r1", + destination: "web01.example.com:22", + alias: null, + nodeId: "n1", + savedAt: null, + lastConnectAt: "2026-10-07T00:00:00.000Z", + ...over, +}); + +describe("buildDestinationOptions", () => { + it("groups saved, recent and config rows under their headers, in order", () => { + const { options, candidates } = buildDestinationOptions({ + saved: [row({ id: "s1", alias: "web" })], + recent: [row({ id: "c1", destination: "db.example.com:22", alias: null })], + aliases: ["workbox"], + machineName: "mac mini", + typed: "", + }); + expect(options.map((o) => o.group)).toEqual([SAVED_GROUP, RECENT_GROUP, configGroupLabel("mac mini")]); + // A saved row LABELS with its alias and searches with its destination. + expect(options[0].label).toBe("web"); + expect(options[0].searchText).toBe("web01.example.com:22"); + expect(options[2].label).toBe("workbox"); + // The wire meanings: the alias label never leaks into the destination. + expect(candidates.get("saved:s1")).toEqual({ destination: "web01.example.com:22" }); + // Only the config row carries the alias token (the save sends it). + expect(candidates.get("config:workbox")).toEqual({ destination: "workbox", aliasToken: "workbox" }); + }); + + it("a recent row already saved reads once, under Saved", () => { + const shared = row({ id: "d1", savedAt: "2026-10-01T00:00:00.000Z" }); + const { options } = buildDestinationOptions({ + saved: [shared], + recent: [shared], + aliases: [], + machineName: null, + typed: "", + }); + expect(options).toHaveLength(1); + expect(options[0].group).toBe(SAVED_GROUP); + }); + + it("typed text with no equal row becomes its own candidate, leading the list", () => { + const { options, candidates } = buildDestinationOptions({ + saved: [row({})], + recent: [], + aliases: [], + machineName: null, + typed: "box.example", + }); + expect(options[0]).toEqual({ value: "typed:box.example", label: "box.example" }); + expect(candidates.get("typed:box.example")).toEqual({ destination: "box.example" }); + }); + + it("typed text a real row already carries gets no mirror row", () => { + const { options } = buildDestinationOptions({ + saved: [row({ id: "s1", destination: "box.example", alias: null })], + recent: [], + aliases: ["box.example"], + machineName: "mac mini", + typed: "box.example", + }); + expect(options.some((o) => o.value.startsWith("typed:"))).toBe(false); + // The real rows still carry it, so the choice exists once, not twice. + expect(options.some((o) => o.label === "box.example")).toBe(true); + }); + + it("no machine picked means no config group, and no aliases leak into it", () => { + const { options } = buildDestinationOptions({ + saved: [], + recent: [], + aliases: ["workbox"], + machineName: null, + typed: "", + }); + expect(options).toEqual([]); + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx new file mode 100644 index 000000000..9cbc84dd5 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SavedHostsSection } from "@/components/connect/saved-hosts-section"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The ledger under the panel (contract 7): label-over-detail rows, delete on + * Remembered, remember on Recent, saved rows never shown twice, and the two + * one-sentence empties. Wire-mocked the way the panel test mocks it. + */ +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +const savedRow: SshSavedHost = { + id: "s1", + destination: "web01.example.com:22", + alias: "web", + nodeId: "a1", + savedAt: "2026-10-01T00:00:00.000Z", + lastConnectAt: "2026-10-05T00:00:00.000Z", +}; +const recentRow: SshSavedHost = { + id: "r1", + destination: "db.example.com:22", + alias: null, + nodeId: "a1", + savedAt: null, + lastConnectAt: "2026-10-06T00:00:00.000Z", +}; + +function node(overrides: Partial): Node { + return { + id: "n", + name: "node", + kind: "agent", + os: null, + arch: null, + hostname: null, + status: "online", + lastSeenAt: null, + agentVersion: null, + protocolVersion: null, + access: "owner", + canManage: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...overrides, + }; +} + +interface Call { + method: string; + url: string; + body?: unknown; +} + +async function renderSection(ledger: unknown) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ + method, + url: url.pathname + url.search, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) as unknown } : {}), + }); + if (url.pathname === "/api/ssh/saved-hosts" && method === "GET") return Promise.resolve(json(ledger)); + if (url.pathname === "/api/nodes") { + return Promise.resolve(json({ nodes: [node({ id: "a1", name: "mac mini", sshEnabled: true })] })); + } + return Promise.resolve(json({})); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + return { calls, restore: () => (globalThis.fetch = original) }; +} + +afterEach(cleanup); + +describe("SavedHostsSection", () => { + it("renders remembered and recent rows label-over-detail, the machine named in the detail", async () => { + const { restore } = await renderSection({ saved: [savedRow], recent: [savedRow, recentRow], defaultNodeId: null }); + try { + expect(screen.getByText("web")).toBeDefined(); // the ALIAS is the display + expect(screen.getByText("mac mini · 2026-10-01")).toBeDefined(); // saved date rides remembered rows + expect(screen.getByText("db.example.com:22")).toBeDefined(); + expect(screen.getByText("mac mini · 2026-10-06")).toBeDefined(); // last connect rides recents + // The row already Saved appears once, under Remembered (reads once). + expect(screen.getAllByText("web")).toHaveLength(1); + } finally { + restore(); + } + }); + + it("a remembered row forgets by id; a recent row remembers on its own machine", async () => { + const { calls, restore } = await renderSection({ saved: [savedRow], recent: [recentRow], defaultNodeId: null }); + try { + fireEvent.click(screen.getByRole("button", { name: "Forget web" })); + await waitFor(() => expect(calls.some((c) => c.method === "DELETE")).toBe(true)); + expect(calls.find((c) => c.method === "DELETE")?.url).toBe("/api/ssh/saved-hosts/s1"); + fireEvent.click(screen.getByRole("button", { name: "Remember db.example.com:22" })); + await waitFor(() => expect(calls.some((c) => c.method === "PUT")).toBe(true)); + const put = calls.find((c) => c.method === "PUT"); + expect(put?.url).toBe("/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "db.example.com:22" }); // the row's own machine, no alias + } finally { + restore(); + } + }); + + it("the two empty states are one short sentence each", async () => { + const { restore } = await renderSection({ saved: [], recent: [], defaultNodeId: null }); + try { + expect(screen.getByText("No remembered destinations yet.")).toBeDefined(); + expect(screen.getByText("No recent destinations yet.")).toBeDefined(); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts new file mode 100644 index 000000000..45e8b4997 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { ApiError, NetworkError } from "@internal/node-admin"; +import { isSshResolveRefusal, type SshMachineFacts, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; + +/** + * The refusal matrix of `POST /api/ssh/launch` (launch.route.ts's response + * map), each branch named (Task 3 addition 1). The 422 is the branch with + * two roads: a parsed `{outcome}` body renders the blocked settings verbatim + * even when the display message was sliced to 200 chars, and an old or + * body-less 422 falls back to the message text rather than inventing one. + */ +const machine = (over: Partial): SshMachineFacts => ({ + name: "mac mini", + kind: "agent", + sshEnabled: true, + ...over, +}); + +describe("sshLaunchRefusal", () => { + it("422 with a body renders every blocked setting verbatim, sliced message or not", () => { + const settings = ["ProxyCommand", "LocalForward", "RemoteForward"]; + const raw = JSON.stringify({ outcome: { accepted: false, code: "unsupported_setting", settings } }); + const err = new ApiError(422, raw.slice(0, 200), { body: JSON.parse(raw) }); + const copy = sshLaunchRefusal(err, machine({})); + expect(copy).toEqual({ + text: "Config needs settings Subshell does not run: ProxyCommand, LocalForward, RemoteForward. Edit them on the connecting machine and retry.", + field: "destination", + }); + }); + + it("422 whose code names the whole cause (no settings) says so without an empty list", () => { + const err = new ApiError(422, "", { body: { outcome: { accepted: false, code: "config_missing", settings: [] } } }); + const copy = sshLaunchRefusal(err, machine({})); + expect(copy?.text).toBe( + "Subshell cannot run this destination as its config stands. Edit that config on the connecting machine and retry.", + ); + expect(copy?.field).toBe("destination"); + }); + + it("422 with no parseable body falls back to the message text", () => { + const err = new ApiError(422, "refused"); // no body: an old server or a proxy page + expect(sshLaunchRefusal(err, machine({}))).toEqual({ text: "API 422: refused", field: "destination" }); + }); + + it("400 names the destination as invalid, not the machine", () => { + expect(sshLaunchRefusal(new ApiError(400, "x", { code: "ALIAS_UNSAFE" }), machine({}))).toEqual({ + text: "That destination is not a valid host name.", + field: "destination", + }); + }); + + it("403 SSH_GATE_OFF on a gated-off agent names the owner remedy", () => { + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), machine({ sshEnabled: false }))).toEqual({ + text: "SSH is off on this machine. Ask its owner to enable it.", + field: "machine", + }); + }); + + it("403 SSH_GATE_OFF on the control-plane host names the admin remedy", () => { + const local = machine({ kind: "local", name: "this host", sshEnabled: false }); + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), local)).toEqual({ + text: "An admin can enable SSH on this machine in its settings.", + field: "machine", + }); + }); + + it("403 with the gate ON is the owner-door: no share reaches SSH", () => { + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), machine({}))).toEqual({ + text: "You can't connect from this machine.", + field: "machine", + }); + }); + + it("404 says the machine isn't available", () => { + expect(sshLaunchRefusal(new ApiError(404, "x", { code: "NOT_FOUND_ERROR" }), machine({}))).toEqual({ + text: "That machine isn't available.", + field: "machine", + }); + }); + + it("409 arms name the machine and their own cause", () => { + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OFFLINE" }), machine({}))?.text).toBe( + "mac mini has no live connection right now. Bring its Subshell app online and retry.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_PROTOCOL_HELD" }), machine({}))?.text).toBe( + "mac mini runs a Subshell version this server does not speak. Update it from its machine page and retry.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OUTDATED" }), machine({}))?.text).toBe( + "The Subshell app on mac mini is too old to speak SSH. Update it from its machine page.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_UNREACHABLE" }), machine({}))?.text).toBe( + "mac mini did not answer the SSH request in time. Check its connection and retry.", + ); + // No machine facts (the row vanished) keeps the sentence grammatical. + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OFFLINE" }), null)?.text).toBe( + "That machine has no live connection right now. Bring its Subshell app online and retry.", + ); + }); + + it("502 is the machine's own refusal, in one sentence", () => { + expect(sshLaunchRefusal(new ApiError(502, "x", { code: "SSH_NODE_REFUSED" }), machine({}))).toEqual({ + text: "The connecting machine did not answer.", + field: "machine", + }); + }); + + it("a NetworkError renders nothing: the global offline banner already says it", () => { + expect(sshLaunchRefusal(new NetworkError(new TypeError("down")), machine({}))).toBeNull(); + }); + + it("an unexpected failure gets the generic red line, never an API string", () => { + expect(sshLaunchRefusal(new Error("boom"), machine({}))?.text).toBe("The connection could not be started."); + expect(sshLaunchRefusal(new ApiError(500, "Internal"), machine({}))?.text).toBe( + "The connection could not be started.", + ); + }); + + it("every shipped sentence is at most two sentences and dash-free", () => { + const texts = [422, 400, 403, 404, 409, 502].flatMap((status) => { + const copy = sshLaunchRefusal(new ApiError(status, "x", { code: "ANY" }), machine({ sshEnabled: false })); + return copy === null ? [] : [copy.text]; + }); + for (const text of texts) { + expect(text).not.toMatch(/[─—–]/); // U+2500, U+2014, U+2013 + expect(text.split(/[.!?] /).length).toBeLessThanOrEqual(2); + } + }); + + it("isSshResolveRefusal narrows the refused arm only", () => { + expect(isSshResolveRefusal({ accepted: false, code: "config_missing", settings: [] })).toBe(true); + expect(isSshResolveRefusal({ accepted: true, snapshot: {} })).toBe(false); + expect(isSshResolveRefusal({ accepted: false, code: "x" })).toBe(false); // no settings array + expect(isSshResolveRefusal(null)).toBe(false); + expect(isSshResolveRefusal("outcome")).toBe(false); + }); +}); diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx new file mode 100644 index 000000000..dc6c03d57 --- /dev/null +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -0,0 +1,252 @@ +import type { Node } from "@internal/node-admin"; +import { Button, Label } from "@internal/node-admin"; +import { useNavigate } from "@tanstack/react-router"; +import { type JSX, useEffect, useState } from "react"; +import { DestinationField } from "@/components/connect/destination-field"; +import { buildDestinationOptions, type DestinationCandidate } from "@/components/connect/destination-options"; +import { SavedHostsSection } from "@/components/connect/saved-hosts-section"; +import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; +import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { useNodes } from "@/hooks/use-nodes"; +import { useLaunchSsh, useSaveSshHost, useSetDefaultNode, useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; +import { fieldErrorToned } from "@/lib/form"; +import { nodeOptionLabel } from "@/lib/node-label"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import { launchableNodes } from "@/lib/subshell-compat"; + +/** + * The destination-first Connect flow (spec 2026-10-07 §7, decision 2): the + * destination leads, the connecting machine follows as a disclosure line, + * and one POST opens the pane - the SERVER resolves, so selecting a row is + * display, never a pre-resolve step. Three nouns only (the §11 vocabulary): + * destination, connecting machine. No key-source affordance (that is M2). + * + * A machine whose SSH is off stays VISIBLE and disabled with its reason - + * greying explains, hiding does not (decision 3). With no default machine and + * several SSH-enabled ones the disclosure is required with the gold `*` and + * never silently chosen; with a preference or exactly one usable machine it + * pre-selects honestly and stays on screen. The disclosure sentence (the + * `Match exec` note, decision 6) ships verbatim under the destination field, + * before the button, because approving a destination approves running its + * resolution. + * + * The destination is one COMMITTED candidate: a picked row's meaning, or + * nothing. Typed text becomes a candidate through the mirror row the list + * offers (free text accepted, §7), and the commitment releases under any + * later edit, so the field's text and the launch's truth cannot drift. + * The field is the working-directory field's input-plus-panel posture + * (`destination-field.tsx`), not the shared combobox: the list must swap on + * every keystroke, which is exactly what Base UI's held input cannot keep. + * Refusals render on the field they belong to, in red, from + * `ssh-refusal.ts`; the 422's settings list comes from the parsed body, + * because the display message is sliced. Remembering is explicit: every + * launch is already recent server-side; the checkbox PUTs the saved row only + * when checked, sending the alias only for a pick from the machine's own + * config list (§7's alias-is-display rule). + */ +const DESTINATION_IDS = { field: "connect-destination", disclosure: "connect-destination-disclosure" }; +const MACHINE_IDS = { field: "connect-machine", requirement: "connect-machine-requirement" }; + +/** The disclosure every ssh surface owes before the button (decision 6, verbatim). */ +export const SSH_DISCLOSURE_COPY = + "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves."; + +/** The destination field's committed state, held as ONE value so a pick and + * the text echo that follows it cannot interleave into a stale pair. */ +interface DestinationState { + /** The picked row's wire meaning; null = nothing committed yet */ + pick: DestinationCandidate | null; + /** The picked row's option id (the picker's held value) */ + pickId: string | null; + /** The input's live text (feeds the typed mirror row; not a commitment) */ + typed: string; +} + +export function ConnectPanel(): JSX.Element { + const navigate = useNavigate(); + const { data: nodeData } = useNodes(); + const nodes = Array.isArray(nodeData?.nodes) ? nodeData.nodes : null; + const { data: ledger } = useSshSavedHosts(); + const launch = useLaunchSsh(); + const save = useSaveSshHost(); + const setDefault = useSetDefaultNode(); + + const [nodeId, setNodeId] = useState(""); + const [dest, setDest] = useState({ pick: null, pickId: null, typed: "" }); + const [remember, setRemember] = useState(false); + // The gold caption waits for a submit that found the machine untouched: + // "nothing typed yet", never before the first attempt (ruling 2026-09-30). + const [submitAttempted, setSubmitAttempted] = useState(false); + const [refusal, setRefusal] = useState(null); + + const targets = launchableNodes(nodes ?? []); + const selectedNode: Node | null = (nodes ?? []).find((n) => n.id === nodeId) ?? null; + const defaultNodeId = ledger?.defaultNodeId ?? null; + const enabledCount = targets.filter((n) => n.sshEnabled).length; + // No preference, several doors: the choice is the human's (spec §7). + const machineRequired = nodeId === "" && defaultNodeId === null && enabledCount > 1; + + // The machine disclosure keeps gated-off rows visible (decision 3): grey, + // with the reason naming what is in the way. + const machineOptions: ComboboxOption[] = targets.map((n) => ({ + value: n.id, + label: nodeOptionLabel(n), + disabled: !n.sshEnabled, + reason: n.sshEnabled ? undefined : "SSH is off on this machine", + })); + + // Aliases are the MACHINE's config, asked only while a gate-ON machine is + // picked (spec §7: discovery runs where it can answer). + const aliasesQ = useSshAliases(selectedNode?.sshEnabled ? selectedNode.id : null); + const aliases = aliasesQ.data?.aliases ?? []; + + const { options: destinationOptions, candidates } = buildDestinationOptions({ + saved: ledger?.saved ?? [], + recent: ledger?.recent ?? [], + aliases, + machineName: selectedNode?.name ?? null, + typed: dest.typed, + }); + + // The default pre-fill, once and only over an untouched pick: the stored + // preference wins when its machine is still a usable row; an only child + // pre-selects honestly; several with no preference stay an open question + // (decision 2). Keyed on the loaded node list, never on `targets` (a new + // array every render). + useEffect(() => { + if (nodeId !== "" || nodes === null) return; + const usable = launchableNodes(nodes).filter((n) => n.sshEnabled); + if (defaultNodeId !== null && usable.some((n) => n.id === defaultNodeId)) { + setNodeId(defaultNodeId); + } else if (usable.length === 1) { + setNodeId(usable[0].id); + } + }, [nodeId, nodes, defaultNodeId]); + + const destination = dest.pick?.destination ?? ""; + // A committed row's label is the field's text; while that echo stands the + // list is UNFILTERED (the whole ledger again), because the echo is display, + // not a search. Any keystroke releases the pick, and text becomes query. + const pickLabel = dest.pickId !== null ? destinationOptions.find((o) => o.value === dest.pickId)?.label : undefined; + const destinationQuery = dest.pick !== null && dest.typed === pickLabel ? "" : dest.typed; + + // Typing is only typing here (the field reports keystrokes, never echoes), + // so an edit always releases the commitment: what the field shows is what + // the launch would carry, never a stale pick under new text. + function onDestinationText(text: string): void { + setDest({ pick: null, pickId: null, typed: text }); + } + + /** Clicking (or Enter on) a row commits its candidate; the label becomes + * the field's text, which is what a re-opened list then echoes. */ + function pickDestinationOption(option: ComboboxOption): void { + const candidate = candidates.get(option.value); + if (candidate === undefined) return; + setDest({ pick: candidate, pickId: option.value, typed: option.label }); + } + + async function submit(): Promise { + setSubmitAttempted(true); + if (nodeId === "" || destination === "") return; + setRefusal(null); + try { + const created = await launch.mutateAsync({ node: nodeId, destination }); + if (remember) { + // The alias rides ONLY for a config-list pick (an alias token); saved + // and recent rows already carry their own. + save.mutate({ + node: nodeId, + destination, + ...(dest.pick?.aliasToken !== undefined ? { alias: dest.pick.aliasToken } : {}), + }); + } + void navigate({ to: "/subshells/$id", params: { id: created.subshell.id } }); + } catch (err) { + setRefusal(sshLaunchRefusal(err, selectedNode)); + } + } + + const machineGap = + submitAttempted && machineRequired + ? fieldErrorToned([{ message: "Choose a connecting machine first.", gap: true }]) + : null; + + return ( +
+
+
+ + + {refusal?.field === "destination" && ( +

+ {refusal.text} +

+ )} +

+ {SSH_DISCLOSURE_COPY} +

+
+ +
+ + id !== "" && setNodeId(id)} + /> + {machineGap && ( +

+ {machineGap.text} +

+ )} + {refusal?.field === "machine" && ( +

+ {refusal.text} +

+ )} + {selectedNode !== null && defaultNodeId !== selectedNode.id && ( + + )} +
+ +
+ setRemember(e.target.checked)} + className="h-4 w-4 rounded border border-input bg-background accent-primary" + /> + +
+ + +
+ + +
+ ); +} diff --git a/apps/server/web/src/components/connect/destination-field.tsx b/apps/server/web/src/components/connect/destination-field.tsx new file mode 100644 index 000000000..c25e24be2 --- /dev/null +++ b/apps/server/web/src/components/connect/destination-field.tsx @@ -0,0 +1,139 @@ +import { Input } from "@internal/node-admin"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { ComboboxOption } from "@/components/ui/combobox"; + +/** + * The destination field: a real input, because the answer may be a host no + * list carries, with an in-flow row panel that opens on focus. The posture + * is the working-directory field's (`directory-picker-input.tsx`), copied + * deliberately: a controlled input, a panel that follows its text, and an + * outside press or Escape as the dismissal. `SearchableSelect` is NOT used + * here on purpose - Base UI's collection reset wipes a held input's typed + * text when its items swap mid-typing (measured and documented in + * `ui/combobox.tsx`), and this field's items must swap on EVERY keystroke: + * the typed mirror row is the list growing to meet what was typed. + * + * The list is the three fed groups (`destination-options.ts`): Saved, + * Recent, and the picked machine's config aliases, each under its header, + * plus the mirror row. Clicking (or pressing Enter on the top row) COMMITS + * a row - the commitment is the candidate the launch carries, never the raw + * echo - and typing over a committed row releases it, so the field's text + * and the panel's truth cannot drift apart. + */ +export function DestinationField({ + id, + describedBy, + placeholder, + typed, + query, + options, + onTextChange, + onPick, +}: { + /** Input id - the Label's htmlFor and the test/e2e anchor */ + id: string; + /** Id of the element explaining this field (the disclosure sentence) */ + describedBy?: string; + placeholder: string; + /** The input's text: typed free text, or the committed row's label */ + typed: string; + /** What filters the list; "" while a committed row's echo stands */ + query: string; + /** The full fed list (groups + mirror row); the field only filters it */ + options: readonly ComboboxOption[]; + onTextChange: (text: string) => void; + /** Commits one row (a click, or Enter on the top visible row) */ + onPick: (option: ComboboxOption) => void; +}): JSX.Element { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Dismissal by anything outside the field, or Escape - the directory + // picker's rule, and the listeners live with `open` so an idle field costs + // nothing. + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // The same filter the shared combobox applies, so the picker and every + // other list in the product answer a query identically: label or searchText, + // case-insensitive contains, trimmed. + const needle = query.trim().toLowerCase(); + const visible = + needle === "" + ? [...options] + : options.filter( + (o) => o.label.toLowerCase().includes(needle) || (o.searchText ?? "").toLowerCase().includes(needle), + ); + + function pick(option: ComboboxOption): void { + onPick(option); + setOpen(false); + } + + let lastGroup: string | undefined; + return ( +
+ onTextChange(e.target.value)} + onFocus={() => setOpen(true)} + placeholder={placeholder} + aria-describedby={describedBy} + onKeyDown={(e) => { + if (e.key === "Enter" && visible.length > 0) { + e.preventDefault(); // committing the top row is the act; it must not submit the page + pick(visible[0]); + } + }} + /> + {open && visible.length > 0 && ( + // The id lets callers (and tests) scope to the popup: a group header + // like "Recent" names the same word the ledger heading below uses, + // and only the popup's copy is the picker's. +
+ {visible.map((option) => { + const header = option.group !== undefined && option.group !== lastGroup; + lastGroup = option.group; + return ( +
+ {header &&
{option.group}
} + +
+ ); + })} +
+ )} + {open && visible.length === 0 &&

No matches

} +
+ ); +} diff --git a/apps/server/web/src/components/connect/destination-options.ts b/apps/server/web/src/components/connect/destination-options.ts new file mode 100644 index 000000000..4e0b63a31 --- /dev/null +++ b/apps/server/web/src/components/connect/destination-options.ts @@ -0,0 +1,98 @@ +import type { ComboboxOption } from "@/components/ui/combobox"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The destination field's list, as pure data (the picker itself is the + * panel's). Three fed groups per spec 2026-10-07 §7: the caller's saved rows, + * their recent rows, and the picked machine's own config aliases - plus the + * one row that makes the field accept free text: a row mirroring whatever is + * typed. Picking is the commitment; the candidate map says what each row + * MEANS on the wire, so display aliases never leak into the launch body. + */ + +/** What choosing one row commits the launch to. */ +export interface DestinationCandidate { + /** The token sent as `destination` (an alias from the config, or a concrete host) */ + destination: string; + /** + * The alias display token, set ONLY for rows that came from the machine's + * config list - the save sends it as the row's display override (§7's + * alias-is-display rule). Saved and recent rows already own an alias. + */ + aliasToken?: string; +} + +/** The typed mirror row's group: none. It leads the list unlabelled. */ +const TYPED_PREFIX = "typed:"; +const SAVED_PREFIX = "saved:"; +const RECENT_PREFIX = "recent:"; +const CONFIG_PREFIX = "config:"; + +/** The group headers, one definition so the panel and its tests agree. */ +export const SAVED_GROUP = "Saved"; +export const RECENT_GROUP = "Recent"; +/** `From mac mini's config` - the aliases belong to the machine, and the row says so. */ +export const configGroupLabel = (machineName: string): string => `From ${machineName}'s config`; + +/** The destination field's full list plus the per-row candidate map. */ +export function buildDestinationOptions(args: { + /** The caller's saved rows (newest first, as the server sends them) */ + saved: readonly SshSavedHost[]; + /** The caller's recent rows; rows already Saved are dropped from Recent */ + recent: readonly SshSavedHost[]; + /** The picked machine's alias names, fetched only while its gate is on */ + aliases: readonly string[]; + /** The picked machine's display name, for the config group header */ + machineName: string | null; + /** The field's current typed text - the mirror row's source */ + typed: string; +}): { options: ComboboxOption[]; candidates: Map } { + const options: ComboboxOption[] = []; + const candidates = new Map(); + + const push = (id: string, option: ComboboxOption, candidate: DestinationCandidate): void => { + options.push(option); + candidates.set(id, candidate); + }; + + // The free-text mirror first: whatever is typed is offered as a row the + // click commits. Suppressed when a real row already carries the same + // token - the row would be a duplicate of a choice already on screen. + const text = args.typed.trim(); + const covered = (token: string): boolean => + args.saved.some((h) => h.destination === token || h.alias === token) || + args.recent.some((h) => h.destination === token || h.alias === token) || + args.aliases.some((a) => a === token); + if (text !== "" && !covered(text)) { + push(`${TYPED_PREFIX}${text}`, { value: `${TYPED_PREFIX}${text}`, label: text }, { destination: text }); + } + + const savedIds = new Set(args.saved.map((h) => h.id)); + for (const row of args.saved) { + const label = row.alias ?? row.destination; + push( + `${SAVED_PREFIX}${row.id}`, + { value: `${SAVED_PREFIX}${row.id}`, label, group: SAVED_GROUP, searchText: row.destination }, + { destination: row.destination }, + ); + } + for (const row of args.recent) { + if (savedIds.has(row.id)) continue; // the destination reads once (the favorites precedent) + const label = row.alias ?? row.destination; + push( + `${RECENT_PREFIX}${row.id}`, + { value: `${RECENT_PREFIX}${row.id}`, label, group: RECENT_GROUP, searchText: row.destination }, + { destination: row.destination }, + ); + } + if (args.machineName !== null) { + for (const token of args.aliases) { + push( + `${CONFIG_PREFIX}${token}`, + { value: `${CONFIG_PREFIX}${token}`, label: token, group: configGroupLabel(args.machineName) }, + { destination: token, aliasToken: token }, + ); + } + } + return { options, candidates }; +} diff --git a/apps/server/web/src/components/connect/saved-hosts-section.tsx b/apps/server/web/src/components/connect/saved-hosts-section.tsx new file mode 100644 index 000000000..fd8bc7584 --- /dev/null +++ b/apps/server/web/src/components/connect/saved-hosts-section.tsx @@ -0,0 +1,111 @@ +import { Star, Trash2 } from "lucide-react"; +import type { JSX } from "react"; +import { useNodes } from "@/hooks/use-nodes"; +import { useDeleteSshHost, useSaveSshHost, useSshSavedHosts } from "@/hooks/use-ssh"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The destination ledger under the Connect panel (spec 2026-10-07 §7): what + * the caller remembered, and what they last connected to. Two lists, one + * shape: `label` over `detail`, differing by weight and colour - the label is + * the display alias or the destination, the detail names the connecting + * machine and the day. Remembered rows carry the way OUT (delete); recent + * rows carry the way IN (remember). Recent rows already Saved read once: the + * remembered copy wins, nothing duplicates. + * + * The save is a plain PUT keyed to the row's own machine - the server + * resolves first and stores the canonical destination, so a rename of the + * alias later cannot re-point the key. The delete is by row id. No confirms: + * a ledger row is cheap to remake, and the ACT that matters is the launch. + */ +export function SavedHostsSection(): JSX.Element { + const { data: ledger } = useSshSavedHosts(); + const { data: nodeData } = useNodes(); + const save = useSaveSshHost(); + const remove = useDeleteSshHost(); + + const saved = ledger?.saved ?? []; + const savedIds = new Set(saved.map((h) => h.id)); + const recent = (ledger?.recent ?? []).filter((h) => !savedIds.has(h.id)); + const nameById = new Map( + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).map((n) => [n.id, n.name]), + ); + const machineName = (row: SshSavedHost): string => nameById.get(row.nodeId) ?? row.nodeId; + + return ( +
+
+

Remembered

+ {saved.length === 0 ? ( +

No remembered destinations yet.

+ ) : ( +
    + {saved.map((row) => ( + remove.mutate(row.id), + }} + /> + ))} +
+ )} +
+
+

Recent

+ {recent.length === 0 ? ( +

No recent destinations yet.

+ ) : ( +
    + {recent.map((row) => ( + save.mutate({ node: row.nodeId, destination: row.destination }), + }} + /> + ))} +
+ )} +
+
+ ); +} + +/** One ledger line: label over detail, with the row's single act on the right. */ +function LedgerRow({ + row, + detail, + action, +}: { + row: SshSavedHost; + detail: string; + action: { label: string; icon: typeof Star; onSelect: () => void }; +}): JSX.Element { + const Icon = action.icon; + return ( +
  • +
    +
    {row.alias ?? row.destination}
    +
    {detail}
    +
    + +
  • + ); +} diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts new file mode 100644 index 000000000..8785bae63 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -0,0 +1,122 @@ +import type { Node } from "@internal/node-admin"; +import { ApiError, isNetworkError } from "@internal/node-admin"; +import type { SshResolveOutcome } from "@/lib/ssh"; + +/** + * The launch refusal, in the client's words. `POST /api/ssh/launch` answers + * every no with a status and a code the panel can name (the response map in + * `apps/server/api/src/api/ssh/launch.route.ts`); this file is the ONE place + * that maps them to copy, one sentence pair per branch, so the panel renders + * a cause and a remedy rather than an `API 409:` string. + * + * The 422 is the special case: its body is not the error envelope but the + * resolve outcome itself (`{outcome}`), and `ApiError.message` slices the + * display text to 200 chars, so the settings list is read from `err.body` + * (the node-admin un-truncation). An older or truncated body falls back to + * the message text; nothing renders a half sentence from a missing field. + */ + +/** The refused arm of the resolve outcome, narrowed from `unknown`. */ +export function isSshResolveRefusal(value: unknown): value is Extract { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return v.accepted === false && typeof v.code === "string" && Array.isArray(v.settings); +} + +/** Which field the refusal belongs under: the token typed, or the machine chosen. */ +export type RefusalField = "destination" | "machine"; + +/** The red line the panel renders, and where. */ +export interface SshRefusalCopy { + text: string; + field: RefusalField; +} + +/** What the panel knows about the connecting machine at submit time. */ +export type SshMachineFacts = Pick; + +/** The settings-bearing sentence names each blocked keyword verbatim; the + * code-only arms (empty settings) name the fact and the place to fix it. */ +function refusalFromOutcome(outcome: Extract): string { + return outcome.settings.length > 0 + ? `Config needs settings Subshell does not run: ${outcome.settings.join(", ")}. Edit them on the connecting machine and retry.` + : "Subshell cannot run this destination as its config stands. Edit that config on the connecting machine and retry."; +} + +/** + * Maps a caught launch failure to copy. A `NetworkError` returns null: the + * app-wide offline banner already says it, and restating "server unreachable" + * under the form would be the same sentence twice. + * @param err - the value from the launch mutation's catch + * @param machine - the picked node's facts (null when the row is unknown) + */ +export function sshLaunchRefusal(err: unknown, machine: SshMachineFacts | null): SshRefusalCopy | null { + if (isNetworkError(err)) return null; + if (err instanceof ApiError) { + switch (err.status) { + case 400: + // ALIAS_UNSAFE: the token was refused before the machine was asked. + return { text: "That destination is not a valid host name.", field: "destination" }; + case 403: { + if (err.code === "SSH_GATE_OFF" && machine !== null && !machine.sshEnabled) { + // The gate's off-door. Naming WHO flips it follows the row's kind: + // `local` is an admin's setting, every other machine its owner's + // (spec 2026-10-07 decision 3). + return { + text: + machine.kind === "local" + ? "An admin can enable SSH on this machine in its settings." + : "SSH is off on this machine. Ask its owner to enable it.", + field: "machine", + }; + } + // The owner-door (the code is shared) or any other 403: the machine + // is open to someone else, and a share is not a remedy here. + return { text: "You can't connect from this machine.", field: "machine" }; + } + case 404: + return { text: "That machine isn't available.", field: "machine" }; + case 409: { + const name = machine?.name ?? "That machine"; + switch (err.code) { + case "NODE_PROTOCOL_HELD": + return { + text: `${name} runs a Subshell version this server does not speak. Update it from its machine page and retry.`, + field: "machine", + }; + case "NODE_OUTDATED": + return { + text: `The Subshell app on ${name} is too old to speak SSH. Update it from its machine page.`, + field: "machine", + }; + case "NODE_UNREACHABLE": + return { + text: `${name} did not answer the SSH request in time. Check its connection and retry.`, + field: "machine", + }; + default: + // NODE_OFFLINE and any other 409: no live link to carry the ask. + return { + text: `${name} has no live connection right now. Bring its Subshell app online and retry.`, + field: "machine", + }; + } + } + case 422: { + // The resolve refused: read the outcome from the parsed body, because + // the message is a 200-char slice of it. + const outcome = + typeof err.body === "object" && err.body !== null ? (err.body as { outcome?: unknown }).outcome : undefined; + if (isSshResolveRefusal(outcome)) return { text: refusalFromOutcome(outcome), field: "destination" }; + return { text: err.message, field: "destination" }; + } + case 502: + // The machine answered its own refusal (no ssh binary, the mirror + // says no); its verbatim text never rides the wire to here. + return { text: "The connecting machine did not answer.", field: "machine" }; + default: + break; + } + } + return { text: "The connection could not be started.", field: "machine" }; +} diff --git a/apps/server/web/src/routeTree.gen.ts b/apps/server/web/src/routeTree.gen.ts index d7ae6c85e..288ff1d1f 100644 --- a/apps/server/web/src/routeTree.gen.ts +++ b/apps/server/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AccountRouteImport } from './routes/account' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as LoginRouteImport } from './routes/login' import { Route as NewRouteImport } from './routes/new' import { Route as NodesRouteImport } from './routes/nodes' @@ -48,6 +49,11 @@ const AccountRoute = AccountRouteImport.update({ path: '/account', getParentRoute: () => rootRouteImport, } as any) +const ConnectRoute = ConnectRouteImport.update({ + id: '/connect', + path: '/connect', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -182,6 +188,7 @@ const NodesIdServiceRoute = NodesIdServiceRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -212,6 +219,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -243,6 +251,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -275,6 +284,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -305,6 +315,7 @@ export interface FileRouteTypes { to: | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -335,6 +346,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -366,6 +378,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AccountRoute: typeof AccountRoute + ConnectRoute: typeof ConnectRoute LoginRoute: typeof LoginRoute NewRoute: typeof NewRoute NodesRoute: typeof NodesRoute @@ -410,6 +423,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountRouteImport parentRoute: typeof rootRouteImport } + '/connect': { + id: '/connect' + path: '/connect' + fullPath: '/connect' + preLoaderRoute: typeof ConnectRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -598,6 +618,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccountRoute: AccountRoute, + ConnectRoute: ConnectRoute, LoginRoute: LoginRoute, NewRoute: NewRoute, NodesRoute: NodesRoute, diff --git a/apps/server/web/src/routes/__tests__/connect.test.tsx b/apps/server/web/src/routes/__tests__/connect.test.tsx new file mode 100644 index 000000000..164a85bd9 --- /dev/null +++ b/apps/server/web/src/routes/__tests__/connect.test.tsx @@ -0,0 +1,58 @@ +import { afterEach, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { ConnectPage, Route } from "../connect"; + +/** + * Contract 8: the route exists at /connect and opens with a real page + * heading. The panel itself is pinned by `components/connect/`; the nav + * entry by the sidebar tests; the e2e spec lands here end-to-end. + */ +afterEach(cleanup); + +it("declares the route with a component (the path binds in the generated tree)", () => { + // createFileRoute attaches `path`/`id` when the router builds the tree from + // routeTree.gen.ts; the loadable unit here is the options object, and the + // e2e spec lands on the URL. + expect(typeof Route.options.component).toBe("function"); +}); + +it("opens on the Connect heading over the panel", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const path = new URL(String(input), "http://localhost").pathname; + const body = path === "/api/nodes" ? { nodes: [] } : { saved: [], recent: [], defaultNodeId: null }; + return new Response(JSON.stringify(body), { status: 200 }); + }) as typeof fetch; + try { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/connect", component: ConnectPage }), + ]), + history: createMemoryHistory({ initialEntries: ["/connect"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(await screen.findByRole("heading", { name: "Connect" })).toBeDefined(); + expect(screen.getByPlaceholderText("Choose or type a destination")).toBeDefined(); + } finally { + globalThis.fetch = original; + } +}); diff --git a/apps/server/web/src/routes/connect.tsx b/apps/server/web/src/routes/connect.tsx new file mode 100644 index 000000000..5f520131a --- /dev/null +++ b/apps/server/web/src/routes/connect.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectPanel } from "@/components/connect/connect-panel"; +import { PageHeader } from "@/components/page-header"; + +/** + * The SSH launcher (spec 2026-10-07 §7): the destination-first page the top- + * level Connect nav entry lands on. The route carries no search params - the + * panel is one act, not a view over data; the ledger lives in the panel's + * own queries. All the flow lives in `components/connect/`. + */ +export const Route = createFileRoute("/connect")({ + component: ConnectPage, +}); + +// Exported for the route test (the composition pin); the router reads it +// through `Route`, no other caller should. +export function ConnectPage() { + return ( +
    + + +
    + ); +} From d45df991ee92f7125231524566471abf651905ec Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 7 Oct 2026 23:57:52 -0700 Subject: [PATCH 059/170] =?UTF-8?q?fix(web):=20reviewer=20round=201=20-=20?= =?UTF-8?q?the=20machine=20field's=20explanation=20lines=20are=20now=20ass?= =?UTF-8?q?ociated,=20the=20save=20hook's=20render=20claim=20matches=20rea?= =?UTF-8?q?lity,=20spec=20=C2=A711=20records=20the=20combobox=20free-text?= =?UTF-8?q?=20contradiction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/connect/connect-panel.tsx | 20 +++++++++++++++---- apps/server/web/src/hooks/use-ssh.ts | 7 +++++-- .../specs/2026-10-07-ssh-anywhere-design.md | 8 ++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index dc6c03d57..3248d0df8 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -46,7 +46,11 @@ import { launchableNodes } from "@/lib/subshell-compat"; * config list (§7's alias-is-display rule). */ const DESTINATION_IDS = { field: "connect-destination", disclosure: "connect-destination-disclosure" }; -const MACHINE_IDS = { field: "connect-machine", requirement: "connect-machine-requirement" }; +const MACHINE_IDS = { + field: "connect-machine", + requirement: "connect-machine-requirement", + refusal: "connect-machine-refusal", +}; /** The disclosure every ssh surface owes before the button (decision 6, verbatim). */ export const SSH_DISCLOSURE_COPY = @@ -171,6 +175,13 @@ export function ConnectPanel(): JSX.Element { submitAttempted && machineRequired ? fieldErrorToned([{ message: "Choose a connecting machine first.", gap: true }]) : null; + // aria-describedby may name nothing that is not rendered, so the + // association is exactly the explanation lines currently on screen. + const machineRefusal = refusal?.field === "machine" ? refusal.text : null; + const machineDescribedBy = + [machineGap ? MACHINE_IDS.requirement : null, machineRefusal !== null ? MACHINE_IDS.refusal : null] + .filter((id): id is string => id !== null) + .join(" ") || undefined; return (
    @@ -208,15 +219,16 @@ export function ConnectPanel(): JSX.Element { placeholder="Choose a machine" options={machineOptions} onValueChange={(id) => id !== "" && setNodeId(id)} + describedBy={machineDescribedBy} /> {machineGap && (

    {machineGap.text}

    )} - {refusal?.field === "machine" && ( -

    - {refusal.text} + {machineRefusal !== null && ( +

    )} {selectedNode !== null && defaultNodeId !== selectedNode.id && ( diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts index c688cae8b..3bd151114 100644 --- a/apps/server/web/src/hooks/use-ssh.ts +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -54,8 +54,11 @@ function useInvalidateSavedHosts(): () => Promise { /** * `PUT /api/ssh/saved-hosts` — resolves the destination first, so a - * refusal-shaped outcome answers 422 `{outcome}` and stores nothing; the - * panel renders that, an `ApiError` with status 422. + * refusal-shaped outcome answers 422 `{outcome}` (an `ApiError` with status + * 422) and stores nothing. Both callers treat it best-effort: the connect + * panel's Remember act and the Recent-star act do not render that refusal, + * because a row already exists from the launch's server-side recency touch, + * so a failed save costs only the `savedAt` pin, never the destination. */ export function useSaveSshHost() { const invalidate = useInvalidateSavedHosts(); diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md index 2ac355107..aff870a4e 100644 --- a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -377,6 +377,14 @@ nouns: destination, connecting machine, key source. The destination field is a `SearchableSelect` like the other launch pickers. Shipped copy follows the design system: role tokens only, at most two sentences, no em dashes. +(Amendment, built. "SearchableSelect like the other launch pickers" holds for the +connecting-machine picker, which is one. For the destination it contradicts "Pick or +type a host" above: the pinned Base UI combobox (measured 2026-09-29, re-verified at +1.7.0) wipes mid-typing input when its option set swaps and cannot commit a value +outside its registered options, so a typed host would vanish - the silent-omission +defect in the control itself. The destination ships as the working-directory field's +controlled input-plus-panel posture: same role, same search, free text commitable.) + ## 12. Failure modes and UX - A offline in relay mode: refuse before the handshake, name the remedy (bring the From db782422d3cc6212d475e87a77257f88a7a0c68d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:01:21 -0700 Subject: [PATCH 060/170] feat(web): ssh panes label themselves and tell viewers why typing is off --- .../__tests__/clone-subshell-dialog.test.tsx | 1 + .../__tests__/existing-subshell-list.test.tsx | 1 + .../__tests__/open-in-browser.test.tsx | 1 + .../__tests__/pane-diagnostics-hud.test.tsx | 1 + .../__tests__/ssh-pane-labels.test.tsx | 55 +++++++++++++++++++ .../__tests__/subshell-actions-menu.test.tsx | 1 + .../__tests__/subshell-card.test.tsx | 1 + .../__tests__/subshell-manager-table.test.tsx | 1 + .../__tests__/subshell-status.test.tsx | 1 + .../__tests__/switch-preset-dialog.test.tsx | 1 + .../__tests__/terminal-overlay-stack.test.tsx | 1 + .../__tests__/workspace-pane-actions.test.tsx | 1 + .../__tests__/subshell-recent-row.test.tsx | 1 + .../web/src/components/ssh-pane-labels.tsx | 27 +++++++++ .../__tests__/add-subshell-dialog.test.tsx | 1 + apps/server/web/src/routes/subshells_.$id.tsx | 6 ++ apps/server/web/src/types/subshell.ts | 2 + 17 files changed, 103 insertions(+) create mode 100644 apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx create mode 100644 apps/server/web/src/components/ssh-pane-labels.tsx diff --git a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx index d5cf02ed6..198af03b6 100644 --- a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx @@ -37,6 +37,7 @@ function makeSource(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx index 65a2b3c78..58731f77b 100644 --- a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx +++ b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx @@ -27,6 +27,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, nodeOffline: false, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx index 70fa74908..e09567bb0 100644 --- a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx +++ b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx @@ -155,6 +155,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx b/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx index 52eba59a8..f4b59b40c 100644 --- a/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx +++ b/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx @@ -32,6 +32,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx new file mode 100644 index 000000000..cf721faaf --- /dev/null +++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, render, screen } from "@testing-library/react"; +import { SSH_VIEW_ONLY_COPY, SshPaneLabels } from "@/components/ssh-pane-labels"; +import type { SubshellView } from "@/types/subshell"; + +afterEach(() => cleanup()); + +/** The fields this header row reads; the rest of the view is irrelevant here. */ +function row(overrides: Partial = {}): SubshellView { + return { id: "s-1", ssh: false, access: "owner", ...overrides } as SubshellView; +} + +describe("SshPaneLabels", () => { + it("labels an owner's ssh pane with the SSH badge and says nothing more", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); + }); + + it("labels an edit grantee's ssh pane with the badge only (the sentence is the viewer's)", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); + }); + + it("tells a view-only ssh viewer why typing is off", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText("SSH sessions take input from their owner only.")).not.toBeNull(); + // One sentence pinned exactly (copy rule), and no em/en dash in it. + expect(SSH_VIEW_ONLY_COPY).toBe("SSH sessions take input from their owner only."); + expect(SSH_VIEW_ONLY_COPY).not.toMatch(/[–—]/); + }); + + it("stays silent for an ordinary pane at any access (no regression for view grantees)", () => { + const view = render(); + expect(view.container.textContent).toBe(""); + cleanup(); + const owner = render(); + expect(owner.container.textContent).toBe(""); + }); + + it("renders nothing while the record has not arrived", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("treats an absent ssh field as not-ssh (an older cached payload)", () => { + // A payload cached before the field existed has no `ssh` key at all; + // the component's test is `=== true`, so it must read as not-ssh. + const cached = { id: "s-1", access: "view" } as SubshellView; + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index c4ed25f43..8e7702805 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -38,6 +38,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/subshell-card.test.tsx b/apps/server/web/src/components/__tests__/subshell-card.test.tsx index 65e223466..15e7da1fc 100644 --- a/apps/server/web/src/components/__tests__/subshell-card.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-card.test.tsx @@ -77,6 +77,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx b/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx index 1a1b02817..7539d6946 100644 --- a/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx @@ -40,6 +40,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/subshell-status.test.tsx b/apps/server/web/src/components/__tests__/subshell-status.test.tsx index e313ffe4a..853ac6503 100644 --- a/apps/server/web/src/components/__tests__/subshell-status.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-status.test.tsx @@ -27,6 +27,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, nodeOffline: false, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx b/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx index cb9da72ab..b959a2347 100644 --- a/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx @@ -30,6 +30,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx b/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx index 72bddcbae..d1442898f 100644 --- a/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx +++ b/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx @@ -32,6 +32,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx b/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx index 35c60191a..afad745b9 100644 --- a/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx +++ b/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx @@ -32,6 +32,7 @@ function row(id: string, access: SubshellView["access"] = "owner"): SubshellView status: "running", alive: true, access, + ssh: false, nodeOffline: false, notify: false, activity: "idle", diff --git a/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx b/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx index b25d3da24..f446b554c 100644 --- a/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx @@ -51,6 +51,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx new file mode 100644 index 000000000..fc0f795ef --- /dev/null +++ b/apps/server/web/src/components/ssh-pane-labels.tsx @@ -0,0 +1,27 @@ +import type { SubshellView } from "@/types/subshell"; + +/** The header sentence a non-owner of an ssh pane sees (spec 2026-10-07 §5.4). */ +export const SSH_VIEW_ONLY_COPY = "SSH sessions take input from their owner only."; + +/** + * The ssh facts a pane header states beside its other labels: an SSH badge + * naming what kind of pane this is, and — only for a viewer who is not the + * owner — the one sentence explaining why the terminal takes no input from + * them. Enforcement is NOT here: the view layer already answers a non-owner + * with `access: "view"` for an ssh row (spec §5.4), and the terminal reads + * exactly that (`readOnly`, subshell-terminal.tsx). An ordinary pane renders + * nothing, including for a `view` grantee: the general view rule is not + * restated per-surface. + * + * `=== true` on the flag is deliberate: a payload cached before the field + * existed has no `ssh` key and must read as an ordinary pane. + */ +export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) { + if (subshell?.ssh !== true) return null; + return ( + <> + SSH + {subshell.access === "view" && {SSH_VIEW_ONLY_COPY}} + + ); +} diff --git a/apps/server/web/src/components/subshell-picker/__tests__/add-subshell-dialog.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/add-subshell-dialog.test.tsx index 8430eecf3..233eaf045 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/add-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/add-subshell-dialog.test.tsx @@ -28,6 +28,7 @@ function makeSubshell(overrides: Partial & { id: string }): Subshe waitingSince: null, unseenPush: false, access: "owner", + ssh: false, nodeOffline: false, ...overrides, }; diff --git a/apps/server/web/src/routes/subshells_.$id.tsx b/apps/server/web/src/routes/subshells_.$id.tsx index 8d814ead7..e71f6c9f8 100644 --- a/apps/server/web/src/routes/subshells_.$id.tsx +++ b/apps/server/web/src/routes/subshells_.$id.tsx @@ -11,6 +11,7 @@ import { EditableText } from "@/components/editable-text"; import { SubshellNotFoundCard } from "@/components/not-found-page"; import { InjectPromptDialog } from "@/components/prompts/inject-prompt-dialog"; import { SplitSubshellButton } from "@/components/split-subshell-button"; +import { SshPaneLabels } from "@/components/ssh-pane-labels"; import { StatusPill } from "@/components/status-pill"; import { SubshellActionsMenu } from "@/components/subshell-actions-menu"; import { SubshellDevices } from "@/components/subshell-devices"; @@ -310,6 +311,11 @@ function SubshellPage() { about the pane stated where its own name is, worded the way the rail's section labels it. */} {subshell?.crossAgent && Cross-agent comms} + {/* What kind of pane this is, and for a non-owner why typing is + off (spec 2026-10-07 §5.4). The badge is the label; the + enforcement is the server's access downgrade the terminal + already honours. */} + {/* Permanent disclosure: whose machine this runs on, and who else can read it. Never suppressible — see components/trust-indicators.tsx. */} diff --git a/apps/server/web/src/types/subshell.ts b/apps/server/web/src/types/subshell.ts index 24cc48c50..c391d1394 100644 --- a/apps/server/web/src/types/subshell.ts +++ b/apps/server/web/src/types/subshell.ts @@ -85,6 +85,8 @@ export interface SubshellView { unseenPush: boolean; /** The caller's effective access to this subshell (viewer-relative; drives which controls render) */ access: SubshellAccess; + /** True when this pane is an SSH-terminal pane: only its owner can type into it. */ + ssh: boolean; /** * How many sharing grants this subshell carries; 0 = private to its owner. * Optional for the same reason `nodeId` is — a payload cached by a client From 8da407e2118bcdaf73225345fd937f1815fd12d5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:09:07 -0700 Subject: [PATCH 061/170] test(e2e): connect flow end to end - destination-first launch and the view-only pane --- e2e/AGENTS.md | 8 +- e2e/ports.ts | 6 + e2e/tests/23-connect-flow.spec.ts | 480 ++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/23-connect-flow.spec.ts diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 5889afbf0..d4b8e7faf 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -51,13 +51,17 @@ runs discovery against this fake), and no spec ever dials a real IdP. Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`, `E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`, -`E2E_PORT_SSH`) with the committed defaults unchanged, the escape hatch for -two suites running side by side on one machine, which the defaults collide on. +`E2E_PORT_SSH`, `E2E_PORT_CONNECT`) with the committed defaults unchanged, the +escape hatch for two suites running side by side on one machine, which the +defaults collide on. Spec `22` boots its own backend too (port 3201) for a reason the shared stack cannot serve: the ssh tier reads the connecting account's HOME and bun caches `os.homedir()` per process, so that child is SPAWNED with `HOME=` plus the spec's `ssh-agent` socket, on its own temp DB/data dir/tmux base. +Spec `23` repeats that boot on port 3202 (its own fresh slot, since spec 22's +instance lives across its retries) and drives the same fixture through the +browser: the `/connect` page, the gate flip, and a view-only ssh pane. Spec `12` extends the stack itself: it spawns the **real `subshell` from source** (`bun apps/node/agent/src/main.ts enroll|run` via `stub/client.ts`, with diff --git a/e2e/ports.ts b/e2e/ports.ts index 07c7451f3..fc44d77da 100644 --- a/e2e/ports.ts +++ b/e2e/ports.ts @@ -35,6 +35,11 @@ * carry for the other specs. Its own temp DB, its own data dir (whose * `ssh/` dir the spec asserts on through the filesystem), and its own * tmux base. + * + * `connect` belongs to spec 23, the browser twin of `ssh`: the same HOME- + * override reason, and a FRESH slot rather than a reuse because spec 22 keeps + * its instance alive across its own retries — two ssh backends boot in one + * run and must never collide on the same port. */ const envPort = (name: string, fallback: number): number => { const raw = process.env[name]; @@ -52,6 +57,7 @@ export const PORTS = { fakeIdp: envPort("E2E_PORT_FAKE_IDP", 3197), onboarding: envPort("E2E_PORT_ONBOARDING", 3200), ssh: envPort("E2E_PORT_SSH", 3201), + connect: envPort("E2E_PORT_CONNECT", 3202), } as const; export const BASE_URL = `http://127.0.0.1:${PORTS.backend}`; diff --git a/e2e/tests/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts new file mode 100644 index 000000000..23cd9fb8a --- /dev/null +++ b/e2e/tests/23-connect-flow.spec.ts @@ -0,0 +1,480 @@ +import { spawn, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { type APIRequestContext, expect, type Page, type PlaywrightWorkerArgs, test } from "@playwright/test"; +import { missingSshBins, SSH_FIXTURE_ALIAS, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { PORTS } from "../ports"; +import { shortTmuxBase } from "../stack"; +import { expectSubshellRunning } from "./helpers"; + +/** + * Plan 3's browser proof (spec 2026-10-07 §7, plan Task 5): the destination- + * first /connect page driven in a REAL browser against a REAL sshd. Four + * scenarios, in order, each its own test with its own page context: + * + * 1. gate OFF (the fresh instance's default): the machine picker keeps the + * control-plane host VISIBLE and DISABLED with the reason "SSH is off on + * this machine" - greying explains, hiding does not (decision 3). The + * disabled row is never clicked; the field staying empty IS the inertness + * assertion. + * 2. the two-sentence Match exec disclosure, verbatim (decision 6), above the + * Connect button BEFORE any Connect is attempted - the box-order check + * makes "before the button" a literal assertion. + * 3. gate ON (a REST flip on the admin's own cookie session, then a reload): + * the sole SSH-enabled machine pre-selects honestly, the destination + * picker's config group carries the fixture alias, Connect POSTs once and + * the router lands on /subshells/; the typed line comes back EVALUATED + * by the remote shell (only the shell's arithmetic spells CONNECT-7-OK), + * read through the pane-log route - spec 22's proof, browser-driven. + * 4. an EVERYONE-edit sharee opening the SAME pane sees the SSH badge and the + * view-only sentence, and their typing leaves nothing in the log (the + * client posture; the 403 itself is tier 2's, pinned in spec 22). + * + * Why its own backend: exactly spec 22's reason - the ssh tier resolves as the + * server process's HOME and bun caches `os.homedir()` per process, so this + * child is SPAWNED with `HOME=` + the spec's ssh-agent socket, + * on its own temp DB, data dir and tmux base (port: PORTS.connect, a fresh + * slot so the two ssh specs' instances can never collide). + * + * The control-plane host is the seeded `local` node, whose picker NAME is + * "Server" (seed-local.ts) - the assertions read that name because the page + * reads node rows, not ids. + * + * Binaries: ssh / ssh-keygen / sshd, decided by the fixture's own gate; a host + * without them SKIPS LOUDLY and never fakes a pass. + */ + +const ROOT = path.join(import.meta.dirname, "..", ".."); +const BACKEND_DIR = path.join(ROOT, "apps", "server", "api"); +const ORIGIN = `http://127.0.0.1:${PORTS.connect}`; + +const READY_TIMEOUT = 60_000; +const SPAWN_TIMEOUT = 90_000; + +const missing = missingSshBins(); +test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join(", ")}) - spec 23 needs a real sshd`); + +/** The exact strings the page ships (components/connect/* + components/ssh-pane-labels.tsx). */ +const SSH_DISCLOSURE_COPY = + "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves."; +const SSH_VIEW_ONLY_COPY = "SSH sessions take input from their owner only."; +const MACHINE_OFF_REASON = "SSH is off on this machine"; +/** The seeded control-plane host's display name (seed-local.ts): the picker reads names, not ids. */ +const LOCAL_NODE_NAME = "Server"; +/** `configGroupLabel(selectedNode.name)` for that row. */ +const CONFIG_GROUP = `From ${LOCAL_NODE_NAME}'s config`; + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** Polls `check` until true or the deadline; on timeout throws the label's current text. */ +async function pollUntil( + label: string | (() => string), + check: () => Promise | boolean, + timeoutMs = SPAWN_TIMEOUT, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return; + if (Date.now() > deadline) throw new Error(typeof label === "string" ? label : label()); + await sleep(500); + } +} + +interface ConnectInstance { + child: ReturnType; + dir: string; + tmuxBase: string; +} + +/** Boots the spec's OWN backend on `PORTS.connect` with HOME + SSH_AUTH_SOCK in ITS env (spec 22's recipe). */ +async function startConnectBackend(fixtureHome: string, sshAuthSock: string): Promise { + // Same orphan guard as specs 15/22: a dead retry's detached backend would + // hold :3202 for the next boot. + stopConnectBackend(); + const dir = mkdtempSync(path.join(tmpdir(), "subshell-e2e-connect-")); + const dataDir = path.join(dir, "data"); + const tmuxBase = shortTmuxBase(); + mkdirSync(tmuxBase, { recursive: true }); + + const env: Record = { + ...process.env, + NODE_ENV: "development", + SUBSHELL_TEST_MODE: "false", + SUBSHELL_SERVER_CONFIG_DIR: dir, + SERVER_PORT: String(PORTS.connect), + HOST: "127.0.0.1", + DATABASE_PATH: path.join(dir, "subshell.db"), + SUBSHELL_SERVER_DATA_DIR: dataDir, + APP_BASE_URL: ORIGIN, + BETTER_AUTH_SECRET: "e2e-secret-not-used-outside-tests-0000000000", + SUBSHELL_PLUGIN_REGISTRY_URL: `http://127.0.0.1:${PORTS.fakeRegistry}`, + SUBSHELL_RELEASE_URL: "", + TMUX_TMPDIR: tmuxBase, + // The two the ssh tier reads (spec 22's header): HOME is the alias/config + // source for discovery AND for `ssh -G`; SSH_AUTH_SOCK is the resolve's + // agent-socket fact. + HOME: fixtureHome, + SSH_AUTH_SOCK: sshAuthSock, + }; + // A developer inside tmux must not hand the backend their session's pane. + delete env.TMUX; + delete env.TMUX_PANE; + + const child = spawn("bun", ["run", "src/index.ts"], { + cwd: BACKEND_DIR, + detached: true, + stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore", + env, + }); + child.unref(); + const inst: ConnectInstance = { child, dir, tmuxBase }; + instance = inst; + + const deadline = Date.now() + READY_TIMEOUT; + for (;;) { + try { + const res = await fetch(`${ORIGIN}/api/setup/status`); + if (res.ok) { + const body = (await res.json()) as { needsSetup: boolean }; + if (!body.needsSetup) throw new Error("[e2e] connect instance: port already set up (leaked run)"); + return inst; + } + } catch (err) { + if ((err as Error).message.includes("port already set up")) throw err as Error; + // Not listening yet. + } + if (Date.now() > deadline) throw new Error(`[e2e] connect instance did not become ready at ${ORIGIN}`); + await sleep(300); + } +} + +/** The instance this file's beforeAll started (module scope: the cleanup paths reach it across retries). */ +let instance: ConnectInstance | undefined; + +function stopConnectBackend(): void { + const inst = instance; + if (!inst) return; + instance = undefined; + try { + if (inst.child.pid) process.kill(-inst.child.pid, "SIGTERM"); + } catch { + // Already gone. + } + // The ssh pane's tmux server detached away from the backend's process group + // (the stack.ts lesson), so kill by socket, then wipe the scratch dirs. + for (const uidDir of readdirSafe(inst.tmuxBase)) { + for (const socket of readdirSafe(path.join(inst.tmuxBase, uidDir))) { + try { + spawnSync("tmux", ["-S", path.join(inst.tmuxBase, uidDir, socket), "kill-server"]); + } catch { + // Best-effort per socket. + } + } + } + rmSync(inst.dir, { recursive: true, force: true }); + rmSync(path.dirname(inst.tmuxBase), { recursive: true, force: true }); +} + +function readdirSafe(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +const ADMIN = { + name: "Connect Admin", + email: "connect-admin@subshell.test", + password: "e2e-connect-admin-pass-1", +} as const; +const MEMBER_PASSWORD = "e2e-connect-member-pass-1"; + +/** + * A fresh cookieless API context (spec 22's rule: the sign-up itself passes + * the Origin/CSRF guard and seeds the jar, so each principal acts as itself). + */ +function freshCtx(playwright: PlaywrightWorkerArgs["playwright"]): Promise { + return playwright.request.newContext({ baseURL: ORIGIN, storageState: { cookies: [], origins: [] } }); +} + +let fixture: SshFixture | undefined; +let fixtureRoot: string | undefined; +let agentPid: number | undefined; +/** The admin's REST session: the gate flips, the pane row, and every log read ride it while the browser does the UI. */ +let admin: APIRequestContext | undefined; +/** The pane test 3 launched; test 4 drives a viewer into it, and the cleanup reaches it by id. */ +let paneId: string | undefined; + +test.beforeAll(async ({ playwright }) => { + // The module-level skip already holds the file off this path; the guard is + // so a skipped file never boots daemons either. + if (missing.length > 0) return; + + fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-connfix-")); + fixture = await startSshFixture(fixtureRoot); + + // An ssh-agent holding the fixture's trusted key (spec 22's setup): the + // backend's resolve reads SSH_AUTH_SOCK from the SERVER process env. The + // fixture alias pins `IdentityAgent none`, so the pane dials with the + // IdentityFile key; the agent is the env fact, not the authenticator. + const agentOut = spawnSync("ssh-agent", ["-s"], { encoding: "utf8" }); + if (agentOut.status !== 0) throw new Error(`ssh-agent failed: ${agentOut.stderr}`); + const agentEnv = Object.fromEntries( + ["SSH_AUTH_SOCK", "SSH_AGENT_PID"].map((k) => [k, agentOut.stdout.match(new RegExp(`${k}=([^;]+)`))?.[1] ?? ""]), + ); + agentPid = Number(agentEnv.SSH_AGENT_PID); + if (!agentEnv.SSH_AUTH_SOCK || !Number.isInteger(agentPid) || agentPid <= 0) { + throw new Error(`ssh-agent output unparseable: ${agentOut.stdout.slice(0, 200)}`); + } + const add = spawnSync("ssh-add", [fixture.trustedKeyPath], { + env: { ...process.env, SSH_AUTH_SOCK: agentEnv.SSH_AUTH_SOCK }, + encoding: "utf8", + }); + if (add.status !== 0) throw new Error(`ssh-add failed: ${add.stderr}`); + + await startConnectBackend(fixture.sshConfigHome, agentEnv.SSH_AUTH_SOCK); + + // The first registered account becomes admin (spec 01's rule on a fresh + // instance), through the wizard's own endpoint. needsSetup is `!hasUsers` + // server-side, so from here the SPA answers the app, not /setup. + admin = await freshCtx(playwright); + const signUp = await admin.post("/api/auth/sign-up/email", { data: ADMIN }); + expect(signUp.ok(), await signUp.text()).toBe(true); + + // The first promoted account is born with the wizard bookmark "network" + // (promoteFirstUserAtomically), which keeps the signed-in root gate on + // /setup. This account was minted by the API, not the wizard, so the + // bookmark is cleared the way the wizard's own completion clears it - the + // PATCH is the same route the wizard's writes go through (cookie session + // only, which the sign-up above seeded). + const retired = await admin.patch("/api/setup/progress", { data: { step: null } }); + expect(retired.ok(), await retired.text()).toBe(true); + expect(((await retired.json()) as { step: string | null }).step).toBeNull(); +}); + +test.afterAll(async () => { + const leaks: string[] = []; + if (paneId !== undefined && admin) { + try { + const res = await admin.post(`/api/subshells/${paneId}/terminate`); + if (!res.ok() && res.status() !== 404) leaks.push(`cleanup terminate: HTTP ${res.status()}`); + await admin.delete(`/api/subshells/${paneId}`); + } catch (err) { + leaks.push(`cleanup row: ${String(err)}`); + } + } + try { + await admin?.dispose(); + } catch (err) { + leaks.push(`admin ctx dispose: ${String(err)}`); + } + admin = undefined; + paneId = undefined; + if (agentPid !== undefined) { + try { + process.kill(agentPid, "SIGTERM"); + } catch { + // Already gone. + } + } + try { + await fixture?.stop(); + } catch (err) { + leaks.push(`sshd fixture stop: ${String(err)}`); + } + try { + stopConnectBackend(); + } catch (err) { + leaks.push(`backend stop: ${String(err)}`); + } + if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true }); + fixture = undefined; + fixtureRoot = undefined; + if (leaks.length > 0) console.error(`[23-connect-flow] cleanup problems: ${leaks.join("; ")}`); +}); + +/** Sign in through the REAL login form on this instance (spec 11's pattern, absolute origin: the page fixture's baseURL is the shared stack). */ +async function login(page: Page, email: string, password: string): Promise { + await page.goto(`${ORIGIN}/login`); + await page.fill("#email", email); + await page.fill("#password", password); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: READY_TIMEOUT }); +} + +/** The /connect page, fields mounted (the panel renders them on mount; the queries fill them in). */ +async function openConnectPage(page: Page): Promise { + await page.goto(`${ORIGIN}/connect`); + await expect(page.getByRole("heading", { name: "Connect", exact: true })).toBeVisible(); + await expect(page.locator("#connect-destination")).toBeVisible(); +} + +function requireAdmin(): APIRequestContext { + if (!admin) throw new Error("[23] beforeAll did not produce the admin session"); + return admin; +} + +function requirePaneId(): string { + if (paneId === undefined) throw new Error("[23] test 3 never produced a pane"); + return paneId; +} + +test("gate off: the control-plane host sits disabled in the machine picker with its reason", async ({ page }) => { + await login(page, ADMIN.email, ADMIN.password); + await openConnectPage(page); + + // Base UI opens its popup on the pointer path; a real Playwright click is + // exactly that. The picker keeps the gated-off row LISTED (decision 3). + await page.locator("#connect-machine").click(); + const popup = page.locator('[data-slot="combobox-content"]'); + const serverRow = popup.getByRole("option", { name: /Server/ }); + await expect(serverRow).toHaveAttribute("data-disabled", ""); + await expect(serverRow).toContainText(MACHINE_OFF_REASON); + + // Inertness, asserted rather than attempted (the T3 handoff): no click is + // fired at the disabled row; the field still carries no selection. + await expect(page.locator("#connect-machine")).toHaveValue(""); +}); + +test("the disclosure names the Match exec risk above the Connect button, before any Connect", async ({ page }) => { + await login(page, ADMIN.email, ADMIN.password); + await openConnectPage(page); + + const disclosure = page.getByText(SSH_DISCLOSURE_COPY); + await expect(disclosure).toBeVisible(); + const connectButton = page.getByRole("button", { name: "Connect", exact: true }); + await expect(connectButton).toBeVisible(); + + // "Before the button" literally: the paragraph's box lies above the + // button's box in the reading column. + const disclosureBox = await disclosure.boundingBox(); + const buttonBox = await connectButton.boundingBox(); + expect(disclosureBox && buttonBox ? disclosureBox.y + disclosureBox.height <= buttonBox.y : false).toBe(true); +}); + +test("gate on: destination-first launch reaches a live ssh pane whose remote shell answers", async ({ page }) => { + test.setTimeout(300_000); + const adminCtx = requireAdmin(); + + // The flip happens OUTSIDE the browser (REST, admin cookie) and the reload + // picks the fact up: the page reads sshEnabled off the node view it fetches + // fresh on mount. + const enable = await adminCtx.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enable.ok(), await enable.text()).toBe(true); + expect(((await enable.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + + await login(page, ADMIN.email, ADMIN.password); + await openConnectPage(page); + + // Exactly one SSH-enabled machine and no preference: pre-selected honestly + // and still on screen (decision 2), labelled with the node's own name. + await expect(page.locator("#connect-machine")).toHaveValue(/Server/); + + // The destination picker's config group (the handoff): the BROWSER session + // fetches the machine's aliases; the fixture's alias rides under it. + await page.locator("#connect-destination").click(); + const panel = page.locator("#connect-destination-panel"); + await expect(panel.getByText(CONFIG_GROUP, { exact: true })).toBeVisible({ timeout: READY_TIMEOUT }); + await panel.getByRole("button", { name: SSH_FIXTURE_ALIAS, exact: true }).click(); + // The committed pick: the field's text is the row's label, and the wire + // value behind it is the alias token (buildDestinationOptions). + await expect(page.locator("#connect-destination")).toHaveValue(SSH_FIXTURE_ALIAS); + + // Connect: one POST, and the router lands on the pane's page. Arm the + // attach socket BEFORE the click (spec 15's rule): typing needs the live + // /ws, not just a running row. + const socket = page.waitForEvent("websocket", { + predicate: (w) => w.url().includes("/ws"), + timeout: READY_TIMEOUT, + }); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page).toHaveURL(/\/subshells\/[a-zA-Z0-9_-]{1,64}$/, { timeout: READY_TIMEOUT }); + paneId = new URL(page.url()).pathname.split("/").pop(); + + // The pane's own header labels the kind it is (T4's badge, owner included). + await expect(page.getByText("SSH", { exact: true }).first()).toBeVisible({ timeout: READY_TIMEOUT }); + await expectSubshellRunning(page, READY_TIMEOUT); + await expect(page.locator(".xterm-screen")).toBeVisible(); + await socket; + + // Settle on sshd's own truth, not the row (spec 22): `alive` is stamped at + // create, and the pty is in the pre-raw line discipline during the ssh + // handshake - it swallows the Enter. The beat after covers the remote + // shell's init before its line editor exists. + const daemonLog = fixture?.logPath; + if (daemonLog === undefined) throw new Error("[23] fixture is gone"); + await pollUntil( + () => `sshd never reported a shell for ${paneId}; its log ends: ${readFileSync(daemonLog, "utf8").slice(-800)}`, + () => /Starting session: shell/.test(readFileSync(daemonLog, "utf8")), + READY_TIMEOUT, + ); + await sleep(1_500); + + await page.locator(".xterm-helper-textarea").click(); + // The typed line NEVER spells the result (spec 22's argument): only the + // REMOTE shell's arithmetic prints CONNECT-7-OK, so the log match proves + // bytes travelled both ways through real ssh, typed here through the page. + await page.keyboard.type("echo CONNECT-$((6+1))-OK\r"); + + let lastLogView = ""; + await pollUntil( + () => + `the pane log never carried the remote shell's evaluated marker; last read: ${JSON.stringify(lastLogView.slice(-1200))}`, + async () => { + const res = await adminCtx.get(`/api/subshells/${paneId}/log`); + if (!res.ok()) { + lastLogView = `HTTP ${res.status()} ${(await res.text()).slice(0, 300)}`; + return false; + } + lastLogView = ((await res.json()) as { lines: string[] }).lines.join("\n"); + return lastLogView.includes("CONNECT-7-OK"); + }, + SPAWN_TIMEOUT, + ); +}); + +test("view only: an edit sharee sees the SSH rule in the header and their typing changes nothing", async ({ page }) => { + test.setTimeout(180_000); + const adminCtx = requireAdmin(); + const id = requirePaneId(); + + // A second account with the ordinary EVERYONE edit grant (spec 22's shape). + const memberEmail = `connect-member-${test.info().retry}@subshell.test`; + const created = await adminCtx.post("/api/users", { + data: { email: memberEmail, name: memberEmail, password: MEMBER_PASSWORD, role: "user" }, + }); + expect(created.ok(), await created.text()).toBe(true); + const shared = await adminCtx.put(`/api/subshells/${id}/shares`, { + data: { shares: [{ granteeUserId: null, permission: "edit" }] }, + }); + expect(shared.ok(), await shared.text()).toBe(true); + + await login(page, memberEmail, MEMBER_PASSWORD); + + // The VIEW layer's downgrade read through the member's own browser session + // (decision 1): the grant says edit, the ssh rule answers view. + const memberView = await page.request.get(`${ORIGIN}/api/subshells/${id}`); + expect(memberView.ok(), await memberView.text()).toBe(true); + const view = (await memberView.json()) as { access: string; ssh: boolean }; + expect(view.access).toBe("view"); + expect(view.ssh).toBe(true); + + await page.goto(`${ORIGIN}/subshells/${id}`); + await expect(page.locator(".xterm-screen")).toBeVisible({ timeout: READY_TIMEOUT }); + // The badge names the kind of pane; the sentence names the rule (T4's copy). + await expect(page.getByText("SSH", { exact: true }).first()).toBeVisible(); + await expect(page.getByText(SSH_VIEW_ONLY_COPY)).toBeVisible(); + + // The client posture: the terminal is mounted but takes nothing. Whether the + // keystroke dies at disableStdin or at the WS gate, the PROOF is the same - + // the nonce never reaches the log (spec 22 owns the 403 itself). + await page.locator(".xterm-helper-textarea").click(); + const nonce = `VIEW-TYPE-${test.info().retry}-${Date.now()}`; + await page.keyboard.type(`echo ${nonce}\r`); + await sleep(1_500); // give any raced write a beat, then require absence from the whole log + const logRes = await adminCtx.get(`/api/subshells/${id}/log`); + expect(logRes.ok(), await logRes.text()).toBe(true); + expect(((await logRes.json()) as { lines: string[] }).lines.join("\n")).not.toContain(nonce); +}); From 20edf35ddc04b25bf04869aa7ba467624b97454c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:11:16 -0700 Subject: [PATCH 062/170] chore(changeset): the destination-first UI (Milestone 1 complete) --- .changeset/ssh-destination-first-ui.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/ssh-destination-first-ui.md diff --git a/.changeset/ssh-destination-first-ui.md b/.changeset/ssh-destination-first-ui.md new file mode 100644 index 000000000..0cce7f795 --- /dev/null +++ b/.changeset/ssh-destination-first-ui.md @@ -0,0 +1,18 @@ +--- +"@internal/server": minor +"@internal/server-web": minor +--- + +The Connect page: one question first - where do you want to work? Pick a +destination from your remembered hosts, your recent ones, or the connecting +machine's SSH config aliases, or type any host; one button opens the pane. +The connecting machine defaults to your chosen one, machines whose SSH is off +stay named with the remedy rather than silently skipped, and a config that +needs settings Subshell does not run shows which ones blocked. SSH-terminal +panes now read as view-only to everyone but their owner everywhere the pane +is shown (the server's view says so, so the terminal is honestly read-only), +and the pane's header labels itself SSH and tells a viewer why typing is off. + +Milestone 1 of the SSH-anywhere design is complete with this: an operator on +an enabled machine opens an interactive session at any sshd-only host, and no +private key or plaintext challenge ever crosses the plane. From 50ef7526cc87186d947c57bc8b4724c9028d4c6b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:23:05 -0700 Subject: [PATCH 063/170] fix(web): reviewer round 1 - maintenance names itself, Connect cannot no-op on an empty machine, the gate remedy rides the row --- .../connect/__tests__/connect-panel.test.tsx | 83 ++++++++++++++++++- .../connect/__tests__/ssh-refusal.test.ts | 40 +++++++-- .../src/components/connect/connect-panel.tsx | 60 +++++++++----- .../web/src/components/connect/ssh-refusal.ts | 20 ++++- 4 files changed, 172 insertions(+), 31 deletions(-) diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx index a984dbb7f..d3b590542 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -181,7 +181,7 @@ async function clickConnect(): Promise { afterEach(cleanup); describe("machine disclosure (contract 1)", () => { - it("keeps a gated-off machine visible, greyed, with the reason naming what is in the way", async () => { + it("keeps a gated-off machine visible, greyed, with the reason naming the remedy", async () => { const { pathname, restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); try { // HOST_A is the sole SSH-enabled machine, so it pre-selects honestly - @@ -190,13 +190,31 @@ describe("machine disclosure (contract 1)", () => { await settle(); const studio = await screen.findByRole("option", { name: /studio/ }); expect(studio.getAttribute("data-disabled")).toBe(""); - expect(studio.textContent).toContain("SSH is off on this machine"); + // The row carries the remedy, not just the cause (the changeset's + // promise; the server's gateCause sentences are the pattern). + expect(studio.textContent).toContain( + "SSH is off on this machine. Its owner can switch it on from this machine's settings.", + ); expect(pathname()).toBe("/"); } finally { restore(); } }); + it("names the admin remedy on a gated-off local row, kind-aware like the server's gate", async () => { + const localOff = node({ id: "loc", name: "this host", kind: "local", sshEnabled: false }); + const { restore } = await renderPanel({ nodes: [HOST_A, localOff] }); + try { + openMachinePopup(); + await settle(); + const row = await screen.findByRole("option", { name: /this host/ }); + expect(row.getAttribute("data-disabled")).toBe(""); + expect(row.textContent).toContain("SSH is off here. An admin can switch it on in Server Settings."); + } finally { + restore(); + } + }); + it("pre-selects the stored default machine", async () => { const { restore } = await renderPanel({ nodes: [HOST_A, HOST_C], @@ -209,16 +227,60 @@ describe("machine disclosure (contract 1)", () => { } }); - it("pre-selects the single SSH-enabled machine with the preference unset", async () => { + it("pre-selects the single SSH-enabled machine with the preference unset, and asks nothing extra", async () => { const { restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); try { await waitFor(() => expect(machineInput().value).toBe("mac mini")); + // The one usable machine is pre-selected honestly: no gold question + // mark, no requirement caption (Connect is live once a destination stands). + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).toBeNull(); + expect(screen.queryByText("Choose a connecting machine first.")).toBeNull(); + } finally { + restore(); + } + }); + + it("a stale default with one enabled machine still pre-selects it, unmarked", async () => { + // The ledger points at a vanished node; the only usable machine wins. + const { restore } = await renderPanel({ + nodes: [HOST_A, HOST_B], + ledger: { saved: [], recent: [], defaultNodeId: "vanished-node" }, + }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).toBeNull(); + } finally { + restore(); + } + }); + + it("a stale default with NO enabled machine: the caption names the missing machine and Connect cannot fire", async () => { + // The honest dead end: the preference points at a row that is gone and + // nothing is SSH-enabled. The sentence names the ENVIRONMENT (gold, one + // line), and the disabled button means Connect can never be a silent + // no-op: a typed destination alone must not POST. + const { calls, restore } = await renderPanel({ + nodes: [], + ledger: { saved: [], recent: [], defaultNodeId: "vanished-node" }, + }); + try { + expect(machineInput().value).toBe(""); + const caption = screen.getByText("No SSH-enabled machine is available. Enable SSH on a machine first."); + expect(caption.className).toContain("text-amber-600"); // gold: nothing chosen, not a refused value + await commitTyped("box.example"); + const button = screen.getByRole("button", { name: /^Connect$/ }) as HTMLButtonElement; + expect(button.disabled).toBe(true); // the machine is in the disabled condition + await clickConnect(); + expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]); + expect(screen.getByText("No SSH-enabled machine is available. Enable SSH on a machine first.")).toBeDefined(); } finally { restore(); } }); - it("several enabled machines and no preference is a required question: gold *, gold caption on the submit, no POST", async () => { + it("several enabled machines and no preference is a required question: gold *, gold caption, no POST", async () => { const { calls, restore } = await renderPanel({ nodes: [HOST_A, HOST_C] }); try { expect(machineInput().value).toBe(""); // never silently chosen @@ -475,4 +537,17 @@ describe("refusals and disclosure (contracts 4, 5)", () => { restore(); } }); + + it("wires the machine requirement caption as the picker's description", async () => { + // The same aria posture the destination field pins above: whatever the + // machine picker is explaining about itself is its accessible + // description, named by id only while it is on screen. + const { restore } = await renderPanel({ nodes: [HOST_A, HOST_C] }); + try { + await waitFor(() => expect(document.getElementById("connect-machine-requirement")).not.toBeNull()); + expect(machineInput().getAttribute("aria-describedby")).toContain("connect-machine-requirement"); + } finally { + restore(); + } + }); }); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts index 45e8b4997..b622c0f09 100644 --- a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts +++ b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts @@ -44,7 +44,7 @@ describe("sshLaunchRefusal", () => { it("400 names the destination as invalid, not the machine", () => { expect(sshLaunchRefusal(new ApiError(400, "x", { code: "ALIAS_UNSAFE" }), machine({}))).toEqual({ - text: "That destination is not a valid host name.", + text: "That destination is not a valid name to connect to.", field: "destination", }); }); @@ -97,9 +97,24 @@ describe("sshLaunchRefusal", () => { ); }); - it("502 is the machine's own refusal, in one sentence", () => { + it("409 NODE_IN_MAINTENANCE names maintenance, not the missing connection", () => { + // The create under the ssh launch throws this code when the machine is in + // a window (`rethrowLaunchRefusal`, subshells.service.ts) - a live link + // exists, it just takes no new panes, so the offline sentence would lie. + const copy = sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_IN_MAINTENANCE" }), machine({})); + expect(copy?.text).toBe( + "mac mini is in maintenance and takes no new panes. Wait for it to come out of maintenance.", + ); + expect(copy?.text).not.toContain("no live connection"); + // The vanish-the-row fallback stays grammatical too. + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_IN_MAINTENANCE" }), null)?.text).toBe( + "That machine is in maintenance and takes no new panes. Wait for it to come out of maintenance.", + ); + }); + + it("502 is the machine's own refusal, with the checks that answer it", () => { expect(sshLaunchRefusal(new ApiError(502, "x", { code: "SSH_NODE_REFUSED" }), machine({}))).toEqual({ - text: "The connecting machine did not answer.", + text: "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", field: "machine", }); }); @@ -116,8 +131,23 @@ describe("sshLaunchRefusal", () => { }); it("every shipped sentence is at most two sentences and dash-free", () => { - const texts = [422, 400, 403, 404, 409, 502].flatMap((status) => { - const copy = sshLaunchRefusal(new ApiError(status, "x", { code: "ANY" }), machine({ sshEnabled: false })); + // Named codes too, so the per-branch arms are all in the sweep, not just + // the default ones. + const cases: [number, string][] = [ + [422, "ANY"], + [400, "ALIAS_UNSAFE"], + [403, "SSH_GATE_OFF"], + [404, "NOT_FOUND_ERROR"], + [409, "NODE_IN_MAINTENANCE"], + [409, "NODE_PROTOCOL_HELD"], + [409, "NODE_OUTDATED"], + [409, "NODE_UNREACHABLE"], + [409, "NODE_OFFLINE"], + [409, "ANY"], + [502, "SSH_NODE_REFUSED"], + ]; + const texts = cases.flatMap(([status, code]) => { + const copy = sshLaunchRefusal(new ApiError(status, "x", { code }), machine({ sshEnabled: false })); return copy === null ? [] : [copy.text]; }); for (const text of texts) { diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index 3248d0df8..dfff77df8 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -22,11 +22,13 @@ import { launchableNodes } from "@/lib/subshell-compat"; * display, never a pre-resolve step. Three nouns only (the §11 vocabulary): * destination, connecting machine. No key-source affordance (that is M2). * - * A machine whose SSH is off stays VISIBLE and disabled with its reason - - * greying explains, hiding does not (decision 3). With no default machine and - * several SSH-enabled ones the disclosure is required with the gold `*` and - * never silently chosen; with a preference or exactly one usable machine it - * pre-selects honestly and stays on screen. The disclosure sentence (the + * A machine whose SSH is off stays VISIBLE and disabled with its reason and + * remedy - greying explains, hiding does not (decision 3). Once both feeds + * have answered, an empty picker IS the open question: the gold `*` and the + * gold sentence stand under it and Connect stays disabled, never a silent + * no-op (a stored default or the only usable machine pre-selects honestly and + * clears them; when no machine is enabled the sentence names that). The + * disclosure sentence (the * `Match exec` note, decision 6) ships verbatim under the destination field, * before the button, because approving a destination approves running its * resolution. @@ -71,7 +73,7 @@ export function ConnectPanel(): JSX.Element { const navigate = useNavigate(); const { data: nodeData } = useNodes(); const nodes = Array.isArray(nodeData?.nodes) ? nodeData.nodes : null; - const { data: ledger } = useSshSavedHosts(); + const { data: ledger, isPending: ledgerPending } = useSshSavedHosts(); const launch = useLaunchSsh(); const save = useSaveSshHost(); const setDefault = useSetDefaultNode(); @@ -79,25 +81,32 @@ export function ConnectPanel(): JSX.Element { const [nodeId, setNodeId] = useState(""); const [dest, setDest] = useState({ pick: null, pickId: null, typed: "" }); const [remember, setRemember] = useState(false); - // The gold caption waits for a submit that found the machine untouched: - // "nothing typed yet", never before the first attempt (ruling 2026-09-30). - const [submitAttempted, setSubmitAttempted] = useState(false); const [refusal, setRefusal] = useState(null); const targets = launchableNodes(nodes ?? []); const selectedNode: Node | null = (nodes ?? []).find((n) => n.id === nodeId) ?? null; const defaultNodeId = ledger?.defaultNodeId ?? null; const enabledCount = targets.filter((n) => n.sshEnabled).length; - // No preference, several doors: the choice is the human's (spec §7). - const machineRequired = nodeId === "" && defaultNodeId === null && enabledCount > 1; + // "Settled" means both feeds have answered, so the pre-fill effect has had + // its say: a stored default or the only usable machine has landed, or there + // was nothing to land. From then on an empty machine IS the open question - + // several doors with no preference, or no door at all (spec §7, decision 2). + const machineRequired = nodes !== null && !ledgerPending && nodeId === ""; // The machine disclosure keeps gated-off rows visible (decision 3): grey, - // with the reason naming what is in the way. + // with the reason carrying the remedy, not just the cause - the sentence + // pair mirrors the server's own gate (ssh-launch.service.ts's `gateCause`): + // the control-plane host's switch is an admin's, every other machine's is + // its owner's. const machineOptions: ComboboxOption[] = targets.map((n) => ({ value: n.id, label: nodeOptionLabel(n), disabled: !n.sshEnabled, - reason: n.sshEnabled ? undefined : "SSH is off on this machine", + reason: n.sshEnabled + ? undefined + : n.kind === "local" + ? "SSH is off here. An admin can switch it on in Server Settings." + : "SSH is off on this machine. Its owner can switch it on from this machine's settings.", })); // Aliases are the MACHINE's config, asked only while a gate-ON machine is @@ -151,7 +160,8 @@ export function ConnectPanel(): JSX.Element { } async function submit(): Promise { - setSubmitAttempted(true); + // Belt over gate: the button's own disabled condition already holds both + // fields, so this can only fire on a render race, never as a silent no-op. if (nodeId === "" || destination === "") return; setRefusal(null); try { @@ -171,10 +181,22 @@ export function ConnectPanel(): JSX.Element { } } - const machineGap = - submitAttempted && machineRequired - ? fieldErrorToned([{ message: "Choose a connecting machine first.", gap: true }]) - : null; + // An open machine question speaks the moment it stands: Connect is disabled + // while the picker is empty, and a dim button with no sentence is the + // silent no-op this round refused. Gold, never red - "nothing chosen yet" + // (ruling 2026-09-30) - and when no enabled machine exists the sentence + // names the environment, not the person. + const machineGap = machineRequired + ? fieldErrorToned([ + { + message: + enabledCount > 0 + ? "Choose a connecting machine first." + : "No SSH-enabled machine is available. Enable SSH on a machine first.", + gap: true, + }, + ]) + : null; // aria-describedby may name nothing that is not rendered, so the // association is exactly the explanation lines currently on screen. const machineRefusal = refusal?.field === "machine" ? refusal.text : null; @@ -253,7 +275,7 @@ export function ConnectPanel(): JSX.Element {
    - diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts index 8785bae63..5816c50c4 100644 --- a/apps/server/web/src/components/connect/ssh-refusal.ts +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -56,7 +56,7 @@ export function sshLaunchRefusal(err: unknown, machine: SshMachineFacts | null): switch (err.status) { case 400: // ALIAS_UNSAFE: the token was refused before the machine was asked. - return { text: "That destination is not a valid host name.", field: "destination" }; + return { text: "That destination is not a valid name to connect to.", field: "destination" }; case 403: { if (err.code === "SSH_GATE_OFF" && machine !== null && !machine.sshEnabled) { // The gate's off-door. Naming WHO flips it follows the row's kind: @@ -94,6 +94,15 @@ export function sshLaunchRefusal(err: unknown, machine: SshMachineFacts | null): text: `${name} did not answer the SSH request in time. Check its connection and retry.`, field: "machine", }; + case "NODE_IN_MAINTENANCE": + // The create under the launch refuses in a window (`rethrow- + // LaunchRefusal`, subshells.service.ts): the link is LIVE, it + // just takes no new panes - saying "no live connection" here + // would name the wrong fact and the wrong wait. + return { + text: `${name} is in maintenance and takes no new panes. Wait for it to come out of maintenance.`, + field: "machine", + }; default: // NODE_OFFLINE and any other 409: no live link to carry the ask. return { @@ -112,8 +121,13 @@ export function sshLaunchRefusal(err: unknown, machine: SshMachineFacts | null): } case 502: // The machine answered its own refusal (no ssh binary, the mirror - // says no); its verbatim text never rides the wire to here. - return { text: "The connecting machine did not answer.", field: "machine" }; + // says no); its verbatim text never rides the wire to here. This is + // the server's own sentence (`rpcRefusal`): the two checks that + // answer the machine's no. + return { + text: "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + field: "machine", + }; default: break; } From 02c644a65cbcb5b899e510a2b054220415c5e83a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:24:27 -0700 Subject: [PATCH 064/170] test(e2e): the gate-off pin follows the kind-aware remedy copy --- e2e/tests/23-connect-flow.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts index 23cd9fb8a..c128ffc34 100644 --- a/e2e/tests/23-connect-flow.spec.ts +++ b/e2e/tests/23-connect-flow.spec.ts @@ -59,7 +59,7 @@ test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join( const SSH_DISCLOSURE_COPY = "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves."; const SSH_VIEW_ONLY_COPY = "SSH sessions take input from their owner only."; -const MACHINE_OFF_REASON = "SSH is off on this machine"; +const MACHINE_OFF_REASON = "SSH is off here. An admin can switch it on in Server Settings."; /** The seeded control-plane host's display name (seed-local.ts): the picker reads names, not ids. */ const LOCAL_NODE_NAME = "Server"; /** `configGroupLabel(selectedNode.name)` for that row. */ From db86b7f986494e9372bceae54f714eebe24fc9f5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:31:21 -0700 Subject: [PATCH 065/170] fix(web): reviewer round 2 - the local gate remedy points at the machine's settings (matching the server's gateCause); the at-rest-gold picker carve-out is documented --- .claude/rules/design-system.md | 4 +++- .../components/connect/__tests__/connect-panel.test.tsx | 2 +- apps/server/web/src/components/connect/connect-panel.tsx | 2 +- docs/design-system.md | 7 ++++++- e2e/tests/23-connect-flow.spec.ts | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.claude/rules/design-system.md b/.claude/rules/design-system.md index eb7271a3e..5e0b5bb4d 100644 --- a/.claude/rules/design-system.md +++ b/.claude/rules/design-system.md @@ -46,7 +46,9 @@ fields. Name-length limits and historical specs aren't exceptions. Follow the [content guide](../../apps/docs/STYLE.md#product-copy-and-confirmations) for action labels, consequences, and examples. **A required input takes a gold `*` at rest and a gold caption once the caret -leaves it empty; a hard error (a refused value, a server refusal) stays +leaves it empty; a required control with no caret-leave moment (a picker whose +nothing-picked state disables the submit) wears the gold caption at rest once +its options settle; a hard error (a refused value, a server refusal) stays `text-destructive` red** (ruling 2026-09-30: gold says "nothing typed yet", red says "what you typed is wrong"; `fieldErrorToned` reads the split). **There is no 12px**: `detail` (13) is the floor, and `text-xs` / diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx index d3b590542..2533c07b9 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -209,7 +209,7 @@ describe("machine disclosure (contract 1)", () => { await settle(); const row = await screen.findByRole("option", { name: /this host/ }); expect(row.getAttribute("data-disabled")).toBe(""); - expect(row.textContent).toContain("SSH is off here. An admin can switch it on in Server Settings."); + expect(row.textContent).toContain("SSH is off here. An admin can switch it on from this machine's settings."); } finally { restore(); } diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index dfff77df8..b8be30ef5 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -105,7 +105,7 @@ export function ConnectPanel(): JSX.Element { reason: n.sshEnabled ? undefined : n.kind === "local" - ? "SSH is off here. An admin can switch it on in Server Settings." + ? "SSH is off here. An admin can switch it on from this machine's settings." : "SSH is off on this machine. Its owner can switch it on from this machine's settings.", })); diff --git a/docs/design-system.md b/docs/design-system.md index e6b006858..1a4f7df5f 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -116,7 +116,12 @@ admitted here. - **Required input**: a gold `*` (`RequiredMark`) stands beside every REQUIRED label at rest, and the submit is disabled until the one schema is satisfied. When the caret LEAVES a field still empty, the sentence appears - under it in the same gold. Gold means "nothing typed yet". + under it in the same gold. Gold means "nothing typed yet". A REQUIRED + control with no caret-leave moment - a picker whose nothing-picked-yet state + itself disables the submit - wears the gold sentence at rest instead, once + its options have settled, since its emptiness is already the "nothing chosen + yet" fact and waiting for a blur that never comes would be the same silent + disabled button the ruling kills. *(The disabled button was honest but silent: an untouched dialog said nothing about what it demanded, and a live-tester dismissed a half-filled preset deciding the form "didn't want the name". Operator ruling, diff --git a/e2e/tests/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts index c128ffc34..cd7ade38e 100644 --- a/e2e/tests/23-connect-flow.spec.ts +++ b/e2e/tests/23-connect-flow.spec.ts @@ -59,7 +59,7 @@ test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join( const SSH_DISCLOSURE_COPY = "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves."; const SSH_VIEW_ONLY_COPY = "SSH sessions take input from their owner only."; -const MACHINE_OFF_REASON = "SSH is off here. An admin can switch it on in Server Settings."; +const MACHINE_OFF_REASON = "SSH is off here. An admin can switch it on from this machine's settings."; /** The seeded control-plane host's display name (seed-local.ts): the picker reads names, not ids. */ const LOCAL_NODE_NAME = "Server"; /** `configGroupLabel(selectedNode.name)` for that row. */ From 74a7c4a92b5c5cee426a7bc65ef6cfed81ff09cf Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 00:58:28 -0700 Subject: [PATCH 066/170] test(web): raise the per-test budget knob 30s to 45s CI's Test: web job timed out NetworkPluginCard's fail-then-succeed chain at 30.098s on this larger suite. The file is pre-existing (#318, last touched before this stack); its own comments carry the whole measurement trail, and the CI regime is the documented starved slice (serial, 0.4 CPU, width 1 once stretched a 2.5s react-act chain past 25s). The chain runs 3.7s idle, so this is the package's one sanctioned knob (par-test.sh, server testing-notes rule), not an assertion change: every per-wait budget stays honest, and the knob moves 20 -> 30 -> 45 exactly where it has before. Focused: network-plugin-card 86 pass serially; full web suite green. --- apps/server/web/package.json | 2 +- .../__tests__/network-plugin-card.test.tsx | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 72118dc8c..0355d4e9c 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 30000 --pass-with-no-tests", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 45000 --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index 10eca96c9..bec05a4a5 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -1355,10 +1355,12 @@ describe("NetworkPluginCard: the rules that are not about one state", () => { // 7.6s wall for this act/NDJSON chain at 0.4 CPU (it passes in 25ms // idle). bun's default 5s per-test timeout was firing mid-chain. The // per-wait budgets stay honest; the load allowance is NOT here anymore - - // the package's script carries `--timeout 30000` (issue #261 round 2, - // after CI's serial run measured this file's sibling chain at 26.2 s - // against a 20 s per-test ceiling), and one knob per package is the rule - // the server package's testing-notes record for exactly this whack-a-mole. + // the package's script carries `--timeout 45000` (issue #261 round 2 set it + // to 30000 after CI's serial run measured this file's sibling chain at + // 26.2 s against a 20 s ceiling; the 2026-10-08 serial run timed out THIS + // test at 30.098 s on a larger suite, so the knob moved one notch), and one + // knob per package is the rule the server package's testing-notes record + // for exactly this whack-a-mole. it("a failed act says so", async () => { failedPublishThenLeave(); await renderCard(JOINED); @@ -1399,10 +1401,11 @@ describe("NetworkPluginCard: the rules that are not about one state", () => { const dialog = await screen.findByRole("dialog", {}, { timeout: 1200 }); fireEvent.click(within(dialog).getByRole("button", { name: "Disconnect" })); await waitFor(() => expect(screen.queryByText(/the daemon went away/)).toBeNull(), { timeout: 1200 }); - // No per-test ceiling: the package's 30 s script budget governs, per the + // No per-test ceiling: the package's 45 s script budget governs, per the // rationale on the test above. This one runs the full fail-then-succeed // chain - measured 12-16 s on CI's slice when the 20 s ceiling was set, - // 26.2 s on 2026-09-30's, which is what moved it onto the package knob. + // 26.2 s on 2026-09-30's, and it crossed 30 s (timed out at 30.098 s) on + // 2026-10-08's larger suite, the two events that moved the knob 30 to 45. }); it("uses the vendor's own words for the credential and for publishing", async () => { From feb17daaec429cd7bb38e9fdec3908440747d67d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:08:34 -0700 Subject: [PATCH 067/170] test(web): reset the global fetch to the delegator after every test CI's Test: web job failed on backup-settings with unmocked fetches to happy-dom's 127.0.0.1:80 - a victim file, not the cause. The per-file pattern swaps globalThis.fetch and restores it inline with no afterEach; a mid-test throw skips the restore, the next file that imports a fetch-time binder (better-auth binds at module eval) captures the leftover mock, and the poisoned global walks forward through the serial worker until something reaches the real network. The file already re-parks window.location after every test for the identical cross-file reason; this completes that invariant for fetch so every file's import-time binders see the delegator regardless of ordering. Root cause, not an ordering chase: the full serial suite (2521) is green. Focused: web suite serial 2521 pass; biome + verify-types + prose clean. --- apps/server/web/src/test-setup.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/server/web/src/test-setup.ts b/apps/server/web/src/test-setup.ts index 327c53bd4..83e64cbfa 100644 --- a/apps/server/web/src/test-setup.ts +++ b/apps/server/web/src/test-setup.ts @@ -56,14 +56,40 @@ const underlyingFetch = globalThis.fetch.bind(globalThis) as ( input: RequestInfo | URL, init?: RequestInit, ) => Promise; -globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => +const fetchDelegator = ((input: RequestInfo | URL, init?: RequestInit) => (fetchRouter ?? underlyingFetch)(input, init)) as typeof globalThis.fetch; +globalThis.fetch = fetchDelegator; /** Routes (or, with null, un-routes) the global fetch used by import-time binders. */ export function setFetchRouter(router: FetchRouter): void { fetchRouter = router; } +/** + * Park the global fetch back on the delegator after EVERY test, exactly as the + * `window.location` reset above does for the URL. + * + * The established per-file pattern swaps `globalThis.fetch` at a test's top and + * restores it inline at the bottom, with no `afterEach` (several suites do this, + * including the SSH Connect panel's). A mid-test throw skips that inline + * restore, so a mock is left on the global; the next file to import a fetch-time + * binder (better-auth captures whatever `globalThis.fetch` is at module + * evaluation, the reason the delegator lives here in the first place) then binds + * the leftover mock instead of the delegator, and it stays bound for that + * worker. The poisoned global walks forward through the CI serial run until some + * file's binder reaches the real network and dies as `ECONNREFUSED 127.0.0.1:80` + * (happy-dom's default origin) — a victim file and one or more workers away from + * whichever test leaked. The URL has carried this exact reset since 2026-09-17 + * for the same cross-file reason; the delegator is installed for that reason + * too, so the reset belongs here, at the boundary, rather than chasing the one + * file whose ordering happened to trip it. A `beforeEach` that sets the global + * still wins (it runs after this, before the next test), and a file that swaps + * inside its own body is untouched until the boundary — which is the point. + */ +afterEach(() => { + globalThis.fetch = fetchDelegator; +}); + const globals = globalThis as Record; // `__BUILD_ID__` is a Vite `define`: the real build inlines it at compile // time, so under bun test the global simply is not there. It is typed as a From fe0197be9beb79ad1b9f43d9cb9e4a2fa5156170 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 01:24:58 -0700 Subject: [PATCH 068/170] test(web): reviewer - attribute the budget history and the fetch leak correctly Two comment-accuracy fixes from review. The 30.098s timeout belongs to the fail-then-succeed test below the block, not the one the block annotates, and only the 2026-10-08 event moved 30 to 45 (26.2s moved 20 to 30). In test-setup the poisoned-global narrative said 'happy-dom's default origin' and 'one or more workers'; the suite registers its window at WINDOW_URL (localhost -> :80, where a relative fetch lands) and CI's serial regime is one process, so the victim is several files down the same worker. Comments only; no behavior change. Focused: the affected files pass serially. --- .../__tests__/network-plugin-card.test.tsx | 12 ++++++------ apps/server/web/src/test-setup.ts | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index bec05a4a5..7b0d68bfc 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -1357,10 +1357,10 @@ describe("NetworkPluginCard: the rules that are not about one state", () => { // per-wait budgets stay honest; the load allowance is NOT here anymore - // the package's script carries `--timeout 45000` (issue #261 round 2 set it // to 30000 after CI's serial run measured this file's sibling chain at - // 26.2 s against a 20 s ceiling; the 2026-10-08 serial run timed out THIS - // test at 30.098 s on a larger suite, so the knob moved one notch), and one - // knob per package is the rule the server package's testing-notes record - // for exactly this whack-a-mole. + // 26.2 s against a 20 s ceiling; the 2026-10-08 serial run timed out the + // fail-then-succeed test BELOW at 30.098 s on a larger suite, moving the + // knob one more notch), and one knob per package is the rule the server + // package's testing-notes record for exactly this whack-a-mole. it("a failed act says so", async () => { failedPublishThenLeave(); await renderCard(JOINED); @@ -1404,8 +1404,8 @@ describe("NetworkPluginCard: the rules that are not about one state", () => { // No per-test ceiling: the package's 45 s script budget governs, per the // rationale on the test above. This one runs the full fail-then-succeed // chain - measured 12-16 s on CI's slice when the 20 s ceiling was set, - // 26.2 s on 2026-09-30's, and it crossed 30 s (timed out at 30.098 s) on - // 2026-10-08's larger suite, the two events that moved the knob 30 to 45. + // 26.2 s on 2026-09-30's (which moved the knob 20 to 30), and it crossed + // 30 s (timed out at 30.098 s) on 2026-10-08's larger suite (30 to 45). }); it("uses the vendor's own words for the credential and for publishing", async () => { diff --git a/apps/server/web/src/test-setup.ts b/apps/server/web/src/test-setup.ts index 83e64cbfa..6898e8a54 100644 --- a/apps/server/web/src/test-setup.ts +++ b/apps/server/web/src/test-setup.ts @@ -72,14 +72,15 @@ export function setFetchRouter(router: FetchRouter): void { * The established per-file pattern swaps `globalThis.fetch` at a test's top and * restores it inline at the bottom, with no `afterEach` (several suites do this, * including the SSH Connect panel's). A mid-test throw skips that inline - * restore, so a mock is left on the global; the next file to import a fetch-time - * binder (better-auth captures whatever `globalThis.fetch` is at module - * evaluation, the reason the delegator lives here in the first place) then binds - * the leftover mock instead of the delegator, and it stays bound for that - * worker. The poisoned global walks forward through the CI serial run until some - * file's binder reaches the real network and dies as `ECONNREFUSED 127.0.0.1:80` - * (happy-dom's default origin) — a victim file and one or more workers away from - * whichever test leaked. The URL has carried this exact reset since 2026-09-17 + * restore, so a mock is left on the global; a file evaluated after it that + * imports a fetch-time binder (better-auth captures whatever `globalThis.fetch` + * is at module evaluation, the reason the delegator lives here in the first + * place) then binds the leftover mock instead of the delegator. The poisoned + * global walks forward through the CI serial run until some later file's binder + * reaches the real network and dies as `ECONNREFUSED 127.0.0.1:80` — the + * `WINDOW_URL` this file registers the happy-dom window at, which is where a + * relative fetch lands — a victim file several positions down the same worker + * from whichever test leaked. The URL has carried this exact reset since 2026-09-17 * for the same cross-file reason; the delegator is installed for that reason * too, so the reset belongs here, at the boundary, rather than chasing the one * file whose ordering happened to trip it. A `beforeEach` that sets the global From 389dff323864b50b6fa7cd6960ab9cf3a0bec815 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 11 Oct 2026 01:37:38 -0700 Subject: [PATCH 069/170] test(ssh): align fixtures with current main --- .../__tests__/subshell-view-ssh.test.ts | 18 +++++------------- .../connect/__tests__/connect-panel.test.tsx | 1 + .../__tests__/saved-hosts-section.test.tsx | 1 + .../nodes/__tests__/node-section-nav.test.tsx | 1 + 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts index 64bede2ab..edbd37d11 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts @@ -122,20 +122,12 @@ describe("ssh panes in subshell views: the non-owner access downgrade", () => { expect(got.ssh).toBe(false); }); - it("the admin's effective edit on a foreign ssh pane reads as view too (the boost is not ownership)", async () => { + it("admins need a grant to see foreign SSH and ordinary panes", async () => { const cookie = await signIn(adminEmail, pw); - try { - const sshRow = await get("/s_ssh", cookie); - expect(sshRow.access).toBe("view"); - expect(sshRow.ssh).toBe(true); - // Same admin, ordinary pane: still the resolved `edit` (pinning that the - // downgrade is keyed to the ssh column, not to the viewer's role). - const plain = await get("/s_plain", cookie); - expect(plain.access).toBe("edit"); - expect(plain.ssh).toBe(false); - } finally { - await deleteUserByEmailOrId(adminEmail); - } + expect((await req("GET", "/s_ssh", cookie)).status).toBe(404); + expect((await req("GET", "/s_plain", cookie)).status).toBe(404); + const rows = (await (await req("GET", "", cookie)).json()) as { id: string }[]; + expect(rows.some((row) => row.id === "s_ssh" || row.id === "s_plain")).toBe(false); }); it("the LIST path downgrades the same way (one helper, both doors; the MCP list rides this route)", async () => { diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx index 2533c07b9..659c0ffc7 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -40,6 +40,7 @@ function node(overrides: Partial): Node { protocolVersion: null, access: "owner", canManage: true, + canRetire: true, canLaunch: true, allowedDirs: [], capabilities: [], diff --git a/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx index 9cbc84dd5..1def92046 100644 --- a/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx @@ -43,6 +43,7 @@ function node(overrides: Partial): Node { protocolVersion: null, access: "owner", canManage: true, + canRetire: true, canLaunch: true, allowedDirs: [], capabilities: [], diff --git a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx index 881b24fe9..b6101c03d 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx @@ -34,6 +34,7 @@ function navNode(overrides: Partial = {}): NodeDetail { access: "owner", canManage: true, canRetire: true, + sshEnabled: false, canLaunch: true, allowedDirs: [], capabilities: [], From bfef7c8dc42106e05bf565c385383ccd49078421 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:02:38 -0700 Subject: [PATCH 070/170] docs(ssh-relay): Milestone 2 sealed agent relay design spec Resolves the five open items from the ssh-anywhere design section 16: machine identity bootstrapped at enroll, destination upgrade reuses ordinary enrollment, first-use approval creates a grant, agent-only key home, node-link relay frames as the sealed shuttle. --- .../2026-10-08-ssh-agent-relay-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md new file mode 100644 index 000000000..1069582a3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -0,0 +1,181 @@ +# SSH sealed agent relay (Milestone 2) design + +**Status:** approved design, pre-plan. +**Date:** 2026-10-08. +**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This spec is Milestone 2 of that program and does not restate mechanics the parent already fixes. Where they agree on a detail (the §8 relay protocol, §9 host-key doctrine, §12 failure convention, §13 security rules, §14 M2 test list), this file is the elaboration and the parent is the summary; where this file resolves a parent §16 open item, this file governs. +**Milestone 1** (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is the base. This work builds on the seam its launcher exposes and is its own spec and its own security review. + +## 1. Goal and scope + +Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** which holds no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It also delivers the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the one act that turns a successful relay destination into a first-class node ("Set up Subshell here"). + +Milestone 2 is done when the parent's §17 sentence for M2 holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. + +**In scope:** the per-machine signing identity and its enrollment/bootstrap; the node-side machine pin store; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; grants/trust UI; audits; the tests in §14. + +**Out of scope (unchanged from the parent §2):** a general SSH-protocol proxy, X11/agent forwarding toward D, port forwarding, key custody or copying of any kind, multi-operator separation of duties, and any change to the OS-user trust boundary. A `ProxyCommand`-based hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. + +## 2. Terminology (adds to the parent §3) + +- **A / key home:** the machine whose ssh-agent signs. Must have `ssh_enabled` on (§4.3 parent) and be online for the handshake window. +- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, and holds no key. `ssh_enabled` on. +- **D / destination:** any sshd. Not gated; dialed to. +- **Signing identity:** the per-machine ES256 keypair introduced here. Distinct from the encryption identity (ECDH-ES) that already lives in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command keypair in `control-keys.ts`. +- **Machine pin:** a byte-equality pin of a peer's signing public key, held in the node-side machine pin store (new), never in the channels' `peers.json`. +- **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). + +## 3. The five open items, decided + +These close the parent §16 list. Each is elaborated in its section. + +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and registered at **enroll**, so relay trust anchors on the enroll event rather than a fresh plane-mediated first-use. A machine trust card carries the fingerprint confirmation. +2. **Destination upgrade** (§7): reuse ordinary enrollment over the open session. No #330 runtime-session re-port. +3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under that grant are silent. The approval flag ships as the act of grant creation, not a separate toggle. +4. **A's identity source** (§5.4): agent-only. A's identity files on disk are never read or served; a key becomes usable remotely only once loaded in A's agent. +5. **Relay-only destination auto-enroll** (§7): collapses into the destination upgrade. Enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. + +## 4. Machine identity and pin store + +### 4.1 A per-machine signing keypair + +`identity.ts` today generates and holds only the ECDH-ES encryption keypair; the ES256 keypair that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and to the same posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, regenerated only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6; it is not a command-signing key and never authorizes a plane command on its own. + +### 4.2 Register at enroll + +The enroll route already carries the node's ECDH-ES public key into the identities store under the `node:` principal. It now also carries the signing public key, over the same setup-key-authenticated request. The identities store (or its `node:` record) gains a signing-key slot beside the encryption slot. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the moment an operator deliberately attached the machine. + +### 4.3 Bootstrap for pre-M2 nodes + +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command (`ssh_register_identity`) delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane stores it in the same slot. This is exactly the transport the parent §5.3 requires ("the peer's JWK must be delivered to it inside a signed command on the existing node link"), reused for the node's own key at first contact. Delivery is idempotent: a node that has already reported re-reports the same bytes and the store is unchanged; a node that reports a different key than one on file hits the §4.5 changed-key block rather than silently overwriting. + +### 4.4 The node-side machine pin store + +A relay is always pin-strict. A holds the peer signing keys it will verify against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, with its own reader that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B to verify A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s TOFU rule for the *comparison* only, never its escape hatch. A changed peer signing key is a hard block that names the peer machine and points at the recovery act of §4.5. + +### 4.5 Pin display and recovery + +The security rule the parent flags ("there is no operator-visible machine pin display or recovery flow") is closed by a **machine trust card** (§8.3): for every machine this one has pinned, and for this machine's own registered signing key, the UI shows a short fingerprint. Recovery from a genuinely rotated peer key (or a re-enrolled machine) is an explicit "re-pair" act that replaces the stored pin after a fresh enroll-time-anchored delivery; it is owner-only and audited. There is no "trust anyway" escape and no reuse of the channel escape. + +### 4.6 What this narrows, honestly + +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Anchoring the signing key to enroll moves that boundary. The key an operator sees on the trust card is bound to the enroll event that only the setup key could create, so a plane compromised **after** any machine enrolled cannot swap it without tripping the byte-equality block. The residual window is a plane compromised **before B enrolled**, which is `docs/security.md` §0's accepted, not-defended control-plane-compromise boundary, not a new hole. The trust-card fingerprint is the optional out-of-band confirmation that lets the operator close even that by comparing the two machines' fingerprints directly; it is offered, not mandatory, so first use stays one approval (§6) rather than a copy-the-fingerprint ceremony. + +## 5. Relay runtime + +The protocol in the parent §8 is unchanged and governs; this section pins the pieces and the one new transport decision. + +### 5.1 Transport: node-link relay frames + +The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is hard-wired in the parent §5.3: the node daemon holds no REST credential, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency transport, and it is already app-layer encrypted (protocol 14) and authenticated by the node key. It also fits the traffic shape: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over a few seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. + +Concretely: the plane sends A and B each one signed command to **open** a relay session (carrying the routing ref, the peer's signing public key to pin, and the lifetime). After that, a new lightweight frame type on the open link carries sealed blobs both ways, keyed by the routing ref. These frames add no per-message command signing: the link already authenticates the machine, and origin comes from the inner ES256 signature (§5.6), so signing every frame would be redundant overhead on a stream. The plane matches the two links by routing ref and forwards the opaque blob without opening it. **Open**, **route**, and **close** are the plane's only roles in the payload's life. + +Because this adds a link frame type, it rides the `NODE_PROTOCOL_VERSION` bump already required for `ssh_enabled` (parent §4.3). The relay frames and the gate flip in one version. + +### 5.2 B: the agent proxy socket + +On B the launcher exports a scoped `SSH_AUTH_SOCK` (the parent §5.4 exception, into the `ssh` invocation only) pointing at a Unix socket B's node creates in a per-session directory, mode 0600, alongside the rendered connection config. The socket speaks the ssh-agent wire locally. Each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back to the socket. The socket is created for the handshake window and torn down when it ends; the established pane then needs no agent (parent §8, rekey re-verifies the host key, not the agent). + +### 5.3 Plane: the broker + +The plane brokers a pairing only when a live grant is used (§6). In-memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; the payload lives solely inside sealed envelopes it shuttles. Teardown fires on handshake completion, on lifetime expiry, on A dropping, and on grant revoke. + +### 5.4 A: the responder + +On A a responder accepts only requests the plane brokered for a live grant, for a session naming this A. It enforces the parent §8 method allow-list: forward `SSH_AGENTC_REQUEST_IDENTITIES` and the sign/authenticate requests OpenSSH needs; refuse `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted window cannot mutate or lock A's agent against its other panes. `REQUEST_IDENTITIES` is answered with A's agent keys **filtered to the identity refs the resolved destination snapshot names** (bounded by `SSH_MAX_IDENTITY_REFS`), never A's whole list. + +**Agent-only (§3.4):** the responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is simply absent from the filtered roster; the handshake fails as "no matching key in A's agent" (parent §12), which points at loading it and never reads the file. This keeps key bytes off both the wire and A's disk reads by Subshell. + +### 5.5 Plane blindness, restated for the record + +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §10 crown-jewel test shows opaque envelopes and plaintext routing ids only. + +### 5.6 Origin, replay, and lifetime + +Confidentiality is not authenticity: anyone holding A's public key, the plane included, could craft a seal A opens. Origin therefore rides a signature, not the seal: the sender signs the inner payload with its machine signing key; the receiver verifies against its **machine pin** of the sender (§4.4), the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. Signatures run both directions and the relay-session nonce is bound into both, so a captured envelope is inert on replay even though the plane stores envelopes. Lifetime, the method allow-list, and "no forwarding toward D" are exactly the parent §8 last three bullets and are not relaxed here. + +## 6. Grants and the first-use approval + +### 6.1 The grant record + +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy only, unrelated to discovery's wildcards). No secret, no key bytes. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§7 of the parent, `ssh_host_pins`), so one selector covering many hosts yields many pins. + +### 6.2 First use creates the grant + +The first time a launch would route through a `(A, B, destination)` combination with no standing grant, A's operator receives one approval naming the requester, the key home, and the destination, through the existing notification/confirm surface. Approving writes the grant and proceeds; refusing aborts with a loud, named refusal (parent §12 convention). Subsequent uses under a matching grant are silent. The approval is the creation gesture; there is no separate per-grant "ask each time" flag shipped in M2, because the first-use prompt already carries that role and the residual scope risk is bounded the same way the parent §8 already bounds it (agent sign requests carry no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by serving only snapshot-named identities plus instant revoke). + +### 6.3 Management and revoke + +A grants screen (owner-scoped) lists, edits the selector/name, and revokes grants. Revocation is instant and cuts both ways: the plane row goes, and a live relay session using it is torn down so signing stops mid-session. A can also kill a session from its own trust surface. Creation via the screen and via first use write the same row and the same audit event. + +## 7. Destination upgrade: "Set up Subshell here" + +A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment from D over the already-open authenticated session, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator had run the one-liner by hand. The relay/launcher is the consumer that carries the command; it is not a new product surface. + +Consequences the parent §16 asked about, now fixed: a relay-only destination becomes a first-class node **by enrolling**, because that is the only way the node row is ever created; and the "lighter boot" question is moot because there is no bespoke runtime-session protocol in play. If D cannot reach the plane (no egress on D's network back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. + +## 8. UI (adds to the parent §11) + +- The destination-first flow is unchanged. Its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay: pick A as the key home, and the grant/approval path (§6) applies. +- **Grants screen:** list/create/edit/revoke, owner-scoped, under the SSH settings area. Normal creation is the first-use prompt; the screen manages and hand-adds. +- **Machine trust card:** this machine's own signing fingerprint, and every peer signing key it has pinned, each with a short fingerprint and a re-pair act (§4.5). Surfaced on the node detail so the operator can confirm out-of-band. +- **Host-key display:** destination pins visible with the changed-key block surfaced when a connect is refused on a byte mismatch. +- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11 keeps the destination-first landing intact). + +Copy follows the design system: role tokens only, at most two sentences per explanation, no em dashes. + +## 9. Data model, config, and audits (adds to parent §7, §10) + +**Schema.** A signing public key per node (a `nodes` column or an identities-store `node:` slot). `ssh_key_grants` and `ssh_host_pins` tables (M2 rows from parent §7; both ship in M2). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and its mirror ship in M1. + +**New audit event names**, to add to `docs/security.md` §10, naming ids/hosts/field-names only, never payload, challenge, or signature: + +- `node.ssh_identity.register` (enroll-time signing key registration; and the pre-M2 link delivery, distinguished by a `via` field). +- `node.ssh_grant.create|update|delete`. +- `node.ssh_relay.open|close` (open names grant/A/B/pane and the routing ref; close names the reason). +- `node.ssh_host_pin.create|rotate|delete`. + +The relay pairing metadata the plane already holds (which machines talk, timing, sizes, routing ids) is disclosed by §10 of the parent as retained, not new. + +## 10. Trust model delta (refines parent §10) + +Gained, as the parent states, plus two specifics from these decisions: the plane never reads an agent payload (blind routing over its own link), and a plane compromised after any relevant enroll cannot forge a request A honors, because the signing key is enroll-anchored and byte-equality pinned. The §4.6 boundary is the only residual, and it equals §0's not-defended control-plane-compromise risk. Agent-only (§5.4) removes a whole class of exposure: no identity file is ever read by Subshell, no passphrase prompt lives on A, no key file transits. Revoke cuts a live session. A compromised B during its own granted window is still bounded by the allow-list and identity scoping (parent §10), unchanged. + +## 11. Failure modes (parent §12, plus M2-specific) + +Everything in parent §12 stands (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: + +- **No signing key on a node** (a pre-M2 node that has not yet delivered, or a corrupt store): name the machine and offer the register/refresh act; never proceed unsigned. +- **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **D cannot reach the plane during "Set up Subshell here":** a clean refusal naming egress, the working pane untouched. +- **Relay frame on a pre-bump agent:** the exact-match `NODE_PROTOCOL_VERSION` gate refuses pairing before any relay command, naming the version remedy (upgrade the agent), never silently half-relaying. + +## 12. Security rules honored (parent §13, restated where M2 leans on them) + +No key/challenge/signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in a per-session dir, removed with the pane. `shellQuote` on every built-command token. Node command/link transport unchanged in trust: relay open/close are signed commands, relay frames ride the authenticated, encrypted link, origin is the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, not a bespoke cipher, and never honor `SUBSHELL_CHANNEL_PIN`. The method allow-list and identity scoping bound a granted window. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. + +## 13. Testing + +The parent §14 M2 list is required and is not repeated here. Add coverage for the decisions: + +- **Units.** Enroll registers the signing pubkey alongside the encryption pubkey; `ssh_register_identity` pre-M2 delivery is idempotent and a mismatched key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, and ignores `SUBSHELL_CHANNEL_PIN`; first pin then change is a block naming the peer. Grant CRUD and selector matching (alias / concrete host / glob-as-policy); first-use prompt creates the grant and a second use is silent; revoke stops signing mid-session. Agent-only filter: an identity file present on A but not in A's agent is refused with "no matching key," and A's agent roster is filtered to the snapshot-named refs. Relay frame codec round-trips and the protocol bump gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks and names D. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; replay of a captured envelope inside the window is inert. +- **End to end (the crown jewel).** The parent §14 M2 e2e verbatim: A with the key in its agent, B with none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it to assert a grant revoke between handshake and D is refused, and "Set up Subshell here" turns D into an enrolled node over an egressing D. +- **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. + +## 14. Branching, phasing, and review + +This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and, per the parent §6.2, an expected focused security review pass; review-until-clean over the whole branch before any merge consideration. M1 remains unmerged at the operator's direction; when M1 lands, this branch rebases onto it. It is deliberately isolated behind the launcher seam M1 exposes, so the relay can be turned off without disturbing M1's direct and jump paths. + +**Likely task slices (for the implementation plan, not yet a plan):** + +1. **Protocol + node identity:** signing keypair generation, enroll registration, `ssh_register_identity` link delivery, identities-store slot, `NODE_PROTOCOL_VERSION` bump + relay frame types + validators + census, node-side machine pin store + strictness. +2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, allow-list, identity filter), B agent proxy socket + framing + seal/open, nonce/replay. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, first-use approval flow, grants screen, the new `docs/security.md` §10 event names. +4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, `UserKnownHostsFile` render, changed-key block, trust card + re-pair. +5. **Destination upgrade:** "Set up Subshell here" minting a key and driving enrollment over the open session, egress refusal, node appears. +6. **Crown-jewel e2e + boundary + review loop.** + +## 15. Non-goals and accepted risks (carry-forward, do not "fix") + +Single-operator posture unchanged. The plane retains denial-of-service and metadata (parent §10). The §4.6 pre-enroll window equals the accepted control-plane-compromise boundary. B cannot be bound to a host at the agent-protocol layer, so a grant bounds exposure by identity-scoping and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no key file read, no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. From 62e4d7a2e2f1a2c17d419792d5d142ea270d8003 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:09:08 -0700 Subject: [PATCH 071/170] docs(ssh-relay): M2 spec review round 1 - security honesty, protocol bump, grant scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the first review pass: - §4.6/§10 now state honestly that enroll-anchoring does NOT move the parent §10 pre-first-pin window; the out-of-band dashboard comparison is the optional first-secure-contact that closes it. - M2 carries its own NODE_PROTOCOL_VERSION bump 17->18 (M1 consumed 16/17). - Agent method allow-list is default-deny (two symbolic request types), not an incomplete name list; identity scoping moves to operator-selected fingerprints on the grant (snapshot paths are not cross-machine portable). - §7 destination upgrade runs non-interactive so the setup key stays off the pane/log; §5.6 nonces are endpoint-contributed; relay lifetime framed. - Cross-reference fixes: parent-§ prefixes, crown-jewel §13, audit §10, re-pair audit event, and grant alias stored resolved. --- .../2026-10-08-ssh-agent-relay-design.md | 170 ++++++++++-------- 1 file changed, 96 insertions(+), 74 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 1069582a3..7a5e5cae0 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -2,180 +2,202 @@ **Status:** approved design, pre-plan. **Date:** 2026-10-08. -**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This spec is Milestone 2 of that program and does not restate mechanics the parent already fixes. Where they agree on a detail (the §8 relay protocol, §9 host-key doctrine, §12 failure convention, §13 security rules, §14 M2 test list), this file is the elaboration and the parent is the summary; where this file resolves a parent §16 open item, this file governs. -**Milestone 1** (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is the base. This work builds on the seam its launcher exposes and is its own spec and its own security review. +**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This is Milestone 2 of that program. Cross-reference rule used throughout: **`§N` below means this document; `parent §N` means the parent.** This file elaborates where the parent summarizes, and it is decisive wherever it resolves a parent §16 open item. +**Base:** Milestone 1 (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is unmerged and is the base; this work lands on the launcher seam M1 exposes, as its own spec and its own security review. ## 1. Goal and scope -Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** which holds no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It also delivers the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the one act that turns a successful relay destination into a first-class node ("Set up Subshell here"). +Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** holding no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It ships the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the act that turns a relay destination into a first-class node ("Set up Subshell here"). -Milestone 2 is done when the parent's §17 sentence for M2 holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. +Done when the parent §17 M2 sentence holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. -**In scope:** the per-machine signing identity and its enrollment/bootstrap; the node-side machine pin store; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; grants/trust UI; audits; the tests in §14. +**In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (unchanged from the parent §2):** a general SSH-protocol proxy, X11/agent forwarding toward D, port forwarding, key custody or copying of any kind, multi-operator separation of duties, and any change to the OS-user trust boundary. A `ProxyCommand`-based hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2, unchanged):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. -## 2. Terminology (adds to the parent §3) +## 2. Terminology (adds to parent §3) -- **A / key home:** the machine whose ssh-agent signs. Must have `ssh_enabled` on (§4.3 parent) and be online for the handshake window. -- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, and holds no key. `ssh_enabled` on. +- **A / key home:** the machine whose ssh-agent signs. Needs `ssh_enabled` on (parent §4.3) and to be online for the handshake window. +- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, holding no key. `ssh_enabled` on. - **D / destination:** any sshd. Not gated; dialed to. -- **Signing identity:** the per-machine ES256 keypair introduced here. Distinct from the encryption identity (ECDH-ES) that already lives in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command keypair in `control-keys.ts`. -- **Machine pin:** a byte-equality pin of a peer's signing public key, held in the node-side machine pin store (new), never in the channels' `peers.json`. +- **Signing identity:** the per-machine ES256 keypair introduced here, distinct from the ECDH-ES encryption pair already in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command key in `control-keys.ts`. +- **Machine pin:** a byte-equality pin of a peer's signing public key, held in a new node-side machine pin store (never the channels' `peers.json`). - **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). +- **TOFU:** trust-on-first-use; a key is pinned from the first copy received, and any later differing copy is refused. +- **Relay frame:** the new `/ws/node` link frame that carries one sealed envelope (§5.1). +- **Wire-frame census:** the repo convention (a `node-frames` test) that inventories every frame type; the relay frames join it. ## 3. The five open items, decided -These close the parent §16 list. Each is elaborated in its section. +These close the parent §16 list; each is elaborated in its section. -1. **Machine-identity bootstrap** (§4): the signing keypair is minted and registered at **enroll**, so relay trust anchors on the enroll event rather than a fresh plane-mediated first-use. A machine trust card carries the fingerprint confirmation. -2. **Destination upgrade** (§7): reuse ordinary enrollment over the open session. No #330 runtime-session re-port. -3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under that grant are silent. The approval flag ships as the act of grant creation, not a separate toggle. -4. **A's identity source** (§5.4): agent-only. A's identity files on disk are never read or served; a key becomes usable remotely only once loaded in A's agent. -5. **Relay-only destination auto-enroll** (§7): collapses into the destination upgrade. Enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the trust card offers an optional out-of-band check. +2. **Destination upgrade** (§7): reuse ordinary enrollment over the machine's own connection. No #330 runtime-session re-port. +3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under it are silent. The approval is the creation act; there is no separate per-grant "ask each time" flag. +4. **A's identity source** (§5.4): agent-only. A's identity files are never read or served; a key becomes usable remotely only once loaded in A's agent. +5. **Relay-only destination auto-enroll** (§7): enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. No separate auto-enroll path. ## 4. Machine identity and pin store ### 4.1 A per-machine signing keypair -`identity.ts` today generates and holds only the ECDH-ES encryption keypair; the ES256 keypair that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and to the same posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, regenerated only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6; it is not a command-signing key and never authorizes a plane command on its own. +`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. ### 4.2 Register at enroll -The enroll route already carries the node's ECDH-ES public key into the identities store under the `node:` principal. It now also carries the signing public key, over the same setup-key-authenticated request. The identities store (or its `node:` record) gains a signing-key slot beside the encryption slot. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the moment an operator deliberately attached the machine. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command (`ssh_register_identity`) delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane stores it in the same slot. This is exactly the transport the parent §5.3 requires ("the peer's JWK must be delivered to it inside a signed command on the existing node link"), reused for the node's own key at first contact. Delivery is idempotent: a node that has already reported re-reports the same bytes and the store is unchanged; a node that reports a different key than one on file hits the §4.5 changed-key block rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.5 changed-key block rather than silently overwriting. ### 4.4 The node-side machine pin store -A relay is always pin-strict. A holds the peer signing keys it will verify against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, with its own reader that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B to verify A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s TOFU rule for the *comparison* only, never its escape hatch. A changed peer signing key is a hard block that names the peer machine and points at the recovery act of §4.5. +Relay is always pin-strict. Each side keeps the peer signing keys it verifies against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. A changed peer signing key is a hard block naming the peer and pointing at recovery (§4.5). ### 4.5 Pin display and recovery -The security rule the parent flags ("there is no operator-visible machine pin display or recovery flow") is closed by a **machine trust card** (§8.3): for every machine this one has pinned, and for this machine's own registered signing key, the UI shows a short fingerprint. Recovery from a genuinely rotated peer key (or a re-enrolled machine) is an explicit "re-pair" act that replaces the stored pin after a fresh enroll-time-anchored delivery; it is owner-only and audited. There is no "trust anyway" escape and no reuse of the channel escape. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which the parent already renders only for an online agent node; an offline node shows the last-known card marked stale. -### 4.6 What this narrows, honestly +**Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Anchoring the signing key to enroll moves that boundary. The key an operator sees on the trust card is bound to the enroll event that only the setup key could create, so a plane compromised **after** any machine enrolled cannot swap it without tripping the byte-equality block. The residual window is a plane compromised **before B enrolled**, which is `docs/security.md` §0's accepted, not-defended control-plane-compromise boundary, not a new hole. The trust-card fingerprint is the optional out-of-band confirmation that lets the operator close even that by comparing the two machines' fingerprints directly; it is offered, not mandatory, so first use stays one approval (§6) rather than a copy-the-fingerprint ceremony. +### 4.6 What this defends, honestly + +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. + +The one control that closes it is an out-of-band comparison, and this design offers one: the first-use approval (§6.2) displays the peer fingerprint A is being asked to pin, and B's own signing fingerprint is readable on B's **plane-independent local surface** (its loopback dashboard, §4.5). An operator who compares the two is doing the "first-secure-contact" the parent §10 asked for; a match rules out a plane substitution, a mismatch is a hard stop. That comparison is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0). Making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The protocol in the parent §8 is unchanged and governs; this section pins the pieces and the one new transport decision. +The parent §8 protocol governs; this section pins the pieces and the transport decision. ### 5.1 Transport: node-link relay frames -The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is hard-wired in the parent §5.3: the node daemon holds no REST credential, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency transport, and it is already app-layer encrypted (protocol 14) and authenticated by the node key. It also fits the traffic shape: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over a few seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. +The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is fixed in parent §5.3: the node daemon holds **no REST credential**, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency, bidirectional transport, already app-layer encrypted since protocol 14 and authenticated by the node key. It also fits the traffic: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. -Concretely: the plane sends A and B each one signed command to **open** a relay session (carrying the routing ref, the peer's signing public key to pin, and the lifetime). After that, a new lightweight frame type on the open link carries sealed blobs both ways, keyed by the routing ref. These frames add no per-message command signing: the link already authenticates the machine, and origin comes from the inner ES256 signature (§5.6), so signing every frame would be redundant overhead on a stream. The plane matches the two links by routing ref and forwards the opaque blob without opening it. **Open**, **route**, and **close** are the plane's only roles in the payload's life. +Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's signing public key to pin, the grant id, and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -Because this adds a link frame type, it rides the `NODE_PROTOCOL_VERSION` bump already required for `ssh_enabled` (parent §4.3). The relay frames and the gate flip in one version. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled and ssh-launch bumps the parent §4.3 required were already consumed by M1 at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. ### 5.2 B: the agent proxy socket -On B the launcher exports a scoped `SSH_AUTH_SOCK` (the parent §5.4 exception, into the `ssh` invocation only) pointing at a Unix socket B's node creates in a per-session directory, mode 0600, alongside the rendered connection config. The socket speaks the ssh-agent wire locally. Each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back to the socket. The socket is created for the handshake window and torn down when it ends; the established pane then needs no agent (parent §8, rekey re-verifies the host key, not the agent). +The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invocation only) pointing at a Unix socket B's node creates in the pane's per-session ssh directory (`/ssh//`, the M1 dir), mode 0600, beside the rendered config. The socket speaks the ssh-agent wire locally: each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back. It exists only for the handshake window (§5.6 lifetime), then is torn down; the established pane needs no agent afterward (parent §8: rekeying re-verifies the host key, not the client agent). ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In-memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; the payload lives solely inside sealed envelopes it shuttles. Teardown fires on handshake completion, on lifetime expiry, on A dropping, and on grant revoke. +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder -On A a responder accepts only requests the plane brokered for a live grant, for a session naming this A. It enforces the parent §8 method allow-list: forward `SSH_AGENTC_REQUEST_IDENTITIES` and the sign/authenticate requests OpenSSH needs; refuse `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted window cannot mutate or lock A's agent against its other panes. `REQUEST_IDENTITIES` is answered with A's agent keys **filtered to the identity refs the resolved destination snapshot names** (bounded by `SSH_MAX_IDENTITY_REFS`), never A's whole list. +A's responder accepts only requests the plane brokered for a live grant on a session naming this A. Two rules: + +**Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. + +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). -**Agent-only (§3.4):** the responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is simply absent from the filtered roster; the handshake fails as "no matching key in A's agent" (parent §12), which points at loading it and never reads the file. This keeps key bytes off both the wire and A's disk reads by Subshell. +**Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. -### 5.5 Plane blindness, restated for the record +### 5.5 Plane blindness, for the record -The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §10 crown-jewel test shows opaque envelopes and plaintext routing ids only. +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. ### 5.6 Origin, replay, and lifetime -Confidentiality is not authenticity: anyone holding A's public key, the plane included, could craft a seal A opens. Origin therefore rides a signature, not the seal: the sender signs the inner payload with its machine signing key; the receiver verifies against its **machine pin** of the sender (§4.4), the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. Signatures run both directions and the relay-session nonce is bound into both, so a captured envelope is inert on replay even though the plane stores envelopes. Lifetime, the method allow-list, and "no forwarding toward D" are exactly the parent §8 last three bullets and are not relaxed here. +Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. + +**Replay.** The nonces are contributed by the endpoints, not the plane: B's first sealed request and A's first sealed response each carry a fresh 128-bit random, and each side records the peer's value and accepts it only once per session. The ES256 signature covers a canonicalized inner message that includes the relay-session id, the routing ref, a direction tag, both nonces, and the agent bytes. Together these make a captured envelope inert on replay even though the plane **routes every envelope and can resend any of them** (it stores none durably). + +**Lifetime.** The parent §8 bullets on the method allow-list, no-forwarding-toward-D, and handshake-window lifetime are adopted unchanged. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. ## 6. Grants and the first-use approval ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy only, unrelated to discovery's wildcards). No secret, no key bytes. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§7 of the parent, `ssh_host_pins`), so one selector covering many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set. No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant -The first time a launch would route through a `(A, B, destination)` combination with no standing grant, A's operator receives one approval naming the requester, the key home, and the destination, through the existing notification/confirm surface. Approving writes the grant and proceeds; refusing aborts with a loud, named refusal (parent §12 convention). Subsequent uses under a matching grant are silent. The approval is the creation gesture; there is no separate per-grant "ask each time" flag shipped in M2, because the first-use prompt already carries that role and the residual scope risk is bounded the same way the parent §8 already bounds it (agent sign requests carry no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by serving only snapshot-named identities plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a pending grant-approval and notifies A's owner (via `notify.service.ts`), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying or letting it expire** (bounded, at setup-key scale) writes nothing. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). -### 6.3 Management and revoke +### 6.3 Matching, management, and revoke -A grants screen (owner-scoped) lists, edits the selector/name, and revokes grants. Revocation is instant and cuts both ways: the plane row goes, and a live relay session using it is torn down so signing stops mid-session. A can also kill a session from its own trust surface. Creation via the screen and via first use write the same row and the same audit event. +The match predicate is (owner + the A chosen in the launch + the grant's resolved selector matched against the resolved destination). **B is not part of the grant's scope:** a grant authorizes reaching the destination with A's selected keys from any of the owner's connecting machines, and the approval copy says so plainly (under the single-operator posture this is a disclosure note, not a privilege widening). When multiple grants match one launch, the most specific selector wins, ties break to the oldest grant, and a distinct key home is disambiguated by the A the operator picked at launch. Revocation is instant and cuts both ways: the plane row goes and any live relay session using it is torn down so signing stops mid-session. Creation via the screen and via first use write the same row and the same audit event. ## 7. Destination upgrade: "Set up Subshell here" -A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment from D over the already-open authenticated session, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator had run the one-liner by hand. The relay/launcher is the consumer that carries the command; it is not a new product surface. +A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. + +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key lands in D's own shell history under the accepted enrollment-link posture (`docs/security.md`), its life single-use and at most 24 h. (This is the enrollment credential, an accepted-argv exception; it is not SSH key material, which §12 still never logs.) -Consequences the parent §16 asked about, now fixed: a relay-only destination becomes a first-class node **by enrolling**, because that is the only way the node row is ever created; and the "lighter boot" question is moot because there is no bespoke runtime-session protocol in play. If D cannot reach the plane (no egress on D's network back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. +**Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. -## 8. UI (adds to the parent §11) +## 8. UI (adds to parent §11) -- The destination-first flow is unchanged. Its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay: pick A as the key home, and the grant/approval path (§6) applies. -- **Grants screen:** list/create/edit/revoke, owner-scoped, under the SSH settings area. Normal creation is the first-use prompt; the screen manages and hand-adds. -- **Machine trust card:** this machine's own signing fingerprint, and every peer signing key it has pinned, each with a short fingerprint and a re-pair act (§4.5). Surfaced on the node detail so the operator can confirm out-of-band. -- **Host-key display:** destination pins visible with the changed-key block surfaced when a connect is refused on a byte mismatch. -- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11 keeps the destination-first landing intact). +- The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). +- **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. +- **Machine trust card** (this is the card §4.5 cites): this machine's own signing fingerprint, every pinned peer with its fingerprint, and a re-pair act each; fed by the `ready`/runtime report and stale-marked offline. Surfaced on the node detail. +- **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. +- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). Copy follows the design system: role tokens only, at most two sentences per explanation, no em dashes. -## 9. Data model, config, and audits (adds to parent §7, §10) +## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node (a `nodes` column or an identities-store `node:` slot). `ssh_key_grants` and `ssh_host_pins` tables (M2 rows from parent §7; both ship in M2). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and its mirror ship in M1. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. -**New audit event names**, to add to `docs/security.md` §10, naming ids/hosts/field-names only, never payload, challenge, or signature: +**New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): -- `node.ssh_identity.register` (enroll-time signing key registration; and the pre-M2 link delivery, distinguished by a `via` field). -- `node.ssh_grant.create|update|delete`. -- `node.ssh_relay.open|close` (open names grant/A/B/pane and the routing ref; close names the reason). +- `node.ssh_identity.register` (enroll-time registration, and the pre-M2 link delivery, distinguished by a `via` field). +- `node.ssh_grant.create|update|delete` and `node.ssh_grant.approve|deny` (approve names the chosen key fingerprints and destination; never any key material). +- `node.ssh_machine_pin.repair` (the §4.5 re-pair, naming the peer id only). +- `node.ssh_relay.open|close` (open names grant/A/B/pane and routing ref; close names the reason). - `node.ssh_host_pin.create|rotate|delete`. +- `node.ssh_upgrade.run` (the §7 destination upgrade, naming D, the grant, and the outcome; never the setup key). -The relay pairing metadata the plane already holds (which machines talk, timing, sizes, routing ids) is disclosed by §10 of the parent as retained, not new. +The relay metadata the plane always held (which machines talk, timing, sizes, routing ids) is disclosed by parent §10 as retained, not new. ## 10. Trust model delta (refines parent §10) -Gained, as the parent states, plus two specifics from these decisions: the plane never reads an agent payload (blind routing over its own link), and a plane compromised after any relevant enroll cannot forge a request A honors, because the signing key is enroll-anchored and byte-equality pinned. The §4.6 boundary is the only residual, and it equals §0's not-defended control-plane-compromise risk. Agent-only (§5.4) removes a whole class of exposure: no identity file is ever read by Subshell, no passphrase prompt lives on A, no key file transits. Revoke cuts a live session. A compromised B during its own granted window is still bounded by the allow-list and identity scoping (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 out-of-band comparison; it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. -## 11. Failure modes (parent §12, plus M2-specific) +## 11. Failure modes (parent §12, plus M2) -Everything in parent §12 stands (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: +Parent §12 stands in full (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: -- **No signing key on a node** (a pre-M2 node that has not yet delivered, or a corrupt store): name the machine and offer the register/refresh act; never proceed unsigned. +- **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). +- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **D cannot reach the plane during "Set up Subshell here":** a clean refusal naming egress, the working pane untouched. -- **Relay frame on a pre-bump agent:** the exact-match `NODE_PROTOCOL_VERSION` gate refuses pairing before any relay command, naming the version remedy (upgrade the agent), never silently half-relaying. +- **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). +- **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. +- **Over-cap relay frame:** refused, session closed with a named reason (§5.1). +- **Relay frame on a pre-bump agent:** the 17→18 `NODE_PROTOCOL_VERSION` exact-match gate refuses pairing before any relay command, naming the agent-upgrade remedy, never half-relaying. -## 12. Security rules honored (parent §13, restated where M2 leans on them) +## 12. Security rules honored (parent §13) -No key/challenge/signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in a per-session dir, removed with the pane. `shellQuote` on every built-command token. Node command/link transport unchanged in trust: relay open/close are signed commands, relay frames ride the authenticated, encrypted link, origin is the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, not a bespoke cipher, and never honor `SUBSHELL_CHANNEL_PIN`. The method allow-list and identity scoping bound a granted window. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log (its landing in D's shell history is the accepted enrollment-link posture, and it is an enrollment credential, not SSH key material). Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing -The parent §14 M2 list is required and is not repeated here. Add coverage for the decisions: +The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers the signing pubkey alongside the encryption pubkey; `ssh_register_identity` pre-M2 delivery is idempotent and a mismatched key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, and ignores `SUBSHELL_CHANNEL_PIN`; first pin then change is a block naming the peer. Grant CRUD and selector matching (alias / concrete host / glob-as-policy); first-use prompt creates the grant and a second use is silent; revoke stops signing mid-session. Agent-only filter: an identity file present on A but not in A's agent is refused with "no matching key," and A's agent roster is filtered to the snapshot-named refs. Relay frame codec round-trips and the protocol bump gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks and names D. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; replay of a captured envelope inside the window is inert. -- **End to end (the crown jewel).** The parent §14 M2 e2e verbatim: A with the key in its agent, B with none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it to assert a grant revoke between handshake and D is refused, and "Set up Subshell here" turns D into an enrolled node over an egressing D. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; and **B rejects a bad A response signature** (reverse direction). +- **End to end (the crown jewel).** Parent §14 verbatim: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. ## 14. Branching, phasing, and review -This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and, per the parent §6.2, an expected focused security review pass; review-until-clean over the whole branch before any merge consideration. M1 remains unmerged at the operator's direction; when M1 lands, this branch rebases onto it. It is deliberately isolated behind the launcher seam M1 exposes, so the relay can be turned off without disturbing M1's direct and jump paths. +This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and per parent §6.2 an expected focused security review; review-until-clean over the whole branch before any merge consideration. M1 stays unmerged at the operator's direction; when M1 lands, this rebases onto it. It is isolated behind the launcher seam M1 exposes, so relay can be off without disturbing M1's direct and jump paths. **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, enroll registration, `ssh_register_identity` link delivery, identities-store slot, `NODE_PROTOCOL_VERSION` bump + relay frame types + validators + census, node-side machine pin store + strictness. -2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, allow-list, identity filter), B agent proxy socket + framing + seal/open, nonce/replay. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, first-use approval flow, grants screen, the new `docs/security.md` §10 event names. -4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, `UserKnownHostsFile` render, changed-key block, trust card + re-pair. -5. **Destination upgrade:** "Set up Subshell here" minting a key and driving enrollment over the open session, egress refusal, node appears. -6. **Crown-jewel e2e + boundary + review loop.** +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, and the `ready`/runtime-report fingerprint carriage. +2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the pending grant-approval flow plus its approve/deny affordance and expiry, the grants screen, and every new `docs/security.md` §10 event name. +4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. +5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. +6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -Single-operator posture unchanged. The plane retains denial-of-service and metadata (parent §10). The §4.6 pre-enroll window equals the accepted control-plane-compromise boundary. B cannot be bound to a host at the agent-protocol layer, so a grant bounds exposure by identity-scoping and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no key file read, no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 out-of-band comparison can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. From da80ea3b66f31152be9e02a17852be8675890216 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:13:54 -0700 Subject: [PATCH 072/170] docs(ssh-relay): M2 spec review round 2 - signing scope, replay seq, out-of-band, local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the second review pass: - §5.4/§5.6: a SIGN_REQUEST blob is scoped to the grant's fingerprints and refused at the responder; replay now uses per-direction monotonic seq with endpoint session nonces so multi-key handshakes complete while resends are inert. - §4.5/§4.6/§10/§15: the out-of-band check is now both-sides-plane-independent (A's dashboard pinned-peer fp vs B's dashboard own fp), not off the plane UI. - §5.6: the parent §8 snapshot-files filter supersession is declared, not implied. - §9: durable pending-approval row + restart behavior; drop the nonexistent host-pin rotate event; add node.ssh_grant.request. - §7: name the real accepted sinks (B/D process tables, install URL), not shell history. - §15/§11: relay requires the node agent, so local is not an A/B in M2. - Cross-ref fixes and §13 verbatim label removed. --- .../2026-10-08-ssh-agent-relay-design.md | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 7a5e5cae0..531bb778e 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -49,7 +49,7 @@ The enroll route already writes the node's ECDH-ES public key into the identitie ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.5 changed-key block rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.4 changed-key block (recovery via §4.5) rather than silently overwriting. ### 4.4 The node-side machine pin store @@ -57,7 +57,7 @@ Relay is always pin-strict. Each side keeps the peer signing keys it verifies ag ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which the parent already renders only for an online agent node; an offline node shows the last-known card marked stale. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which `docs/security.md` §6 attaches only for an online agent node; an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, and that dashboard copy of the pinned-peer list is the plane-independent side of §4.6's out-of-band check. **Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). @@ -65,11 +65,11 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison, and this design offers one: the first-use approval (§6.2) displays the peer fingerprint A is being asked to pin, and B's own signing fingerprint is readable on B's **plane-independent local surface** (its loopback dashboard, §4.5). An operator who compares the two is doing the "first-secure-contact" the parent §10 asked for; a match rules out a plane substitution, a mismatch is a hard stop. That comparison is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0). Making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprint it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own signing fingerprint computed from B's local private key. An operator comparing those two plane-independent values is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real key, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads both values off the two dashboards, not off the plane. The check is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The parent §8 protocol governs; this section pins the pieces and the transport decision. +The parent §8 protocol governs; this section pins the pieces and the transport decision, with one declared deviation: §5.4 supersedes parent §8's snapshot-files identity-filter clause (paths resolved on B are not portable to A's agent), noted again at §5.6. ### 5.1 Transport: node-link relay frames @@ -93,7 +93,7 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses any whose blob is not one of the grant's selected fingerprints, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. @@ -105,9 +105,9 @@ The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemera Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. -**Replay.** The nonces are contributed by the endpoints, not the plane: B's first sealed request and A's first sealed response each carry a fresh 128-bit random, and each side records the peer's value and accepts it only once per session. The ES256 signature covers a canonicalized inner message that includes the relay-session id, the routing ref, a direction tag, both nonces, and the agent bytes. Together these make a captured envelope inert on replay even though the plane **routes every envelope and can resend any of them** (it stores none durably). +**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces are known there (`nB` alone in B's first message, both thereafter). A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably), and because the signature covers the session id, an old envelope cannot ride a later session that reuses a routing ref. -**Lifetime.** The parent §8 bullets on the method allow-list, no-forwarding-toward-D, and handshake-window lifetime are adopted unchanged. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. +**Lifetime.** The parent §8 method allow-list, no-forwarding-toward-D, and handshake-window lifetime bullets are adopted; within the allow-list bullet the clause filtering identities to the snapshot's named identity *files* is superseded by §5.4's fingerprint set (B-resolved paths are not portable to A's agent), which is the deviation §5 declares. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. ## 6. Grants and the first-use approval @@ -117,7 +117,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a pending grant-approval and notifies A's owner (via `notify.service.ts`), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying or letting it expire** (bounded, at setup-key scale) writes nothing. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -127,7 +127,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key lands in D's own shell history under the accepted enrollment-link posture (`docs/security.md`), its life single-use and at most 24 h. (This is the enrollment credential, an accepted-argv exception; it is not SSH key material, which §12 still never logs.) +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, which §12 still never logs. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -143,22 +143,22 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): - `node.ssh_identity.register` (enroll-time registration, and the pre-M2 link delivery, distinguished by a `via` field). -- `node.ssh_grant.create|update|delete` and `node.ssh_grant.approve|deny` (approve names the chosen key fingerprints and destination; never any key material). +- `node.ssh_grant.request|create|update|delete` and `node.ssh_grant.approve|deny` (request names the launch that asked, A, B, and destination; approve names the chosen key fingerprints and destination; never any key material). - `node.ssh_machine_pin.repair` (the §4.5 re-pair, naming the peer id only). - `node.ssh_relay.open|close` (open names grant/A/B/pane and routing ref; close names the reason). -- `node.ssh_host_pin.create|rotate|delete`. +- `node.ssh_host_pin.create|delete` (a changed D host key is the parent §9 hard block; recovery is delete plus a fresh grant-creation TOFU, no separate rotate act). - `node.ssh_upgrade.run` (the §7 destination upgrade, naming D, the grant, and the outcome; never the setup key). The relay metadata the plane always held (which machines talk, timing, sizes, routing ids) is disclosed by parent §10 as retained, not new. ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 out-of-band comparison; it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) @@ -169,6 +169,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. +- **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. - **Over-cap relay frame:** refused, session closed with a named reason (§5.1). - **Relay frame on a pre-bump agent:** the 17→18 `NODE_PROTOCOL_VERSION` exact-match gate refuses pairing before any relay command, naming the agent-upgrade remedy, never half-relaying. @@ -180,9 +181,9 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; and **B rejects a bad A response signature** (reverse direction). -- **End to end (the crown jewel).** Parent §14 verbatim: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file; and a `SIGN_REQUEST` naming a blob outside the selected set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). +- **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. ## 14. Branching, phasing, and review @@ -191,13 +192,14 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, and the `ready`/runtime-report fingerprint carriage. +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, the `ready`/runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the pending grant-approval flow plus its approve/deny affordance and expiry, the grants screen, and every new `docs/security.md` §10 event name. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. 6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 out-of-band comparison can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +- **Relay needs the node agent.** Both roles depend on the node-agent pin store, `ready`-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From 9c8269acae7047bdadfb5666e5c4aed060d11048 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:20:55 -0700 Subject: [PATCH 073/170] docs(ssh-relay): M2 spec review round 3 - encryption-key pinning, out-of-band honesty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the third review pass: - CRUX: sealing uses the peer ENCRYPTION key, so the pairing now delivers and byte-pins BOTH the peer signing and encryption keys (§4.4/§5.1/§5.5); the trust card and dashboard show both. Blindness rests on the encryption pin. - The grant fingerprint SET is now provisioned to A in the relay-open command; §10 notes a plane could widen it (within docs/security.md §0). - §6.2/§3/§10/§15: the out-of-band check reads BOTH values off the two machines' own dashboards; the plane-rendered approval value is display only. - Pin lands on A at relay-open, not at approval (§6.2/§4.4/§13). - §7: never echo raw installer output (its 404 branch prints the unspent key); emit a redacted status verb; fix the installer line. - §5.6 replay: per-direction seq, sender-known nonces, plane-collision caveat. - Named SHA256 fingerprint canonical form, SSH_RELAY_MAX_PER_NODE quota, ssh_grant_requests table; §12/§1 cross-ref and staleness fixes. --- .../2026-10-08-ssh-agent-relay-design.md | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 531bb778e..496a07624 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -13,7 +13,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit **In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (parent §2, unchanged):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2 and §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. ## 2. Terminology (adds to parent §3) @@ -31,7 +31,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit These close the parent §16 list; each is elaborated in its section. -1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the trust card offers an optional out-of-band check. +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the two machines' own loopback dashboards offer an optional out-of-band check. 2. **Destination upgrade** (§7): reuse ordinary enrollment over the machine's own connection. No #330 runtime-session re-port. 3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under it are silent. The approval is the creation act; there is no separate per-grant "ask each time" flag. 4. **A's identity source** (§5.4): agent-only. A's identity files are never read or served; a key becomes usable remotely only once loaded in A's agent. @@ -53,31 +53,31 @@ Nodes enrolled before M2 hold a signing keypair after the agent upgrades but hav ### 4.4 The node-side machine pin store -Relay is always pin-strict. Each side keeps the peer signing keys it verifies against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. A changed peer signing key is a hard block naming the peer and pointing at recovery (§4.5). +Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the ES256 signing key of §4.1 and the ECDH-ES encryption key `seal`/`open` uses) in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify or seal to B (or B to A), the plane brokers the pairing and delivers the peer's registered signing AND encryption public keys inside the relay-open command; the receiving machine writes both to the machine pin store and thereafter enforces byte equality on each, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. Pinning the encryption key is what makes §5.5's blindness real: sealing uses the recipient's ECDH-ES key, so a plane that could hand B a plane-controlled encryption key for A would read every challenge, and this byte-equality block is what refuses it. A changed peer key (either half) is a hard block naming the peer and pointing at recovery (§4.5). ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which `docs/security.md` §6 attaches only for an online agent node; an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, and that dashboard copy of the pinned-peer list is the plane-independent side of §4.6's out-of-band check. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. That canonical form is fixed so the honest dashboard comparison of §4.6 is well-posed. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. -**Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). +**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprint it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own signing fingerprint computed from B's local private key. An operator comparing those two plane-independent values is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real key, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads both values off the two dashboards, not off the plane. The check is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprints it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own fingerprints, computed locally from its private keys. An operator comparing those plane-independent values, for either or both keys, is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real keys, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads values off the two dashboards, not off the plane. It is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under `docs/security.md` §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The parent §8 protocol governs; this section pins the pieces and the transport decision, with one declared deviation: §5.4 supersedes parent §8's snapshot-files identity-filter clause (paths resolved on B are not portable to A's agent), noted again at §5.6. +The parent §8 protocol governs, with two declared deviations: §5.4 supersedes its snapshot-files identity-filter clause (B-resolved paths are not portable to A's agent), and here the relay is route-only, so parent §8's "the plane stores envelopes" phrasing describes the channels precedent, not this posture (it stores none durably). This section pins the pieces and the transport decision. ### 5.1 Transport: node-link relay frames The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is fixed in parent §5.3: the node daemon holds **no REST credential**, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency, bidirectional transport, already app-layer encrypted since protocol 14 and authenticated by the node key. It also fits the traffic: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. -Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's signing public key to pin, the grant id, and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. +Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's registered signing AND encryption public keys to pin, the grant id and its selected key-fingerprint set (§6.1, the set delivered so A's responder can enforce it without a REST call it cannot make), and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled and ssh-launch bumps the parent §4.3 required were already consumed by M1 at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. ### 5.2 B: the agent proxy socket @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -93,19 +93,19 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses any whose blob is not one of the grant's selected fingerprints, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. ### 5.5 Plane blindness, for the record -The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). Each side seals to the peer's **pinned** encryption key (§4.4), so plane blindness rests on the byte-equality pin, not on the plane's honesty. The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. ### 5.6 Origin, replay, and lifetime Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. -**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces are known there (`nB` alone in B's first message, both thereafter). A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably), and because the signature covers the session id, an old envelope cannot ride a later session that reuses a routing ref. +**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces the sender knows at send time (`nB` alone in B's first message; both once each side has seen the peer's), and the receiver verifies against what it holds. A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably). Cross-session replay is closed by the signature covering the relay-session id: an old envelope cannot ride a later session unless the plane mints a colliding id, and if it did the survivor is a stale-challenge signature inert outside the already-closed D connection, self-DoS the plane already holds rather than a forgery. **Lifetime.** The parent §8 method allow-list, no-forwarding-toward-D, and handshake-window lifetime bullets are adopted; within the allow-list bullet the clause filtering identities to the snapshot's named identity *files* is superseded by §5.4's fingerprint set (B-resolved paths are not portable to A's agent), which is the deviation §5 declares. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. @@ -113,11 +113,11 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set. No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64 over the key's public material, the same canonical form the trust card shows). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from. It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -127,7 +127,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, which §12 still never logs. +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -135,7 +135,7 @@ A secondary act inside an open SSH-terminal pane on D. It does not re-port #330' - The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). - **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. -- **Machine trust card** (this is the card §4.5 cites): this machine's own signing fingerprint, every pinned peer with its fingerprint, and a re-pair act each; fed by the `ready`/runtime report and stale-marked offline. Surfaced on the node detail. +- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report, so it renders for owner or `edit` viewers only (never `view` or `local`) and is stale-marked offline. Surfaced on the node detail. - **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. - **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). @@ -143,7 +143,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -158,7 +158,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance, and the operator sees A's actually-pinned keys on its dashboard (§4.5). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) @@ -175,13 +175,13 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed ## 12. Security rules honored (parent §13) -Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log (its landing in D's shell history is the accepted enrollment-link posture, and it is an enrollment credential, not SSH key material). Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file; and a `SIGN_REQUEST` naming a blob outside the selected set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints, the trust card and loopback dashboard render them from the local store, an offline node stale-marks, and a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -192,8 +192,8 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, the `ready`/runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). -2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. From a480cac81a7cf20f3ccf9c9b636b0ff61ead4ba2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:25:01 -0700 Subject: [PATCH 074/170] docs(ssh-relay): M2 spec review round 4 - roster enumeration, grant-set honesty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5.4/§6.2/§11/§13/§14: add the ssh_agent_identities plane->A signed command that enumerates A's public agent identities (fingerprints+comments, no blobs) for the approval screen, with an A-offline-pending failure mode and test; the plane cannot otherwise reach A's roster to offer a selection. - §10: the enforced agent-key set is plane-widenable and NO plane-independent surface shows it (dashboard = machine pins); the durable record is the node.ssh_grant.approve audit row. - §2 glossary + §11 + §10: machine pin covers BOTH keys, not signing only. - §1 out-of-scope attribution to parent §2/§5.2/§8; §9 teardown-grace constant; §13 trust-card-vs-dashboard render split; §15 folded bullet; minor phrasing. --- .../2026-10-08-ssh-agent-relay-design.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 496a07624..8de928bc4 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -13,7 +13,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit **In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (parent §2 and §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2, parent §5.2 and parent §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. ## 2. Terminology (adds to parent §3) @@ -21,7 +21,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit - **B / connecting machine:** the machine that runs the pane and the `ssh D` process, holding no key. `ssh_enabled` on. - **D / destination:** any sshd. Not gated; dialed to. - **Signing identity:** the per-machine ES256 keypair introduced here, distinct from the ECDH-ES encryption pair already in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command key in `control-keys.ts`. -- **Machine pin:** a byte-equality pin of a peer's signing public key, held in a new node-side machine pin store (never the channels' `peers.json`). +- **Machine pin:** a byte-equality pin of a peer's public keys, signing and encryption halves alike (§4.4), held in a new node-side machine pin store (never the channels' `peers.json`). - **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). - **TOFU:** trust-on-first-use; a key is pinned from the first copy received, and any later differing copy is refused. - **Relay frame:** the new `/ws/node` link frame that carries one sealed envelope (§5.1). @@ -77,7 +77,7 @@ The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/chan Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's registered signing AND encryption public keys to pin, the grant id and its selected key-fingerprint set (§6.1, the set delivered so A's responder can enforce it without a REST call it cannot make), and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17 to 18 bump is what makes §11's pre-bump refusal and §13's bump gate true. ### 5.2 B: the agent proxy socket @@ -95,6 +95,8 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Roster enumeration for approval.** So the operator has something to choose, the plane can ask A's responder (a signed `ssh_agent_identities` command, no relay session required) for A's agent's public identities as `SHA256:` fingerprints plus their comments, blobs withheld. Like the `detect` answer it discloses nothing but what is asked, writes no audit row, and fails closed if A is offline (§11). + **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. ### 5.5 Plane blindness, for the record @@ -117,7 +119,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from. It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from (enumerated by the §5.4 `ssh_agent_identities` command; with A offline the screen cannot offer a selection and the request stays pending until A is reachable). It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -143,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), and a handshake-teardown grace constant for §5.6. **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -158,15 +160,16 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance, and the operator sees A's actually-pinned keys on its dashboard (§4.5). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) Parent §12 stands in full (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: - **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). +- **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. -- **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -181,7 +184,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints, the trust card and loopback dashboard render them from the local store, an offline node stale-marks, and a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -194,12 +197,11 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe 1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. 6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. -- **Relay needs the node agent.** Both roles depend on the node-agent pin store, `ready`-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From c5193f135e14f889798eb8d84eea14c4dfa368ca Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:30:43 -0700 Subject: [PATCH 075/170] docs(ssh-relay): M2 spec review round 5 - detection-not-prevention honesty, roster command shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.6/§10/§15: the dashboard check is post-hoc DETECTION, not prevention or 'first-secure-contact' - pins only land at first relay-open, so the first handshake already ran on plane-delivered keys; remedy is revoke/re-enroll/ re-pair, not an automatic stop. - §5.4: responder invariant scoped to agent-protocol traffic; ssh_agent_identities is a signed node command (no grant needed); §13 test split so it isn't deadlocked. - §4.2/§4.3/§11/§13: node key rotation clears signingPublicKey so the redial re-report is a first fill, not a §4.3 block; peers re-pair. - §4.5: machine-key fingerprint preimage pinned to DER SPKI (not JSON JWK). - §4.5/§9: the runtime-report fingerprint block is mirrored onto the nodes row so the offline stale-mark is backed; new SSH_MAX_GRANT_FINGERPRINTS (distinct from SSH_MAX_IDENTITY_REFS); §10 notes the roster fetch discloses A's whole roster. --- .../2026-10-08-ssh-agent-relay-design.md | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 8de928bc4..eabeb7c5b 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,11 +45,11 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation**, which today clears `encryptPublicKey` inside its transaction, clears `signingPublicKey` in the same transaction, so a rotated node's redial re-report fills an empty slot as a first registration rather than tripping §4.3's guard; peers that had pinned the old key recover through §4.5's re-pair. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.4 changed-key block (recovery via §4.5) rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a post-rotation re-report is exempt, because rotation emptied the slot first, §4.2); recovery is re-enroll plus per-peer re-pairs (§4.5). ### 4.4 The node-side machine pin store @@ -57,7 +57,7 @@ Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. That canonical form is fixed so the honest dashboard comparison of §4.6 is well-posed. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. The preimage is pinned: agent keys (§5.4) hash their agent wire encoding, and the ES256/ECDH-ES machine keys hash their DER `SubjectPublicKeyInfo`, never a JSON-JWK serialization (which is not canonical), so the honest dashboard comparison of §4.6 is well-posed rather than serialization-dependent. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale, which the plane can render because it mirrors the last reported fingerprint block durably onto the `nodes` row (§9) rather than reading only the live socket. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. **Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. @@ -65,7 +65,7 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprints it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own fingerprints, computed locally from its private keys. An operator comparing those plane-independent values, for either or both keys, is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real keys, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads values off the two dashboards, not off the plane. It is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under `docs/security.md` §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice. A's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5); B's dashboard shows B's own fingerprints computed locally from its private keys; a mismatch means the plane substituted, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. ## 5. Relay runtime @@ -89,13 +89,13 @@ The plane brokers a pairing only when a live grant is used (§6). In memory it h ### 5.4 A: the responder -A's responder accepts only requests the plane brokered for a live grant on a session naming this A. Two rules: +A's responder accepts an **agent-protocol** request only when the plane brokered it for a live grant on a session naming this A. (The roster command below is the one non-agent exception: it is a node-link signed command like `detect`, answered with no grant outstanding.) Two rules: **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections). A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. -**Roster enumeration for approval.** So the operator has something to choose, the plane can ask A's responder (a signed `ssh_agent_identities` command, no relay session required) for A's agent's public identities as `SHA256:` fingerprints plus their comments, blobs withheld. Like the `detect` answer it discloses nothing but what is asked, writes no audit row, and fails closed if A is offline (§11). +**Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), and a handshake-teardown grace constant for §5.6. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5). The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and detectable after the fact by the optional §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -170,6 +170,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **Node key rotation:** clears both registration slots (§4.2), the node re-reports on redial, and every peer must re-pair it (§4.5); a stale peer pin blocks until re-paired. - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -184,8 +185,8 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -195,7 +196,7 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing both slots, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. @@ -204,4 +205,4 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can DETECT a pre-first-pin substitution after the fact on the operator's initiative, but neither prevents it before first use nor is mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From 84a329c76c7d88e98e56717434d69cc3be04a5ee Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:34:03 -0700 Subject: [PATCH 076/170] docs(ssh-relay): M2 spec review round 6 - cap enforcement, mirror gating, installer test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5.4/§13: SSH_MAX_GRANT_FINGERPRINTS overflow is a hard red grants-screen error (not truncation), with a test. - §8/§9/§13: the offline nodes-row fingerprint mirror serializes under the same owner-or-edit gate as the live runtime field (never view/local); test. - §13: a unit pins the installer 404 branch prints --key , no value. - §6.1: fingerprint wording fixed - grant fingerprints hash agent wire encoding, the trust card shows machine-key DER-SPKI hashes; same notation, not same preimage. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index eabeb7c5b..fb111273b 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -93,7 +93,7 @@ A's responder accepts an **agent-protocol** request only when the plane brokered **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections). A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections); an approval selecting more than the cap is a hard red error on the grants screen, never a silent truncation. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). @@ -115,7 +115,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64 over the key's public material, the same canonical form the trust card shows). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant @@ -137,7 +137,7 @@ A secondary act inside an open SSH-terminal pane on D. It does not re-port #330' - The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). - **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. -- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report, so it renders for owner or `edit` viewers only (never `view` or `local`) and is stale-marked offline. Surfaced on the node detail. +- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report and mirrored onto the node row for the offline case, gated to owner or `edit` viewers on both paths (never `view` or `local`) and stale-marked offline. Surfaced on the node detail. - **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. - **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5). The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -185,7 +185,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. From 60e2de9697a614d0e5f69dfaa261d15d90936302 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:38:53 -0700 Subject: [PATCH 077/170] docs(ssh-relay): M2 spec review round 7 - rotation is not an identity reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.1/§4.2/§4.3/§11/§13: node key rotation is a bearer-credential act; the machine's on-disk signing/encryption keypairs survive it, so the redial re-reports the SAME bytes, no guard trips, and peer pins are unaffected. Only a re-enroll or replaced data dir changes the signing identity and forces re-pairs. Corrects the round-5 containment over-claim. - §7/§11: the 'Set up Subshell here' exec in relay mode re-authenticates through A's agent, so it re-opens a relay and needs A online; add that failure mode. - §5.3 teardown 'handshake established' -> 'handshake grace elapsed' (§5.6 says the handshake is unobservable). - §12: scope 'removed with the pane' to the per-session dir/config; socket unbinds at teardown. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index fb111273b..17981507e 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -41,15 +41,15 @@ These close the parent §16 list; each is elaborated in its section. ### 4.1 A per-machine signing keypair -`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. +`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only when the node's data dir is reset or it re-enrolls; plane-side key rotation does NOT touch it (§4.2)). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation**, which today clears `encryptPublicKey` inside its transaction, clears `signingPublicKey` in the same transaction, so a rotated node's redial re-report fills an empty slot as a first registration rather than tripping §4.3's guard; peers that had pinned the old key recover through §4.5's re-pair. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act: it nulls both plane-side registration slots, but the node's own signing and encryption keypairs live on its disk and survive rotation unchanged, so the redial re-reports the SAME bytes, §4.3's guard never trips, and peers' pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a post-rotation re-report is exempt, because rotation emptied the slot first, §4.2); recovery is re-enroll plus per-peer re-pairs (§4.5). +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a redial after rotation first-fills the emptied slot, which is not a mismatch, §4.2); a genuine change here means a re-enroll or replaced data dir, recovered by re-enroll plus per-peer re-pairs (§4.5). ### 4.4 The node-side machine pin store @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -129,7 +129,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. In relay mode this second `ssh D` re-authenticates through A's agent, so it re-opens a relay under §5.6's adopted lifetime rule and A must be online; direct and jump panes authenticate with B's own key and need no relay. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -170,7 +170,8 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **Node key rotation:** clears both registration slots (§4.2), the node re-reports on redial, and every peer must re-pair it (§4.5); a stale peer pin blocks until re-paired. +- **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). +- **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -179,13 +180,13 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed ## 12. Security rules honored (parent §13) -Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config are 0600, the socket unbinds at relay teardown (§5.2), and the per-session dir with its rendered config is removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation nulls both registration slots but re-reports the same bytes, so the redial first-fills the slot with no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. From a07ea6952d363915a8797c4031153adcd214333e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:41:20 -0700 Subject: [PATCH 078/170] docs(ssh-relay): M2 spec review round 8 - symmetric pre-first-pin window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.6/§10: the residual window is SYMMETRIC (a plane compromised before either machine first pins the other can substitute, including handing B a bad A encryption key to read every challenge); the §4.6 dashboard check is now two readings, one per direction. - §4.5: 'genuinely rotated peer key' -> 'genuinely changed peer key (re-enroll or replaced data dir)', matching the round-7 rotation story. - §5.3 teardown: add 'child exit' to the signal list. - §11: 'no signing key' names the redial remedy instead of an undefined act. - §9: constant-list connector cleanup. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 17981507e..6b7ec7354 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -59,13 +59,13 @@ Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. The preimage is pinned: agent keys (§5.4) hash their agent wire encoding, and the ES256/ECDH-ES machine keys hash their DER `SubjectPublicKeyInfo`, never a JSON-JWK serialization (which is not canonical), so the honest dashboard comparison of §4.6 is well-posed rather than serialization-dependent. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale, which the plane can render because it mirrors the last reported fingerprint block durably onto the `nodes` row (§9) rather than reading only the live socket. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. -**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. +**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely changed peer key (a re-enroll or a replaced data dir, §4.2) is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice. A's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5); B's dashboard shows B's own fingerprints computed locally from its private keys; a mismatch means the plane substituted, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. +The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice, and it has two readings, one per direction, because the exposure is symmetric. Each machine's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5), and the peer's own dashboard shows what it computes locally from its private keys; the operator compares A's stored copy of B against B's own, and B's stored copy of A against A's own. A mismatch in either direction means the plane substituted there, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. ## 5. Relay runtime @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, child exit, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and detectable after the fact by the optional §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -168,7 +168,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). -- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. +- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and state that it registers its signing identity on its next link contact (a redial, §4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). - **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). From 971d680ed03b81a40175ca55b1c551cfa1735983 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:45:18 -0700 Subject: [PATCH 079/170] docs(ssh-relay): M2 spec review round 9 - name the three rotation stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.2/§11/§13/§14: rotation clears nodes.encryptPublicKey (link pin, register self-heal re-pins same bytes) + the signingPublicKey slot (§4.3 re-reports same bytes); it NEVER clears the identities ECDH publicKey. 'both slots' was ambiguous and implied a cleared ECDH registration nothing refills. - §4.6/§10: post-pin gain phrased symmetrically ('a machine has pinned'). - §13: add the relay-teardown (grace + child exit) and egress-refusal tests. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 6b7ec7354..c09b66b4d 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,7 +45,7 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act: it nulls both plane-side registration slots, but the node's own signing and encryption keypairs live on its disk and survive rotation unchanged, so the redial re-reports the SAME bytes, §4.3's guard never trips, and peers' pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it touches three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes @@ -63,7 +63,7 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** a machine has pinned it without tripping the block. It does **not** close the pre-first-pin window. The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice, and it has two readings, one per direction, because the exposure is symmetric. Each machine's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5), and the peer's own dashboard shows what it computes locally from its private keys; the operator compares A's stored copy of B against B's own, and B's stored copy of A against A's own. A mismatch in either direction means the plane substituted there, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after a machine has pinned the other's two keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -170,7 +170,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and state that it registers its signing identity on its next link contact (a redial, §4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). +- **Node key rotation** (bearer credential): clears the node-link pin and the signing slot (§4.2); the redial re-pins and re-reports the same bytes, so peer pins are unaffected. Only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). - **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. @@ -186,7 +186,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation nulls both registration slots but re-reports the same bytes, so the redial first-fills the slot with no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears the link pin and the signing slot but the redial re-reports the same bytes, so there is no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; the rendered installer 404 branch prints `--key ` with no key value; the relay proxy socket unbinds once the §5.6 grace elapses and immediately on child exit; and a non-egressing D refuses "Set up Subshell here" with the named egress cause, pane untouched. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -197,7 +197,7 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing both slots, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing the link pin plus the signing slot, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. From 439447df4aa2d61fb9fe1fec4ec1e1f1077c6d90 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:46:31 -0700 Subject: [PATCH 080/170] docs(ssh-relay): M2 spec review round 10 - 'concerns' three stores (review clean) Round 10 returned no Critical/Important/Minor findings; this is the lone optional nit: rotation *concerns* three stores (it clears two, never the ECDH publicKey). --- docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index c09b66b4d..6f2936b75 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,7 +45,7 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it touches three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it concerns three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes From 924da8257aa66467b7d9414d7076cdf7e1ede261 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:22:12 -0700 Subject: [PATCH 081/170] docs(ssh-relay): M2 implementation plan (16 tasks, subagent-driven) --- .../plans/2026-10-08-ssh-relay-plan.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-ssh-relay-plan.md diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md new file mode 100644 index 000000000..abfdc3f2e --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -0,0 +1,324 @@ +# SSH sealed agent relay (Milestone 2) implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship relay mode: an SSH pane running on a keyless connecting machine B authenticates to an sshd-only destination D through machine A's ssh-agent over sealed envelopes the plane routes but cannot read, plus the machine-identity roster, grants/approval, host-key pins, and the "Set up Subshell here" upgrade that the parent spec's Milestone 2 requires. + +**Architecture:** Reuse the E2EE channel crypto (`mcp-core` seal/open) and the TOFU byte-equality rule in a NEW always-strict node-side machine pin store. Per-machine ES256 signing keys (minted at enroll) prove origin; the peer's signing AND encryption keys are byte-pinned. Sealed agent traffic rides a new frame type on the existing app-layer-encrypted `/ws/node` link (the daemon's only authenticated transport), which the plane blind-routes by an opaque ref. Grants and their first-use approval, destination host pins, and a durable relay-session broker live on the plane; the ssh-agent proxy socket lives on B and the responder on A. The destination upgrade re-uses ordinary enrollment driven non-interactively over the open session. + +**Tech stack:** Bun, TypeScript, Elysia, Kysely (SQLite), jose (JWE/JWS via `mcp-core`), libsodium `crypto_kx`/secretstream node link, node-ED25519/P-256 through WebCrypto, React SPA (`apps/server/web`), Playwright e2e, an sshd test fixture (M1). + +## Global Constraints + +Every task implicitly includes these; values are copied from `docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md` and the parent. + +- The relay is its own branch `feat/ssh-anywhere-m2` off `feat/ssh-anywhere-3`; commit as `Theo Gravity `. Never push or merge without the operator's explicit word; M1 stays unmerged. +- No key material, challenge, signature, or agent payload in any log, argv, audit row, or notification. Audit rows name ids/hosts/field-names only. +- `shellQuote` on every token of the built `ssh` command; the connection is one tmux shell string (never reasoned away). `SSH_AUTH_SOCK` is exported into the `ssh` invocation only, never the whole pane env. An agent-capable SSH pane is view-only to sharees. +- Outbound SSH stays gated per node (`ssh_enabled`, default off, fail-closed). Both relay roles require an agent node; `local` is not an A/B. +- Sealing reuses `packages/mcp-core/src/crypto.ts`; the machine pin store is a separate file, always strict, and NEVER honors `SUBSHELL_CHANNEL_PIN`. Pin the peer's signing AND encryption keys byte-equal. +- Origin = ES256 signature over the canonicalized inner payload, verified against the pinned machine key. Replay = per-direction monotonic `seq` + endpoint-contributed session nonces, both signed. +- Static imports only (the lone sanctioned `await import` is `plugin-runtime.ts`, untouched). +- New wire caps are constants in `packages/subshell-protocol/src/ssh-limits.ts` and are law: `SSH_RELAY_FRAME_MAX_BYTES = 131_072`, `SSH_RELAY_MAX_PER_NODE = 8`, `SSH_RELAY_LIFETIME_MS = 30_000`, `SSH_RELAY_TEARDOWN_GRACE_MS = 5_000`, `SSH_MAX_GRANT_FINGERPRINTS = 8` (a distinct name from `SSH_MAX_IDENTITY_REFS`). +- `NODE_PROTOCOL_VERSION` bumps 17 to 18 (M1 already consumed 16 for the gate and 17 for the ssh-launch grammar); relay frames and the open/close commands ship in that bump. +- Migrations continue at `0049` (`apps/server/api/src/db/migrations/`); never run an M2 migration against a live DB; the ceiling/strip-set fixtures used in M1 must include the new migrations. +- Voice: no U+2013/U+2014 in prose or shipped strings (`bun run lint:prose` gates it); UI copy is at most two sentences, role tokens only. +- Verification: focused files while iterating; the full trio (`bun run verify-types`, `lint:check`, `lint:prose`) plus `bun run test` at every task boundary; `bunx turbo build` before any server/web/e2e test that consumes `packages/`; happy-dom tests run from `apps/server/web`; run shell-spawning suites with `env -u SHELLOPTS -u BASHOPTS` and check bun's file count; never target the live `:3080`. + +--- + +## File structure (what gets created or modified) + +**Protocol (`packages/subshell-protocol/src/`)** +- Modify `node-frames.ts`: `NODE_PROTOCOL_VERSION` 18; new command arms `ssh_register_identity`, `ssh_agent_identities`, `ssh_relay_open`, `ssh_relay_close`; new relay frame kind + `SshRelayFrame`; dispatch in `parseNodeCommandBody`. +- Modify `ssh-frames.ts` / `ssh-results.ts`: validators + result shapes for the four new commands and the relay frame. +- Modify `ssh-limits.ts`: the five new constants. +- New `ssh-pin-store.ts`: shared fingerprint + byte-equality helpers (pure, no node builtin). +- New `ssh-relay.ts`: pure relay envelope codec (canonicalize/sign/verify/nonce/seq), sealing via injected `seal`/`open` (no `node:` import; Metro-safe). + +**Node agent (`apps/node/agent/src/`)** +- Modify `identity.ts`: generate + persist (0600) an ES256 signing keypair beside the ECDH pair; report its public JWK. +- Modify `enroll.ts`, `commands/index.ts`, `commands/report.ts` (ready report), and link register path to carry/register the signing key. +- New `commands/ssh-identity.ts` (`ssh_register_identity`, `ssh_agent_identities` handlers, copying `commands/set-ssh-enabled.ts`). +- New `commands/ssh-relay.ts` (the A-side responder) + `machine-pin-store.ts` (node-local pin file). +- New `relay-proxy.ts` (the B-side agent proxy socket) wired into `commands/ssh-relay.ts`. +- Modify `ssh-shared.ts` for shared relay/socket helpers. + +**Server API (`apps/server/api/src/`)** +- New `db/migrations/0049-ssh-relay-identity.ts` (identities `signingPublicKey`; `nodes.ssh_fingerprint` mirror block), `0050-ssh-grants.ts` (`ssh_key_grants`, `ssh_grant_requests`, `ssh_host_pins`). +- Modify `db/types/identities.db-types.ts`, `db/types/nodes.db-types.ts`; new `db/types/ssh-grants.db-types.ts`. +- Modify `db/repositories/identities.repository.ts` (signing slot); new `ssh-grants.repository.ts`. +- New `services/ssh-relay.service.ts` (broker, route, responder RPC, grant/approval logic), `services/ssh-grants.service.ts`, `services/ssh-host-pins.service.ts`. +- New `services/nodes/relay-frames.ts` (link relay frame pump) and modify `services/nodes/node-ws-handler.ts` + `link-session.ts` for the new frame type + pairing. +- Modify `api/nodes/enroll.route.ts` (register signing key), `rotate-node-key.route.ts` (clear signing slot), `nodes/node-view.ts` + `get-node.route.ts` (fingerprint carriage, owner/edit gate). +- New `api/ssh/grants.route.ts`, `approvals.route.ts`, `trust.route.ts`, `setup-here.route.ts`; modify `api/ssh/index.ts`. +- Modify `services/notify.service.ts` (`grant_approval` kind + owner-addressed send); new pending-approval expiry sweep hook. + +**Pane runtime (`packages/pane-runtime/src/ssh/`)** +- Modify `ssh-render.ts`: a pinned-config render path (UserKnownHostsFile from the host-pin + `StrictHostKeyChecking yes`) used only in relay mode. + +**SPA (`apps/server/web/src/`)** +- Modify `lib/ssh.ts` (grant/approval/trust/setup-here calls + types); new `components/ssh/grants-screen.tsx`, `pending-approvals.tsx`, `trust-card.tsx`; route + nav wiring. + +**Backend client:** `packages/backend-client` re-exports the app type; rebuild after routes land. + +**Tests + docs:** `apps/node/agent/src/**/__tests__`, `apps/server/api/src/**/__tests__`, `apps/server/web/src/**/__tests__`, `packages/subshell-protocol/src/__tests__`, `e2e/tests/23-ssh-relay.spec.ts`; `docs/security.md` §10 event list. + +--- + +## Task 1: Signing keypair at the node + enroll registration + +**Files:** +- Modify: `apps/node/agent/src/identity.ts` +- Modify: `apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts` (create), `apps/server/api/src/db/types/identities.db-types.ts`, `apps/server/api/src/db/repositories/identities.repository.ts` +- Modify: `apps/server/api/src/api/nodes/enroll.route.ts`, `apps/node/agent/src/enroll.ts` +- Test: `apps/node/agent/src/__tests__/identity.test.ts`, `apps/server/api/src/api/nodes/__tests__/enroll.route.test.ts` (existing file), `apps/server/api/src/db/migrations/__tests__` ceiling + +**Interfaces:** +- Produces: `identity.signingKeyPair: CryptoKeyPair` (ES256, P-256) persisted 0600 with a sibling `node-signing-identity.json`; `identity.signingPublicJwk: string`. Identities record gains `signingPublicKey: string | null`. `register({ principalId, publicKey, signingPublicKey, displayName })`. + +- [ ] **Step 1: Write the failing node identity test.** In `identity.test.ts`, assert the loaded identity exposes an ES256 (`"ES256"`/P-256) public JWK on `signingPublicJwk`, that a second load returns the SAME signing key (persisted), and that the file is mode 0600. +- [ ] **Step 2: Run it, confirm FAIL** (`env -u SHELLOPTS -u BASHOPTS bun test apps/node/agent/src/__tests__/identity.test.ts`). +- [ ] **Step 3: Implement.** In `identity.ts`, after the ECDH pair, `generateKeyPair("ES256", { crv: "P-256", extractable: true })`, persist to `node-signing-identity.json` 0600 (write the private JWK, `chmod 0o600`), load-or-generate on ENOENT only (mirror the ECDH loader); export `signingPublicJwk`. Do NOT regenerate on any error other than ENOENT. +- [ ] **Step 4: Migration 0049** adds `signingPublicKey` (`text` nullable) to the identities table and an `ssh_fingerprint` (`text` nullable, JSON mirror block) to `nodes`; add both to `IdentityTable` / `NodesTable` types. Follow `0048-ssh-launch-and-saved-hosts.ts`. +- [ ] **Step 5: Enroll carries it.** In `enroll.ts` add `signingPublicKey: identity.signingPublicJwk` to the enroll body; in `enroll.route.ts` validate it is a P-256 public JWK (mirror the `publicKey` decode/length block) and pass it to `IdentitiesRepository.register({ ..., signingPublicKey })`. Extend `reregister-node.ts` to swap it atomically. +- [ ] **Step 6: Register + repo.** `identities.repository.ts.register` writes `signingPublicKey`; add it to the interface. Node-delete cascade unchanged (row-scoped). +- [ ] **Step 7: Ceiling test** add 0049 to the migration-count/strip-set fixtures; run `env -u SHELLOPTS -u BASHOPTS bun test apps/server/api/src/db` and the enroll test; PASS. +- [ ] **Step 8: Commit** `git commit -m "feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll"`. + +## Task 2: Machine pin store + fingerprint helpers + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-pin-store.ts` +- Create: `apps/node/agent/src/machine-pin-store.ts` +- Test: `packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts`, `apps/node/agent/src/__tests__/machine-pin-store.test.ts` + +**Interfaces:** +- Produces (protocol): `fingerprintJwk(publicJwkString): string` (SHA-256 over DER `SubjectPublicKeyInfo`, `SHA256:` + base64url no-pad), `bytesOfJwk(jwk): Uint8Array` canonicalization. (agent) `class MachinePinStore` with `get(nodeId): { signing: string; encryption: string } | null`, `pin(nodeId, { signing, encryption })`, `check(nodeId, candidate): "ok" | "changed"`, file `/ssh-machine-pins.json` 0600. + +- [ ] **Step 1: Failing protocol test.** `fingerprintJwk` returns a stable `SHA256:` string; two serializations of the same key (different JWK member order) yield the SAME fingerprint (proves DER-SPKI, not JSON). Wrong bytes differ. +- [ ] **Step 2: FAIL run** on the new test path. +- [ ] **Step 3: Implement `ssh-pin-store.ts`.** Import no `node:` builtin; use WebCrypto `crypto.subtle.importKey("jwk", ...)` + `exportKey("spki", ...)` then `digest("SHA-256")`, format `SHA256:` + base64url(no pad). Export the pure helpers. +- [ ] **Step 4: Failing store test.** `MachinePinStore`: first `pin` succeeds; `check` with the same bytes = ok; with different = "changed"; the file is a SEPARATE path from the pane `peers.json`; and reading never consults `SUBSHELL_CHANNEL_PIN` (assert the env var has no effect). +- [ ] **Step 5: Implement `machine-pin-store.ts`.** JSON map `nodeId -> {signing, encryption}`; byte-equality on both halves via the stored raw public strings, not fingerprints; 0600 after write (chmod); no fallback to `peers.json`; a distinct reader function. No `SUBSHELL_CHANNEL_PIN` reference. +- [ ] **Step 6: PASS** both test files. +- [ ] **Step 7: Commit** `git commit -m "feat(ssh-relay): always-strict node machine pin store (both keys, no channel escape)"`. + +## Task 3: `ssh_register_identity` link delivery for pre-M2 nodes + +**Files:** +- Modify: `packages/subshell-protocol/src/ssh-frames.ts`, `node-frames.ts`, `ssh-results.ts` +- Create: `apps/node/agent/src/commands/ssh-identity.ts` +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/server/api/src/services/nodes/*` (ready handler), a new `ssh-rpc` helper +- Test: `apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore` (Task 2), signing slot (Task 1). Produces: signed command `ssh_register_identity` returning `{ signingPublicKey }`; server `deliverSigningKey(node): Promise` that stores into the slot and, when a DIFFERENT key is already present, refuses (own-registration guard) and returns the existing. + +- [ ] **Step 1: Failing test.** `deliverSigningKey` writes a first-time report, is idempotent on a re-report of the same bytes, and rejects a report of a different key (returns existing, writes nothing). +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Command shape.** Add the `ssh_register_identity` arm to `parseNodeCommandBody` + `SSH_COMMAND_TYPES` (census) + an `ssh-results.ts` validator for `{ signingPublicKey }`. Handler mirrors `commands/set-ssh-enabled.ts`: read `identity.signingPublicJwk`, return it. +- [ ] **Step 4: Server side.** In the `ready` handler, when an online agent reports no `signingPublicKey` slot, `sendCommand("ssh_register_identity")` once and store the answer into the slot through `deliverSigningKey`. Idempotent, audited `node.ssh_identity.register` with `via:"link"` (add the event name to `docs/security.md` §10). +- [ ] **Step 5: PASS** + protocol test that `parseNodeCommandBody` narrows the new arm and the census count matches. +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): ssh_register_identity delivers signing keys for pre-M2 nodes"`. + +## Task 4: Protocol bump + relay frame type + constants + +**Files:** +- Modify: `packages/subshell-protocol/src/node-frames.ts`, `ssh-limits.ts`, `index.ts` +- Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` (census), a new relay-frame codec test + +**Interfaces:** +- Produces: `NODE_PROTOCOL_VERSION = 18`; frame kinds `ssh_relay_open`/`ssh_relay_close` (signed commands) and a `relay` link frame `{ ref, seq, direction, blob }`; the five caps (Global Constraints). `parseRelayFrame(value): RelayFrame | null`. + +- [ ] **Step 1: Failing test.** `NODE_PROTOCOL_VERSION === 18`; `parseRelayFrame` accepts `{ref:"r1",seq:0,direction:"B2A",blob:}` and rejects over-cap blobs (`> SSH_RELAY_FRAME_MAX_BYTES`) and malformed; the open/close command arms validate. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Bump the version; add the open/close command arms (validators name: session id, routing ref, both node ids + roles, peer signing + encryption public keys, grant id + selected fingerprint set, lifetime); add the `relay` frame + `parseRelayFrame` enforcing the cap. Export from `index.ts`. +- [ ] **Step 4: Wire the link pump.** In `apps/server/api/src/services/nodes/link-session.ts` + `node-ws-handler.ts`, accept the `relay` frame after `open`, key pairing by `ref`, and forward between A's and B's sockets without parsing `blob`. Refuse on a pre-18 agent via the existing exact-match gate. +- [ ] **Step 5: PASS** (protocol tests + a plane-side pairing unit test with two fake sockets). +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): protocol 18, relay open/close commands + blind relay frame"`. + +## Task 5: Relay envelope codec (origin, seq, nonces) + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-relay.ts` +- Test: `packages/subshell-protocol/src/__tests__/ssh-relay.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore` check, `crypto.ts` seal/open, the signing key. Produces: `canonicalize(msg): string`, `sealAgentRequest(...)`, `openAgentRequest(...)`, per-direction `SeqGate` (`accept(dir, seq): boolean` monotonic), nonce helpers. + +- [ ] **Step 1: Failing test.** A message signed by A verifies against A's pinned key and fails against a swapped key; `SeqGate` accepts increasing `seq` per direction and rejects equal/decreasing; a canonicalized payload binds session id + ref + direction + seq + nonces + agent bytes (changing any fails verification); `open` by the wrong recipient id throws. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement `ssh-relay.ts`.** Canonicalize deterministically (sorted-key JSON with fixed number formatting, base64url blobs); ES256 `sign`/`verify` over the canonical string (WebCrypto, injected key); wrap in `seal`/`open` (recipient encryption key from the pin); expose `SeqGate`. No `node:` import (Metro-safe). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): sealed envelope codec with signed canonical payload + seq gate"`. + +## Task 6: B-side agent proxy socket + +**Files:** +- Create: `apps/node/agent/src/relay-proxy.ts` +- Modify: `apps/node/agent/src/commands/ssh-relay.ts` (new, B branch), `ssh-shared.ts` +- Test: `apps/node/agent/src/__tests__/relay-proxy.test.ts` + +**Interfaces:** +- Consumes: `ssh-relay.ts` codec, the relay frame pump, the machine pin of A. Produces: `startAgentProxy({ paneId, peerNodeId, sessionRef }): { socketPath, close() }` speaking the ssh-agent Unix wire: each request → sealed relay frame (direction B2A, next `seq`); each reply frame → opened and written back; `close()` unbinds. + +- [ ] **Step 1: Failing test.** Against a stub agent, `startAgentProxy` accepts an `SSH2_AGENTC_REQUEST_IDENTITIES` on the socket, emits one sealed relay frame with `seq:0 direction:"B2A"`, and writes the reply bytes back to the socket verbatim; a second `SIGN_REQUEST` gets `seq:1`. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Unix socket in `/ssh//agent.sock` (0600), framed length-prefixed agent messages; seal each to A's pinned encryption key with the ES256 signature; hand the sealed blob to the link pump as a `relay` frame; on reply, verify A's signature (pin) + `SeqGate` then write back. Never export the socket path beyond `SSH_AUTH_SOCK` (the pane env exception). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames"`. + +## Task 7: A-side responder (allow-list, filter, origin) + +**Files:** +- Create: `apps/node/agent/src/commands/ssh-relay.ts` (A branch) + `relay-responder.ts` +- Test: `apps/node/agent/src/__tests__/relay-responder.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces: `handleRelayRequest(frame, session): Promise` that verifies origin, default-denies agent methods, filters identities, and forwards to A's real agent socket. + +- [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (26) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type, refuse all else; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. + +## Task 8: Plane relay broker + lifetime/quota/teardown + audit + +**Files:** +- Create: `apps/server/api/src/services/ssh-relay.service.ts` +- Modify: `services/nodes/relay-frames.ts` (create), `node-ws-handler.ts` +- Test: `apps/server/api/src/services/__tests__/ssh-relay.service.test.ts` + +**Interfaces:** +- Produces: `openRelay({ grantId, aNode, bNode, paneId }): { sessionRef, expiry }` (in-memory), `routeRelayFrame(...)`, `closeRelay(ref, reason)`, quota check, timer teardown; emits `node.ssh_relay.open|close`. + +- [ ] **Step 1: Failing test.** `openRelay` pairs A+B with an opaque ref and stores no payload; exceeding `SSH_RELAY_MAX_PER_NODE` throws a loud refusal; `closeRelay` on lifetime/grant-revoke/A-drop fires; frames route by ref without the plane holding bytes. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** In-memory `Map` + a per-node count; deliver the two `ssh_relay_open` signed commands (carrying both peer keys + the grant fingerprint set + lifetime); pump `relay` frames between the two sockets blind; `closeRelay` on expiry, A offline, grant revoke, handshake grace, child exit; audit open/close. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): plane relay broker with lifetime, quota, blind routing, audit"`. + +## Task 9: Relay integration + replay tests + +**Files:** +- Test: `apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts` + +- [ ] **Step 1: Write the integration matrix.** Two in-process machine identities over a fake plane broker: sign reaches A's stub agent and returns sealed; a non-allow-listed method refused; wrong-origin refused; a `SIGN_REQUEST` with an ungranted blob refused after a correct filtered roster; relayed blob byte-opaque at the plane; captured-envelope replay inside the window AND into a later reused-ref session inert; multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes; a resent/non-increasing `seq` refused; B rejects a bad A response signature; `ssh_agent_identities` accepted with no grant. +- [ ] **Step 2: Implement any seams the tests expose** (do not add behavior not in Tasks 5-8; if a gap appears, it is a real defect to fix in the owning module, not a test workaround). +- [ ] **Step 3: PASS all.** +- [ ] **Step 4: Commit** `git commit -m "test(ssh-relay): relay integration matrix, replay inertness, reverse verification"`. + +## Task 10: Grants + first-use approval + audit events + +**Files:** +- Create: `db/migrations/0050-ssh-grants.ts`, `db/types/ssh-grants.db-types.ts`, `db/repositories/ssh-grants.repository.ts`, `services/ssh-grants.service.ts` +- Create: `api/ssh/grants.route.ts`, `api/ssh/approvals.route.ts`; modify `api/ssh/index.ts` +- Modify: `services/notify.service.ts` (a `grant_approval` owner-addressed kind), a pending-expiry sweep hook +- Modify: `docs/security.md` §10 event list +- Test: `apps/server/api/src/services/__tests__/ssh-grants.service.test.ts`, `api/ssh/__tests__/grants-route.test.ts` + +**Interfaces:** +- Produces: `ssh_key_grants` (id, owner, name, keyHomeNodeId A, resolved selector, `fingerprints` JSON, createdVia); `ssh_grant_requests` (durable pending row + expiry sweep); `requestFirstUse`, `approveGrant`, `denyGrant`, `revokeGrant`, `matchGrant(owner, aNode, resolvedHost)`. `SSH_MAX_GRANT_FINGERPRINTS` overflow is a red error. + +- [ ] **Step 1: Failing service test.** First use creates a `ssh_grant_requests` row and notifies; approve writes the grant with the chosen fingerprints and pins; second matching use is silent; deny writes the audit row only; expiry writes nothing; a `ssh_grant_requests` row survives a simulated restart and is swept at expiry; more-than-cap selection refuses. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement migration + repo + service + routes** (cookie + bearer-follows-ownership like the sibling ssh routes; owner-scoped; audit `node.ssh_grant.request|create|update|delete|approve|deny`). B's launch calls `requestFirstUse` and fails fast with the named refusal. +- [ ] **Step 4: notify kind.** Add `grant_approval` to `NotifyKind` + body/urgency maps, and an owner-addressed send path (the existing `notifyDevices` is subshell-row-shaped; add a sibling that takes the owner + a message, reusing the device-token machinery). It writes no secret. +- [ ] **Step 5: PASS** + `docs/security.md` §10 lists every new event. +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): key grants with first-use approval, durable pending row, audits"`. + +## Task 11: `ssh_agent_identities` roster command + +**Files:** +- Modify: `ssh-frames.ts`/`ssh-results.ts`, `node-frames.ts`; `apps/node/agent/src/commands/ssh-identity.ts`; `services/ssh-grants.service.ts` (approval screen feeds from it) +- Test: `apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts` + +- [ ] **Step 1: Failing test.** The plane sends `ssh_agent_identities` to A, gets `{ identities: [{ fingerprint, comment }] }` (blobs withheld), writes no audit row, and fails closed (returns an error, keeps the request pending) when A is offline. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the command arm + a handler that enumerates A's live agent public identities as `SHA256:` (agent-wire) + comment; the approvals route calls it to populate the operator's choice. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): ssh_agent_identities enumerates A's public roster for grant approval"`. + +## Task 12: Host-key pins (capture, render, block, display) + +**Files:** +- Create: `services/ssh-host-pins.service.ts` +- Modify: `packages/pane-runtime/src/ssh/ssh-render.ts`, `apps/node/agent/src/commands/ssh-relay.ts` (B render path), `api/ssh/trust.route.ts` (create) +- Test: `ssh-host-pins.service.test.ts`, `ssh-render.test.ts` (extend), `node-frames` unaffected + +**Interfaces:** +- Produces: `captureHostPin(user@host:port, aKnownHostsOrPin)`, `hostPinFor(resolved)`, `deleteHostPin(resolved)`; render adds `UserKnownHostsFile ` + `StrictHostKeyChecking yes` in relay mode only. + +- [ ] **Step 1: Failing test.** Grant creation captures A's host key as `ssh_host_pins` for `user@host:port`; relay render emits the pinned `UserKnownHostsFile` and `yes`; a changed D key refuses naming D; delete + fresh grant TOFU recovers. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Capture at approve from A's `~/.ssh/known_hosts` (via a relay resolve) or an explicit pin; extend `ssh-render.ts` with a relay-mode branch (leave the M1 `accept-new` path untouched); refuse on mismatch; audit `node.ssh_host_pin.create|delete`. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): per-destination host-key pins, pinned render, changed-key block"`. + +## Task 13: Machine trust card + fingerprint carriage + mirror + dashboard + +**Files:** +- Modify: `apps/node/agent/src/commands/report.ts` (ready/runtime carries own + pinned-peer fingerprints), `services/nodes/node-view.ts`, `api/nodes/get-node.route.ts`, `rotate-node-key.route.ts` (clear signing slot); `apps/node/agent/src/dashboard/*` (local display); SPA `components/ssh/trust-card.tsx` +- Test: node-view gate test, dashboard test, `rotate-node-key.route.test.ts` (extend) + +**Interfaces:** +- Produces: runtime report gains `sshFingerprint: { own: {signing,encryption}, peers: [{nodeId, signing, encryption}] }`; mirrored onto `nodes.ssh_fingerprint` (owner/`edit` gate, never `view`/`local`), stale-marked offline; loopback dashboard shows own + pinned-peer fingerprints. + +- [ ] **Step 1: Failing tests.** A `view` grantee never sees the mirrored block; an offline agent node shows a stale-marked card; rotation clears the link pin + signing slot and the redial re-reports the same bytes (no block, no peer re-pair). +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** carriage + mirror + gating (mirror serializes under the same `nodeCanConfigure`/config-capable rule as `runtime`); dashboard render from the node-local store; SPA card on node detail (owner/`edit`). +- [ ] **Step 4: PASS** (server + agent + happy-dom web from `apps/server/web`). +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): machine trust card, mirrored fingerprints, rotation clearing, dashboard display"`. + +## Task 14: Destination upgrade "Set up Subshell here" + +**Files:** +- Create: `api/ssh/setup-here.route.ts`, `services/ssh-setup-here.service.ts` +- Modify: `apps/server/api/src/api/install-script.ts` (404 branch prints `--key `), `apps/node/agent/src/commands/ssh-relay.ts` (non-interactive exec + redaction), SPA pane action +- Test: `ssh-setup-here.service.test.ts`, `install-script.test.ts` (extend) + +**Interfaces:** +- Produces: `setupHere(paneId, ...)`: mint a setup key, run `ssh D ''` as a SEPARATE non-interactive connection (re-opening the relay in relay mode, requiring A online), parse only a status verb with a redaction rule, await the node `ready`. + +- [ ] **Step 1: Failing tests.** The rendered installer 404 branch contains `--key ` and no key value; a non-egressing D refuses with the named egress cause and the pane untouched; the relay-mode act re-opens a relay and refuses naming A-online when A is down; success is detected via the new node's ready event. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the exec (never typed into the PTY; redactor drops any `nsk_…` line), the installer line fix, egress refusal, `node.ssh_upgrade.run` audit (never the key). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment over the open session"`. + +## Task 15: SPA grants/approvals screens + nav + client + +**Files:** +- Modify: `apps/server/web/src/lib/ssh.ts` (+ types/calls), routes + nav; Create `components/ssh/grants-screen.tsx`, `pending-approvals.tsx` +- Test: `apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx`, `lib/__tests__/ssh.test.ts` + +**Interfaces:** Consumes the Eden-treaty client types (rebuild `@internal/backend-client` + `bunx turbo build` first). + +- [ ] **Step 1: Failing web tests** (run from `apps/server/web`): grant list/revoke render; an approval card shows requester/destination/roster choices; a >-cap selection shows the red error; the trust card renders owner/edit only. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the screens + copy (two sentences, role tokens, no em dash), wire nav, redact B's plane-rendered fingerprint label as "for display only." +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): grants, pending-approvals, and trust screens in the SPA"`. + +## Task 16: e2e crown jewel + boundary + PR + review loop + +**Files:** +- Create: `e2e/tests/23-ssh-relay.spec.ts`; modify the e2e sshd fixture wiring for a second machine A +- Add: `.changeset/ssh-relay-milestone-2.md` + +- [ ] **Step 1: Write the e2e.** A holds the key in its agent, B holds none, B authenticates to D through the plane relay; a plane-side wire capture shows only opaque envelopes + routing ids; the key never appears on B or the plane; a revoke between handshake and D is refused; "Set up Subshell here" enrolls an egressing D without the setup key in B's log. +- [ ] **Step 2: Run it** (`bun run test:e2e` path for this spec, serial regime, `env -u SHELLOPTS -u BASHOPTS`), fix until green (rebuild `@internal/server-web` + `bunx turbo build` first; e2e serves web dist from disk). +- [ ] **Step 3: Boundary verification.** `bunx turbo build`, `bun run verify-types`, `bun run lint:check`, `bun run lint:prose`, `bun run test` all green; add the changeset (major for the branch, list every touched package). +- [ ] **Step 4: PR.** Branch off `feat/ssh-anywhere-3`; `gh pr create` with a body linking the spec and summarizing the six slices; DO NOT merge (operator directive); push via `ssh.github.com:443` if port 22 stalls. +- [ ] **Step 5: Review loop.** Dispatch the whole-branch `code-reviewer` (most capable model), quote-verify every finding, fix all MAJOR+MINOR, re-review until a pass is clean. +- [ ] **Step 6: Commit + push** the changeset and any review fixes; leave merge to the operator. + +--- + +## Self-review notes (author, after writing) + +- Spec coverage: §4 identity (T1-T3), §5 runtime (T4-T9), §6 grants/approval (T10-T11), §7 upgrade (T14), §9 data/audit (T1/T10/T12/T13/T14 + `docs/security.md`), §8 UI (T13/T15), §13 tests (each task's TDD + T9/T16). §4.6 detection-only posture needs no prevention code (dashboards only, T13). +- Type/name consistency across tasks: `signingPublicKey` slot (T1) used by T3/T13; `MachinePinStore.check` (T2) used by T5/T6/T7; `SeqGate` (T5) used by T6/T7/T8; `ssh_agent_identities` (T11) feeds T10 approvals; `SSH_MAX_GRANT_FINGERPRINTS` (Global) enforced T10, tested T10/T15. +- No placeholders: caps, version bump, migration numbers, file paths, and the non-obvious crypto/quoting rules are named. Routine handlers cite the file to copy (`set-ssh-enabled.ts`). From 8751accbe8ee2f9b2ae6aaf3825a68ffaedc3e37 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:32:53 -0700 Subject: [PATCH 082/170] docs(ssh-relay): plan pre-flight - correct agent request codes to 13/11 (default-deny) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index abfdc3f2e..67fb185b9 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (26) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type, refuse all else; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (11) by numeric type (confirm against the target OpenSSH's `agent-proto.h`), refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 7d40691be627babd65c6d931c3f3caa7cf23f661 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:43:29 -0700 Subject: [PATCH 083/170] feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll --- apps/node/agent/src/__tests__/enroll.test.ts | 22 ++- .../node/agent/src/__tests__/identity.test.ts | 52 +++++++- apps/node/agent/src/enroll.ts | 3 + apps/node/agent/src/identity.ts | 100 ++++++++++---- apps/node/agent/src/index.ts | 2 +- apps/server/api/src/api/identities.route.ts | 4 + .../api/nodes/__tests__/enroll-route.test.ts | 94 ++++++++++++- apps/server/api/src/api/nodes/enroll.route.ts | 52 +++++++- apps/server/api/src/api/public-jwk.ts | 55 ++++++-- .../__tests__/backup-cli-subprocess.test.ts | 27 ++-- apps/server/api/src/db/migrate.ts | 5 + .../db/migrations/0049-ssh-relay-identity.ts | 26 ++++ .../__tests__/0049-ssh-relay-identity.test.ts | 126 ++++++++++++++++++ .../__tests__/channels.repository.test.ts | 24 +++- .../db/repositories/identities.repository.ts | 11 +- .../api/src/db/types/identities.db-types.ts | 8 ++ .../server/api/src/db/types/nodes.db-types.ts | 8 ++ .../api/src/services/backups/database.ts | 2 +- .../nodes/__tests__/remote-launcher.test.ts | 2 + .../api/src/services/nodes/reregister-node.ts | 11 ++ 20 files changed, 572 insertions(+), 62 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts diff --git a/apps/node/agent/src/__tests__/enroll.test.ts b/apps/node/agent/src/__tests__/enroll.test.ts index fe416f0d9..c53b75004 100644 --- a/apps/node/agent/src/__tests__/enroll.test.ts +++ b/apps/node/agent/src/__tests__/enroll.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; @@ -123,7 +123,17 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy // (measured: `-`/`_`, no padding) and its `from_base64` REFUSES the padded // standard spelling, so this is the alphabet that survives the round trip. expect(Object.keys(seen ?? {}).sort()).toEqual( - ["agentVersion", "arch", "encryptPublicKey", "hostname", "name", "os", "publicKey", "setupKey"].sort(), + [ + "agentVersion", + "arch", + "encryptPublicKey", + "hostname", + "name", + "os", + "publicKey", + "setupKey", + "signingPublicKey", + ].sort(), ); expect(String(seen?.encryptPublicKey)).toMatch(/^[A-Za-z0-9_-]{43,44}$/); expect((["linux", "darwin", "unknown"] as unknown[]).includes(seen?.os)).toBe(true); @@ -135,6 +145,14 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy const pub = JSON.parse(String(seen?.publicKey)); expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); expect("d" in pub).toBe(false); + // The relay signing key posted (spec 2026-10-08 §4.2) is the PUBLIC half of + // the persisted sibling file: the private half never leaves the machine, + // and what rides the wire is exactly the string identity generation stored. + const signing = JSON.parse(String(seen?.signingPublicKey)); + expect(signing).toMatchObject({ kty: "EC", crv: "P-256" }); + expect("d" in signing).toBe(false); + const signingFile = JSON.parse(readFileSync(join(dataDir, "node-signing-identity.json"), "utf8")); + expect(seen?.signingPublicKey).toBe(signingFile.publicJwk); const cfg = await loadConfig(); // The link pair is FRESH per enroll, so it is asserted by pairing rather diff --git a/apps/node/agent/src/__tests__/identity.test.ts b/apps/node/agent/src/__tests__/identity.test.ts index 04427d8b3..2fbcf5eef 100644 --- a/apps/node/agent/src/__tests__/identity.test.ts +++ b/apps/node/agent/src/__tests__/identity.test.ts @@ -11,7 +11,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { identityPath, loadOrCreateIdentity } from "../identity.js"; +import { importJWK } from "jose"; +import { identityPath, loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; function freshDir(): string { return mkdtempSync(join(tmpdir(), "subshell-id-")); @@ -72,3 +73,52 @@ test("valid JSON that is not a usable identity object routes to quarantine too", await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); expect(readdirSync(dir).some((f) => f.endsWith(".corrupt-parse"))).toBe(true); }); + +// ── The M2 relay signing keypair (spec 2026-10-08 §4.1): an ES256 sibling of +// the ECDH pair with the SAME persistence discipline - 0600 file, ENOENT-only +// regeneration, private half never leaves the machine. + +test("first load also mints an ES256 signing keypair at 0600 in the sibling file", async () => { + const dir = freshDir(); + const id = await loadOrCreateIdentity(dir); + const pub = JSON.parse(id.signingPublicJwk); + expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); + expect(typeof pub.x).toBe("string"); + expect(typeof pub.y).toBe("string"); + expect("d" in pub).toBe(false); + expect(typeof JSON.parse(id.signingPrivateJwk).d).toBe("string"); + // A real curve point, importable for ES256 verification; not a curve point + // is exactly what the enroll route will refuse on this string. + expect(await importJWK(pub, "ES256")).toBeDefined(); + // Sibling file, not the same file, and a genuinely different key. + expect(signingIdentityPath(dir)).not.toBe(identityPath(dir)); + expect(id.signingPublicJwk).not.toBe(id.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("second load reuses the exact same signing key (persisted, no silent rotation)", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + const second = await loadOrCreateIdentity(dir); + expect(second.signingPublicJwk).toBe(first.signingPublicJwk); + expect(second.signingPrivateJwk).toBe(first.signingPrivateJwk); +}); + +test("corrupt signing file quarantines only itself; the ECDH identity survives", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + writeFileSync(signingIdentityPath(dir), "{ definitely not json"); + await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); + // The signing file moved aside; the encryption identity is untouched. + expect(existsSync(signingIdentityPath(dir))).toBe(false); + expect(existsSync(identityPath(dir))).toBe(true); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(quarantined[0]).toInclude("node-signing-identity"); + // Only after quarantine may a fresh signing key be minted, and the ECDH + // pair rides through unchanged - one file's corruption rotates one key. + const next = await loadOrCreateIdentity(dir); + expect(next.signingPublicJwk).not.toBe(first.signingPublicJwk); + expect(next.publicJwk).toBe(first.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); diff --git a/apps/node/agent/src/enroll.ts b/apps/node/agent/src/enroll.ts index ebb874683..80693caff 100644 --- a/apps/node/agent/src/enroll.ts +++ b/apps/node/agent/src/enroll.ts @@ -113,6 +113,9 @@ export async function runEnroll(opts: EnrollOptions): Promise { hostname: hostname(), agentVersion: NODE_VERSION, publicKey: identity.publicJwk, + // The ES256 relay signing half (spec 2026-10-08 §4.2): the public JWK + // only, registered beside the encryption key in the same node: record. + signingPublicKey: identity.signingPublicJwk, encryptPublicKey: link.publicKey, }; diff --git a/apps/node/agent/src/identity.ts b/apps/node/agent/src/identity.ts index e87dbf143..c6b13e52f 100644 --- a/apps/node/agent/src/identity.ts +++ b/apps/node/agent/src/identity.ts @@ -1,34 +1,68 @@ /** - * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12) — the same - * fail-closed rules and quarantine wording, simplified to ONE keypair per data - * dir (a node has no principal-per-file; it IS the principal). Deliberately a - * copy: the dependency direction is agent → packages, never agent → backend. + * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12): the same + * fail-closed rules and quarantine wording, simplified to one keypair FILE per + * role (a node has no principal-per-file; it IS the principal). Deliberately a + * copy: the dependency direction is agent -> packages, never agent -> backend. + * + * Two keypairs, one file each, from protocol 18 / spec 2026-10-08 §4.1: the + * ECDH-ES encryption pair in `identity.json` (sealed delivery) and the ES256 + * signing pair in the sibling `node-signing-identity.json` (SSH relay payloads, + * M2). The signing pair is generated at the same moment and posture as the + * encryption pair and follows the exact same discipline below. * * Fail-closed: ONLY a genuinely missing file (ENOENT) generates a fresh key. * Any other read/parse failure on a PRESENT file means key material exists we - * cannot read — the file is moved aside (best-effort) and the call throws, + * cannot read - the file is moved aside (best-effort) and the call throws, * never silently rotating over the old key and orphaning sealed history. */ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { exportJWK, generateKeyPair } from "jose"; -/** The node's persisted keypair (JWKs as JSON strings, mirroring the backend store). */ +/** + * The node's persisted keypairs. JWKs as JSON strings, mirroring the backend + * store; each pair lives in its own file, so one file's corruption can only + * ever rotate its own key. + */ export interface NodeIdentity { - /** Public JWK JSON (P-256 / ECDH-ES) — sent at enroll, registered for sealed delivery. */ + /** Public JWK JSON (P-256 / ECDH-ES) - sent at enroll, registered for sealed delivery. */ + publicJwk: string; + /** Private JWK JSON - never leaves this file. */ + privateJwk: string; + /** + * Public JWK JSON (P-256 / ES256) - the machine's signing identity for the + * SSH agent relay (spec 2026-10-08 §4.1); sent at enroll, registered beside + * the encryption key in the same `node:` identities record (§4.2). + */ + signingPublicJwk: string; + /** Signing private JWK JSON - never leaves this machine; signs relay payloads only. */ + signingPrivateJwk: string; +} + +/** The on-disk shape of one keypair file; both files share it. */ +interface KeypairFile { publicJwk: string; - /** Private JWK JSON — never leaves this file. */ privateJwk: string; } -/** Absolute path of the identity file inside a data dir. */ +/** Absolute path of the encryption identity file inside a data dir. */ export function identityPath(dataDir: string): string { return join(dataDir, "identity.json"); } -/** Loads the node's keypair, generating and persisting one on first run. */ +/** Absolute path of the ES256 signing identity file inside a data dir. */ +export function signingIdentityPath(dataDir: string): string { + return join(dataDir, "node-signing-identity.json"); +} + +/** + * Loads the node's keypairs (encryption + signing), generating and persisting + * any missing one on first run. An existing ECDH file is never rewritten by + * this call, so a pre-M2 node upgrades into a fresh signing pair without its + * encryption identity moving. + */ export async function loadOrCreateIdentity(dataDir: string): Promise { - // The dir will hold the private key material, so a dir WE create gets 0700 — + // The dir will hold the private key material, so a dir WE create gets 0700 - // unconditionally chmod'd, because mkdir's `mode` option is masked by the // umask and cannot guarantee it. A dir that already exists is left alone: // whoever created it chose its mode (shared mount, pre-seeded permissions, @@ -37,15 +71,30 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { try { const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - // Valid JSON that is not a usable identity is corruption of a PRESENT file, - // not a first run — the SyntaxError routes it into quarantine below. - if (!isIdentity(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); + // Valid JSON that is not a usable keypair is corruption of a PRESENT file, + // not a first run - the SyntaxError routes it into quarantine below. + if (!isKeypairFile(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); return parsed; } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file); + if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file, alg); const reason = err instanceof SyntaxError ? "parse" : ((err as NodeJS.ErrnoException).code ?? "read"); let aside = `${file}.corrupt-${reason}`; if (existsSync(aside)) aside = `${aside}-${Date.now()}`; @@ -63,23 +112,24 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { +async function writeFresh(file: string, alg: "ECDH-ES" | "ES256"): Promise { // Bun's crypto.subtle lacks ECDH generateKey; jose falls back to node:crypto - // internally (same path @internal/mcp-core's crypto.ts documents). - const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); - const identity: NodeIdentity = { + // internally (same path @internal/mcp-core's crypto.ts documents). ES256 + // rides the same generator - both are P-256, the algorithm is the purpose. + const { publicKey, privateKey } = await generateKeyPair(alg, { crv: "P-256", extractable: true }); + const keypair: KeypairFile = { publicJwk: JSON.stringify(await exportJWK(publicKey)), privateJwk: JSON.stringify(await exportJWK(privateKey)), }; - writeFileSync(file, JSON.stringify(identity, null, 2), { mode: 0o600 }); - return identity; + writeFileSync(file, JSON.stringify(keypair, null, 2), { mode: 0o600 }); + return keypair; } diff --git a/apps/node/agent/src/index.ts b/apps/node/agent/src/index.ts index 0ac2f878a..7b512bdcd 100644 --- a/apps/node/agent/src/index.ts +++ b/apps/node/agent/src/index.ts @@ -17,7 +17,7 @@ export { wsUrlFor, } from "./daemon.js"; export { type EnrollOptions, type EnrollResult, mapOs, type NodeOs, runEnroll } from "./enroll.js"; -export { identityPath, loadOrCreateIdentity, type NodeIdentity } from "./identity.js"; +export { identityPath, loadOrCreateIdentity, type NodeIdentity, signingIdentityPath } from "./identity.js"; export { buildInventoryEvent, type InventoryEvent } from "./inventory.js"; export { clearLock, type DaemonLock, isPidAlive, lockPath, readLock, writeLock } from "./lock.js"; export { runNodeMcp } from "./mcp/main.js"; diff --git a/apps/server/api/src/api/identities.route.ts b/apps/server/api/src/api/identities.route.ts index 0a655dd56..b5e656270 100644 --- a/apps/server/api/src/api/identities.route.ts +++ b/apps/server/api/src/api/identities.route.ts @@ -42,9 +42,13 @@ export const identityRoutes = new Elysia({ prefix: "/api/identities" }) // Must be a P-256 PUBLIC key jose can IMPORT — a merely parseable // object would poison seal() for every co-member (see public-jwk.ts). await assertImportablePublicJwk(parsed); + // Pane/user principals have no relay signing identity: the slot is + // stated null so the machine-only column (spec 2026-10-08 §4.2) is + // never filled through this door. return await new IdentitiesRepository(db).register({ principalId: principal, publicKey: body.publicKey, + signingPublicKey: null, displayName: body.displayName ?? null, }); }, diff --git a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts index f91c99709..092b34451 100644 --- a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts @@ -28,6 +28,15 @@ async function jwkString(withD = false): Promise { return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); } +/** + * Fresh ES256 signing keypair as a JWK string (the M2 relay identity, spec + * 2026-10-08 §4.1); `withD` exports the PRIVATE half, which enroll must refuse. + */ +async function signingJwkString(withD = false): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); +} + /** * Snapshot of the constants module taken before any `mock.module` swap, so the * wsUrl subpath test can restore the real values for the suites that share this @@ -53,6 +62,7 @@ describe("/api/nodes/enroll", () => { const createdNodeIds: string[] = []; const createdSetupKeyIds: string[] = []; let goodJwk: string; + let goodSigningJwk: string; beforeAll(async () => { await setupAuthTables(); @@ -64,6 +74,7 @@ describe("/api/nodes/enroll", () => { }); await signIn(aliceEmail, pw); // exercise the real auth stack; enroll itself needs no cookie goodJwk = await jwkString(); + goodSigningJwk = await signingJwkString(); }); afterAll(async () => { @@ -105,6 +116,10 @@ describe("/api/nodes/enroll", () => { hostname: "host-a", agentVersion: "0.1.0", publicKey: goodJwk, + // The M2 relay signing key (spec 2026-10-08 §4.2): a post-M2 agent + // carries it; the pre-M2 posture is `signingPublicKey: undefined`, + // which JSON.stringify drops from the body. + signingPublicKey: goodSigningJwk, ...over, }; } @@ -129,9 +144,11 @@ describe("/api/nodes/enroll", () => { expect(node?.agentVersion).toBe("0.1.0"); expect(node?.apiKeyId).toBeTruthy(); - // The identity row carries the exact validated JWK string under node:. + // The identity row carries the exact validated JWK string under node:, + // and the M2 signing slot (spec 2026-10-08 §4.2) rides the SAME record. const identity = await identities.findByPrincipal(`node:${body.nodeId}`); expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); expect(identity?.displayName).toBe("mini-one"); // The node key is a real `subshell_` bearer; the setup key cannot redeem again. @@ -194,6 +211,7 @@ describe("/api/nodes/enroll", () => { timer: setTimeout(() => {}, 30000), }); const freshJwk = await jwkString(); + const freshSigningJwk = await signingJwkString(); const link = await generateLinkKeyPair(); const freshEncryptPublicKey = link.publicKey; const res = await enroll( @@ -201,6 +219,7 @@ describe("/api/nodes/enroll", () => { name: "ignored replacement name", hostname: "replacement-host", publicKey: freshJwk, + signingPublicKey: freshSigningJwk, encryptPublicKey: freshEncryptPublicKey, }), ); @@ -222,7 +241,11 @@ describe("/api/nodes/enroll", () => { expect(current.hostname).toBe("replacement-host"); expect(current.publicKey).toBe(freshJwk); expect(current.encryptPublicKey).toBe(freshEncryptPublicKey); - expect((await identities.findByPrincipal(`node:${original.nodeId}`))?.publicKey).toBe(freshJwk); + // Both identity halves swap together: re-registration replaces the whole + // machine identity, signing slot included (spec 2026-10-08 §4.2). + const recoveredIdentity = await identities.findByPrincipal(`node:${original.nodeId}`); + expect(recoveredIdentity?.publicKey).toBe(freshJwk); + expect(recoveredIdentity?.signingPublicKey).toBe(freshSigningJwk); expect(await db.selectFrom("nodeShares").selectAll().where("nodeId", "=", original.nodeId).execute()).toHaveLength( 1, ); @@ -495,6 +518,73 @@ describe("/api/nodes/enroll", () => { expect(await repo.peekValid(setupKey)).toBe(true); }); + // ── Relay signing identity (spec 2026-10-08 §4.1/§4.2): enroll carries the + // machine's ES256 public key into the SAME `node:` identity record, and a + // bad one burns nothing. + + it("a valid signingPublicKey registers beside the encryption key", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "relay-id" })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); + // The relay half lives only in the identities record, never on the node row. + expect(await nodes.findById(nodeId)).not.toHaveProperty("signingPublicKey"); + }); + + it("an absent signingPublicKey (pre-M2 agent) still enrolls and registers NULL", async () => { + // The field is optional so an old binary enrolls unchanged; §4.3's + // bootstrap delivers the signing key over the node link later. Until then + // the slot is NULL, exactly the shape every pre-M2 row carries. + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "pre-m2-one", signingPublicKey: undefined })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBeNull(); + }); + + it("malformed signingPublicKey fails BEFORE consume — key stays redeemable", async () => { + // THE ordering test for the new field (same position the publicKey checks + // hold): a body that cannot be stored must not spend the single-use key. + // The fixture stays above the schema's minLength so it genuinely reaches + // the route's own decode branches. + const setupKey = await makeKey(); + + const notJson = await enroll(bodyFor(setupKey, { signingPublicKey: "this-is-not-json-at-all" })); + expect(notJson.status).toBe(400); + expect(((await notJson.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // Well-formed, right shape, coordinates off the curve: only the real + // import proves a curve point (the publicKey discipline, measured). + const offCurve = await enroll( + bodyFor(setupKey, { + signingPublicKey: JSON.stringify({ kty: "EC", crv: "P-256", x: "A".repeat(43), y: "B".repeat(43) }), + }), + ); + expect(offCurve.status).toBe(400); + expect(((await offCurve.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // The very same key still redeems with a good signing JWK. + const ok = await enroll(bodyFor(setupKey)); + expect(ok.status).toBe(201); + createdNodeIds.push(((await ok.json()) as { nodeId: string }).nodeId); + }); + + it("private signing JWK (carries `d`) → 400 + key unconsumed", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { signingPublicKey: await signingJwkString(true) })); + expect(res.status).toBe(400); + expect(((await res.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + }); + it("os outside linux|darwin|unknown → 400 + key unconsumed", async () => { const setupKey = await makeKey(); const res = await enroll(bodyFor(setupKey, { os: "windows" })); diff --git a/apps/server/api/src/api/nodes/enroll.route.ts b/apps/server/api/src/api/nodes/enroll.route.ts index f286c5345..e1013c1c6 100644 --- a/apps/server/api/src/api/nodes/enroll.route.ts +++ b/apps/server/api/src/api/nodes/enroll.route.ts @@ -3,7 +3,7 @@ import { NODE_NAME_MAX_UNITS, normalizeNodeName } from "@internal/subshell-proto import { ensureSodium } from "@internal/subshell-protocol/node-link-crypto"; import { Elysia, t } from "elysia"; import { HttpError } from "@/api/auth-guard.js"; -import { assertImportablePublicJwk } from "@/api/public-jwk.js"; +import { assertImportablePublicJwk, assertImportableSigningJwk } from "@/api/public-jwk.js"; import type { CreatedApiKey, NodeKeyMetadata } from "@/auth/apikey-store.js"; import { getAuth } from "@/auth.js"; import { APP_BASE_URL } from "@/constants.js"; @@ -50,6 +50,18 @@ const EnrollBodySchema = t.Object({ maxLength: 2048, description: "JSON-serialized P-256 ECDH-ES PUBLIC JWK (no private component) for sealed delivery to this node", }), + // Optional so a pre-M2 agent still enrolls (spec 2026-10-08 §4.3): the + // identities slot stays NULL until the §4.3 bootstrap delivers the key over + // the node link. A PRESENT value is decoded and imported below (like + // `publicKey` above), BEFORE the setup key is spent. + signingPublicKey: t.Optional( + t.String({ + minLength: 16, + maxLength: 2048, + description: + "JSON-serialized P-256 ES256 PUBLIC JWK (no private component): the machine's SSH relay signing key, registered in the same node: identity record", + }), + ), // Optional so a pre-v14 one-liner still enrolls (spec 2026-09-24 §5): the row // then stores NULL and the node registers its static on first connect. A // PRESENT value is decoded and length-checked below — like `publicKey` above, @@ -201,6 +213,38 @@ export const enrollRoute = new Elysia().use(apiModels).post( ); } + // The relay signing key (spec 2026-10-08 §4.1/§4.2), validated in the SAME + // pre-consume position as publicKey above: a malformed value must not burn + // the single-use setup key. An absent field is the pre-M2 posture (NULL + // slot, filled later by the §4.3 bootstrap), never a refusal. + let signingPublicKey: string | null = null; + if (body.signingPublicKey !== undefined) { + let signingParsed: unknown; + try { + signingParsed = JSON.parse(body.signingPublicKey); + } catch { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: "signingPublicKey must be a JSON JWK", + }), + ); + } + try { + await assertImportableSigningJwk(signingParsed); + } catch (err) { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: err instanceof HttpError ? err.message : "signingPublicKey must be a valid P-256 public JWK", + }), + ); + } + signingPublicKey = body.signingPublicKey; + } + // The link-encryption static (spec 2026-09-24 §3), validated in the SAME // pre-consume position as publicKey above — a malformed body must not burn // the single-use key. Shape is schema-checked (43-44 chars); the decode @@ -269,6 +313,9 @@ export const enrollRoute = new Elysia().use(apiModels).post( agentVersion: body.agentVersion, }, encryptPublicKey, + // Both identity halves swap together on re-registration (§4.2); the + // validated value, or NULL for a pre-M2 body. + signingPublicKey, ); return status(201, { ...result, controlPublicKey, controlEncryptPublicKey, wsUrl }); } catch (err) { @@ -304,9 +351,12 @@ export const enrollRoute = new Elysia().use(apiModels).post( encryptPublicKey, }); nodeRowCreated = true; + // The signing key lands in the SAME node: record (§4.2): enroll is the + // trust event the key inherits, and node deletion cascades both halves. await new IdentitiesRepository(db).register({ principalId: `node:${nodeId}`, publicKey: body.publicKey, + signingPublicKey, displayName: name, }); // Node key: long-lived (no expiresIn — revocation is delete-node, diff --git a/apps/server/api/src/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index f0bcfe32b..accc298b0 100644 --- a/apps/server/api/src/api/public-jwk.ts +++ b/apps/server/api/src/api/public-jwk.ts @@ -7,19 +7,14 @@ type JwkInput = Parameters[0]; /** Base64url charset; coordinate LENGTH is proven by the real import below. */ const BASE64URL = /^[A-Za-z0-9_-]+$/; -const INVALID = "publicKey must be a valid P-256 public JWK"; - /** - * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually - * import for ECDH-ES — the same operation `seal()` performs for every roster - * key at post time. Registration only ever saw "parses as a JSON object", so - * a garbage key that passed would make EVERY later `post_channel` throw - * for EVERY member of every channel the registrant joined (channel-wide DoS). - * Structural checks are not enough either: only the import round-trip catches - * coordinates that are well-formed base64url but not a curve point. - * @throws HttpError 400 with a single generic message on any failure. + * Shared shape gate for both P-256 public-key registrations: an EC/P-256 + * object with base64url coordinates and NO private component - a public-key + * store that accepted `d` would be storing a private key it was handed. The + * refusal names the caller's field and nothing else (one generic message, so + * the answer cannot be probed for WHY the key was refused). */ -export async function assertImportablePublicJwk(parsed: unknown): Promise { +function assertP256PublicJwkShape(parsed: unknown, label: string): Record { const jwk = parsed as Record | null; const structurallyOk = typeof parsed === "object" && @@ -35,10 +30,44 @@ export async function assertImportablePublicJwk(parsed: unknown): Promise BASE64URL.test(jwk.y) && // Public-key store: the private component must never be registered. !("d" in jwk); - if (!structurallyOk) throw new HttpError(400, INVALID); + if (!structurallyOk) throw new HttpError(400, `${label} must be a valid P-256 public JWK`); + return jwk; +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually + * import for ECDH-ES — the same operation `seal()` performs for every roster + * key at post time. Registration only ever saw "parses as a JSON object", so + * a garbage key that passed would make EVERY later `post_channel` throw + * for EVERY member of every channel the registrant joined (channel-wide DoS). + * Structural checks are not enough either: only the import round-trip catches + * coordinates that are well-formed base64url but not a curve point. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportablePublicJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "publicKey"); try { await importJWK(jwk as unknown as JwkInput, "ECDH-ES"); } catch { - throw new HttpError(400, INVALID); + throw new HttpError(400, "publicKey must be a valid P-256 public JWK"); + } +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can import for + * ES256 verification - the operation the SSH relay performs with a machine's + * signing key (spec 2026-10-08 §4.1). Same discipline as + * {@link assertImportablePublicJwk}: shape first, then the real import, which + * is the only check that refuses well-formed-looking coordinates that are not + * a curve point. A garbage registration here would poison every later + * signature check against this machine, so nothing that fails it is stored. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportableSigningJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "signingPublicKey"); + try { + await importJWK(jwk as unknown as JwkInput, "ES256"); + } catch { + throw new HttpError(400, "signingPublicKey must be a valid P-256 public JWK"); } } diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 6acba4c3c..27bf37c74 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0046+0047+0048 together: this era's three migrations are the tail of - // the registered ledger, so the prefix runs contiguously through 0045 and - // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0048) means + // WHY 0047+0048+0049 together: this era's three migrations are the tail of + // the registered ledger, so the prefix runs contiguously through 0046 and + // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0049) means // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity')", ) .all(), ).toEqual([]); @@ -430,18 +430,15 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", ) .all(), ).toEqual([ - { name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }, { name: "0048-ssh-launch-and-saved-hosts" }, + { name: "0049-ssh-relay-identity" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); - expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ - name: "backup_recovery", - }); expect( migrated .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") @@ -453,6 +450,12 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ name: "ssh", }); + expect( + migrated.query("SELECT name FROM pragma_table_info('identities') WHERE name='signing_public_key'").get(), + ).toEqual({ name: "signing_public_key" }); + expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_fingerprint'").get()).toEqual({ + name: "ssh_fingerprint", + }); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index e47050718..415466e44 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -48,6 +48,7 @@ import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregist import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; /** * Runs all pending Kysely migrations against the app database. @@ -140,6 +141,10 @@ export async function runMigrations(): Promise { // The SSH launcher tier: the per-owner destination ledger and the // pane's approved snapshot in `subshells.ssh` (spec 2026-10-07 §7). "0048-ssh-launch-and-saved-hosts": sshLaunchMigration, + // The relay identity tier: the machine's ES256 signing slot in the + // identities record and the trust-card fingerprint mirror on the + // nodes row (spec 2026-10-08 §4, §9). + "0049-ssh-relay-identity": sshRelayIdentityMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts new file mode 100644 index 000000000..5418ccc88 --- /dev/null +++ b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts @@ -0,0 +1,26 @@ +import type { Kysely } from "kysely"; + +/** + * The relay identity tier (spec 2026-10-08 §4.2, §4.5, §9). + * + * `identities.signing_public_key` is the per-machine ES256 signing slot that + * rides beside the ECDH-ES encryption key in the SAME `node:` record: enroll + * fills it, deletion cascades it exactly as the encryption key already does. + * `nodes.ssh_fingerprint` is the durable mirror of the last runtime-report + * ssh-fingerprint block, so the trust card survives an offline node or a + * plane restart; it is populated by the report-handling task, not this one. + * + * Both columns are NULL for every pre-M2 row: the signing slot is filled by a + * re-enroll or the `ssh_register_identity` bootstrap (§4.3), and the mirror by + * the next runtime report (§4.5). An upgrade therefore changes nothing any + * existing behavior reads. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("identities").addColumn("signing_public_key", "text").execute(); + await db.schema.alterTable("nodes").addColumn("ssh_fingerprint", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_fingerprint").execute(); + await db.schema.alterTable("identities").dropColumn("signing_public_key").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts new file mode 100644 index 000000000..2825996f8 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as channelsMigration from "@/db/migrations/0009-channels.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as relayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + identities: Record; + nodes: Record; +} + +/** + * The relay identity tier's storage (spec 2026-10-08 §4.2, §4.5, §9): + * `identities.signing_public_key`, the machine's ES256 signing slot beside the + * ECDH-ES encryption key, and `nodes.ssh_fingerprint`, the durable mirror of + * the runtime fingerprint block so the trust card survives an offline node. + * Both are nullable by design: a pre-M2 row simply carries nothing yet. + */ +describe("migration 0049-ssh-relay-identity", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0049-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target, minimally shaped (same posture as the 0048 test); 0009 + // additionally adds a column to the `sessions` table 0001 creates. + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await initMigration.up(db); + await channelsMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case: a pre-M2 principal + // and a pre-M2 node row both read NULL for both new slots. + await db + .insertInto("identities") + .values({ + principal_id: "node:pre-m2", + public_key: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + display_name: "old box", + }) + .execute(); + await db + .insertInto("nodes") + .values({ + id: "node-pre-m2", + owner_user_id: "u1", + name: "old box", + kind: "agent", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }) + .execute(); + await relayIdentityMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds signing_public_key to identities; a pre-M2 registration reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("signing_public_key"); + const r = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(r.rows[0].signing_public_key).toBeNull(); + // The signing slot stores the registered JWK text and reads back; the + // encryption key beside it is untouched (same record, §4.2). + const signing = '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}'; + await sql`UPDATE identities SET signing_public_key = ${signing} WHERE principal_id = 'node:pre-m2'`.execute(db); + const back = await sql<{ signing_public_key: string; public_key: string }>` + SELECT signing_public_key, public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(back.rows[0].signing_public_key).toBe(signing); + expect(back.rows[0].public_key).toBe('{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}'); + }); + + it("adds ssh_fingerprint to nodes; a pre-M2 row reads NULL and a mirror round-trips", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh_fingerprint"); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + // The mirror block is JSON text (the §4.5 trust card data), written by a + // later task when the agent reports. + const block = JSON.stringify({ signing: "SHA256:aaa", encryption: "SHA256:bbb", reportedAt: "now" }); + await sql`UPDATE nodes SET ssh_fingerprint = ${block} WHERE id = 'node-pre-m2'`.execute(db); + const back = await sql<{ ssh_fingerprint: string }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(back.rows[0].ssh_fingerprint).toBe(block); + }); + + it("neither column is NOT NULL: a fresh insert without them succeeds", async () => { + await db + .insertInto("nodes") + .values({ + id: "node-post-m2", + owner_user_id: "u1", + name: "new box", + kind: "agent", + created_at: "2026-10-08T00:00:00.000Z", + updated_at: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-post-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + }); + + it("down drops both columns and leaves the rows", async () => { + await relayIdentityMigration.down(db); + const identityCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(identityCols.rows.map((c) => c.name)).not.toContain("signing_public_key"); + const nodeCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(nodeCols.rows.map((c) => c.name)).not.toContain("ssh_fingerprint"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["node-post-m2", "node-pre-m2"]); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts index 658c70a0e..1b2e95b42 100644 --- a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts @@ -65,11 +65,29 @@ describe("IdentitiesRepository", () => { beforeEach(wipe); it("registers and upserts (rotate) by principal", async () => { - const first = await repo.register({ principalId: "sess:a", publicKey: "JWK-1", displayName: "Alpha" }); + const first = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-1", + signingPublicKey: null, + displayName: "Alpha", + }); expect(first.publicKey).toBe("JWK-1"); - const rotated = await repo.register({ principalId: "sess:a", publicKey: "JWK-2", displayName: null }); + expect(first.signingPublicKey).toBeNull(); + const rotated = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(rotated.publicKey).toBe("JWK-2"); - expect(await repo.findByPrincipal("sess:a")).toMatchObject({ publicKey: "JWK-2", displayName: null }); + // The signing slot rotates with the record in the one write (spec + // 2026-10-08 §4.2): an insert and an upsert both carry it. + expect(rotated.signingPublicKey).toBe("ES-JWK"); + expect(await repo.findByPrincipal("sess:a")).toMatchObject({ + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(await repo.findByPrincipal("sess:ghost")).toBeUndefined(); }); }); diff --git a/apps/server/api/src/db/repositories/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index 1779f874b..ab227773b 100644 --- a/apps/server/api/src/db/repositories/identities.repository.ts +++ b/apps/server/api/src/db/repositories/identities.repository.ts @@ -8,10 +8,17 @@ import type { IdentityTable } from "@/db/types/identities.db-types.js"; * unreadable to that principal by design (documented in the spec). */ export class IdentitiesRepository extends BaseRepository { - /** Inserts or rotates the principal's keypair registration. */ + /** + * Inserts or rotates the principal's keypair registration. The + * `signingPublicKey` slot (the machine's ES256 relay half, spec + * 2026-10-08 §4.2) rotates WITH the encryption key in the same record, so a + * caller always states it explicitly: null where the principal has no + * signing identity (panes, users) or the agent has not reported one. + */ async register(input: { principalId: string; publicKey: string; + signingPublicKey: string | null; displayName: string | null; }): Promise { await this.db @@ -19,12 +26,14 @@ export class IdentitiesRepository extends BaseRepository { .values({ principalId: input.principalId, publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }) .onConflict((oc) => oc.column("principalId").doUpdateSet({ publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }), diff --git a/apps/server/api/src/db/types/identities.db-types.ts b/apps/server/api/src/db/types/identities.db-types.ts index 3c7816890..fb4ff990f 100644 --- a/apps/server/api/src/db/types/identities.db-types.ts +++ b/apps/server/api/src/db/types/identities.db-types.ts @@ -8,6 +8,14 @@ export interface IdentityTable { principalId: string; /** JSON-serialized JWK (P-256 / ECDH-ES public key) */ publicKey: string; + /** + * JSON-serialized JWK (P-256 / ES256 public key): the machine's signing + * identity for the SSH agent relay (spec 2026-10-08 §4.2). Only `node:` + * principals ever carry one - enroll fills it and node deletion cascades + * it exactly as `publicKey` does; a null reads as "never reported" and is + * what every pre-M2 row and every pane/user holds. + */ + signingPublicKey: string | null; /** Convenience label (e.g. subshell name); not authoritative */ displayName: string | null; /** ISO 8601 timestamp of (re-)registration (DB default); re-registering rotates */ diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 80c215583..74400a0a8 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -101,6 +101,14 @@ export interface NodeTable { * maintenance. */ sshEnabledAt: string | null; + /** + * JSON mirror of the ssh-fingerprint block from this node's last runtime + * report (spec 2026-10-08 §4.5), so the trust card survives an offline node + * or a plane restart. NULL until a post-M2 agent reports. Serialized only + * under the same owner-or-`edit` gate as the live runtime field, never + * `view` and never `local`. Populated by the report-handling task. + */ + sshFingerprint: string | null; /** ISO 8601 creation time */ createdAt: string; /** ISO 8601 last update time */ diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 284d9d864..8b1e25885 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"; +export const LATEST_BACKUP_MIGRATION = "0049-ssh-relay-identity"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index 4b9a256d2..a8cb3413a 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -175,6 +175,8 @@ function nodeRow(over: Partial = {}): NodeTable { maintenanceSource: null, sshEnabled: 0, sshEnabledAt: null, + // The 0049 mirror slot: NULL until the runtime-report task fills it. + sshFingerprint: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", ...over, diff --git a/apps/server/api/src/services/nodes/reregister-node.ts b/apps/server/api/src/services/nodes/reregister-node.ts index e4fb95fa1..d09a82c6f 100644 --- a/apps/server/api/src/services/nodes/reregister-node.ts +++ b/apps/server/api/src/services/nodes/reregister-node.ts @@ -21,6 +21,14 @@ export async function reregisterNode( agentVersion: string; }, encryptPublicKey: string | null, + /** + * The ES256 relay signing half (spec 2026-10-08 §4.2): re-registration is a + * fresh enrollment-grade trust event, so it REPLACES the slot rather than + * preserving it. The machine's on-disk keypairs normally survive the + * re-registration, so the replacement is normally the same bytes; null is + * the pre-M2 posture until the §4.3 bootstrap reports. + */ + signingPublicKey: string | null, ): Promise<{ nodeId: string; nodeKey: string; name: string }> { const nodeId = key.targetNodeId; if (!nodeId) throw new Error("Re-registration needs an existing node"); @@ -59,9 +67,12 @@ export async function reregisterNode( .executeTakeFirst(); if (!updated) throw new Error("Node credentials changed; issue a new re-registration key and retry"); name = updated.name; + // Both identity halves rotate together, inside the same transaction that + // swaps the credentials (§4.2). await new IdentitiesRepository(tx).register({ principalId: `node:${nodeId}`, publicKey: machine.publicKey, + signingPublicKey, displayName: name, }); // Auth uses a separate handle to the SAME SQLite file. Revoke through From 0429c4adf297c4b7816bd2a1df10b9a292adb68d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:46:42 -0700 Subject: [PATCH 084/170] test+docs(ssh-relay): address Task 1 review (identities nullability test, protocol-18 wording, em dash) --- apps/node/agent/src/identity.ts | 2 +- apps/server/api/src/api/public-jwk.ts | 2 +- .../__tests__/0049-ssh-relay-identity.test.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/identity.ts b/apps/node/agent/src/identity.ts index c6b13e52f..720213e1b 100644 --- a/apps/node/agent/src/identity.ts +++ b/apps/node/agent/src/identity.ts @@ -4,7 +4,7 @@ * role (a node has no principal-per-file; it IS the principal). Deliberately a * copy: the dependency direction is agent -> packages, never agent -> backend. * - * Two keypairs, one file each, from protocol 18 / spec 2026-10-08 §4.1: the + * Two keypairs, one file each, for the protocol-18 SSH relay (spec 2026-10-08 §4.1): the * ECDH-ES encryption pair in `identity.json` (sealed delivery) and the ES256 * signing pair in the sibling `node-signing-identity.json` (SSH relay payloads, * M2). The signing pair is generated at the same moment and posture as the diff --git a/apps/server/api/src/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index accc298b0..8fc2421e6 100644 --- a/apps/server/api/src/api/public-jwk.ts +++ b/apps/server/api/src/api/public-jwk.ts @@ -36,7 +36,7 @@ function assertP256PublicJwkShape(parsed: unknown, label: string): Record { }); it("neither column is NOT NULL: a fresh insert without them succeeds", async () => { + // The identities side of the claim: a post-migration registration that + // OMITS the signing slot must land, with the column NULL, not fail NOT NULL. + await db + .insertInto("identities") + .values({ + principal_id: "node:fresh-null", + public_key: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + display_name: "fresh box", + }) + .execute(); + const ri = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:fresh-null' + `.execute(db); + expect(ri.rows[0].signing_public_key).toBeNull(); await db .insertInto("nodes") .values({ From ad6f1fb2fb1d451527730691b0fdb39a5397112f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:52:24 -0700 Subject: [PATCH 085/170] feat(ssh-relay): always-strict node machine pin store (both keys, no channel escape) --- .../src/__tests__/machine-pin-store.test.ts | 158 ++++++++++++++++ apps/node/agent/src/machine-pin-store.ts | 161 ++++++++++++++++ .../src/__tests__/ssh-pin-store.test.ts | 108 +++++++++++ packages/subshell-protocol/src/index.ts | 3 + .../subshell-protocol/src/ssh-pin-store.ts | 175 ++++++++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 apps/node/agent/src/__tests__/machine-pin-store.test.ts create mode 100644 apps/node/agent/src/machine-pin-store.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts create mode 100644 packages/subshell-protocol/src/ssh-pin-store.ts diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts new file mode 100644 index 000000000..d03f8c876 --- /dev/null +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type MachinePin, MachinePinStore, machinePinPath } from "../machine-pin-store.js"; + +/** + * Relay pins are raw PUBLIC JWK strings; byte equality runs on these exact + * strings (spec 2026-10-08 §4.4), so the fixtures are just distinct opaque + * texts. Fingerprints are a display concern (ssh-pin-store.ts), not a store + * concern. + */ +const keyA: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + encryption: '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}', +}; +const keyB: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + encryption: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}', +}; + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-mpin-")); +} + +test("pins land in ssh-machine-pins.json, a file DISTINCT from the pane channels' peers.json", () => { + const dir = freshDir(); + // The channel TOFU store (mcp-core pin-store.ts) lives at /peers.json. + // The relay store must never read or write it, so it is a different name. + expect(machinePinPath(dir)).toBe(join(dir, "ssh-machine-pins.json")); + expect(machinePinPath(dir)).not.toBe(join(dir, "peers.json")); + + const peersFile = join(dir, "peers.json"); + writeFileSync(peersFile, '{"principal:other":"{"untouched":true}"}', { mode: 0o600 }); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.check("node-1", keyA); + // The channel pin file rides through byte-identical: the relay store has its + // own path and never borrows the channels'. + expect(readFileSync(peersFile, "utf8")).toBe('{"principal:other":"{"untouched":true}"}'); +}); + +test("the pin file is 0600 after write, and a pre-existing looser mode is repaired", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + // A hand-chmod or a umask surprise never survives the next write: chmodSync + // after writeFileSync is load-bearing, since O_CREAT's mode is masked by the + // umask and ignored for an existing file. + chmodSync(machinePinPath(dir), 0o644); + store.pin("node-2", keyB); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("get returns the raw pinned strings verbatim; an unknown peer has no pin", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.get("node-1")).toBe(null); + store.pin("node-1", keyA); + // Byte-for-byte round trip: the stored material is exactly what was pinned, + // not a re-serialization. + expect(store.get("node-1")).toEqual(keyA); +}); + +test("check is byte equality on both halves: ok for the same bytes, changed for either half moving", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyA)).toBe("ok"); + expect(store.check("node-1", { ...keyA, signing: keyB.signing })).toBe("changed"); + expect(store.check("node-1", { ...keyA, encryption: keyB.encryption })).toBe("changed"); +}); + +test("check compares the RAW strings, not fingerprints: a reordered serialization of the same key is 'changed'", () => { + // The store's comparison rule mirrors pin-store.ts: byte equality on the + // stored material (§4.4). KEY display hashes DER, but a candidate that + // re-serializes the same key is NOT the plane delivering the registered + // bytes, so strictness says "changed" here; the equality the store enforces + // is on the raw strings. + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + const reordered = JSON.parse(keyA.signing); + const reorderedSigning = JSON.stringify({ y: reordered.y, x: reordered.x, crv: reordered.crv, kty: reordered.kty }); + expect(store.check("node-1", { signing: reorderedSigning, encryption: keyA.encryption })).toBe("changed"); +}); + +test("check on an unpinned peer is 'changed': the store fails closed, it never seals-to or verifies-to an unpinned stranger", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.check("never-seen", keyA)).toBe("changed"); +}); + +test("pins persist across store instances and multiple peers coexist; re-pin replaces one entry", () => { + const dir = freshDir(); + new MachinePinStore(dir).pin("node-1", keyA); + new MachinePinStore(dir).pin("node-2", keyB); + const reopened = new MachinePinStore(dir); + expect(reopened.get("node-1")).toEqual(keyA); + expect(reopened.get("node-2")).toEqual(keyB); + // §4.5's re-pair replaces the stored pin for that peer and only that peer. + reopened.pin("node-1", keyB); + expect(reopened.get("node-1")).toEqual(keyB); + expect(reopened.get("node-2")).toEqual(keyB); + expect(reopened.check("node-1", keyA)).toBe("changed"); +}); + +test("a corrupt pin file is quarantined and throws; it is never read as an empty pin set", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + writeFileSync(machinePinPath(dir), "{ definitely not json"); + // Fail closed on FIRST read: an unreadable pin set must not silently become + // "no pins" (the pin-store.ts / identity.ts doctrine). The unreadable file + // is moved aside, its bytes preserved for the operator. + expect(() => store.get("node-1")).toThrow(); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(readFileSync(join(dir, quarantined[0]), "utf8")).toContain("definitely not json"); + // Only after quarantine does the store read empty; a re-pin works again. + expect(store.get("node-1")).toBe(null); + store.pin("node-1", keyA); + expect(store.check("node-1", keyA)).toBe("ok"); + // check() fail-closes the same way: a junk file is never an "empty pin set", + // and a wrong-shape entry is junk too. + writeFileSync(machinePinPath(dir), "[]"); + expect(() => store.check("node-1", keyA)).toThrow(); +}); + +test("SUBSHELL_CHANNEL_PIN has NO effect on this store: it is always strict", () => { + // The channel escape hatch (SUBSHELL_CHANNEL_PIN=trust) is pinned OUT of the + // relay path: setting it must not soften a byte-equality verdict nor skip + // writing the file (spec 2026-10-08 §4.4: "never its escape hatch"). + const previous = process.env.SUBSHELL_CHANNEL_PIN; + process.env.SUBSHELL_CHANNEL_PIN = "trust"; + try { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyB)).toBe("changed"); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + } finally { + if (previous === undefined) delete process.env.SUBSHELL_CHANNEL_PIN; + else process.env.SUBSHELL_CHANNEL_PIN = previous; + } +}); + +test("the source never consults SUBSHELL_CHANNEL_PIN (code, not prose, is scanned)", () => { + // The header comment NAMES the env var to say the store never reads it; the + // load-bearing fact is that no CODE expression touches it. Comments stripped + // first, then even the word may not appear. + const source = readFileSync(new URL("../machine-pin-store.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/SUBSHELL_CHANNEL_PIN/); + expect(source).not.toMatch(/process\.env/); +}); diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts new file mode 100644 index 000000000..777bb79ef --- /dev/null +++ b/apps/node/agent/src/machine-pin-store.ts @@ -0,0 +1,161 @@ +/** + * The node-side MACHINE pin store for the SSH agent relay (spec 2026-10-08 + * §4.4). Each side pins BOTH of a peer's public keys - the ES256 signing key + * (§4.1) and the ECDH-ES encryption key sealing uses - as the raw public JWK + * strings the plane brokered at first pairing, and thereafter enforces + * byte equality on each, mirroring `pin-store.ts`'s comparison rule. + * + * Always strict, deliberately: this store NEVER reads `SUBSHELL_CHANNEL_PIN`. + * The channel escape hatch ("trust") relaxes the pane channels' peer TOFU in + * `packages/mcp-core/src/pin-store.ts`; reusing it here would let one env var + * silently unpin every machine relationship, which §4.4 forbids ("never its + * escape hatch"). Pinning the encryption half is what makes the plane's + * blindness (§5.5) real: a plane that could substitute a peer's encryption + * key would read every relay seal, and this byte-equality block refuses it. + * + * The file is `/ssh-machine-pins.json` (0600) - a DISTINCT file + * from the pane channels' `peers.json`, which lives under the MCP data dir + * and is written by code this store must not touch. `dataDir` is the agent's + * configured data dir (the same value `identity.ts` stores its keypairs in), + * supplied by the caller from `loadConfig()`; nothing here re-derives it. + * + * Corruption is fail-closed like the sibling stores: a PRESENT-but-unreadable + * file is moved aside and throws - an unreadable pin set never silently + * becomes "no pins". + */ +import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** A peer's pinned public halves, stored as the exact raw JWK strings. */ +export interface MachinePin { + /** Public JWK JSON (P-256 / ES256) - the peer's machine signing identity. */ + signing: string; + /** Public JWK JSON (P-256 / ECDH-ES) - the key relay seals to. */ + encryption: string; +} + +/** Result of comparing a candidate against the stored pin. */ +export type MachinePinVerdict = "ok" | "changed"; + +/** The store's file name inside the node data dir (never `peers.json`). */ +const PIN_FILE_NAME = "ssh-machine-pins.json"; + +/** Absolute path of the machine pin file inside a node data dir. */ +export function machinePinPath(dataDir: string): string { + return join(dataDir, PIN_FILE_NAME); +} + +/** Reads a `MachinePin` out of parsed JSON, or null when the shape is junk. */ +function isMachinePin(value: unknown): value is MachinePin { + if (typeof value !== "object" || value === null) return false; + const entry = value as Record; + return typeof entry.signing === "string" && typeof entry.encryption === "string"; +} + +/** + * Byte-equality pins for relay peers, one JSON map per node data dir. Every + * method reads the file fresh, so a re-pair written by another process is + * visible without restarting the agent. + */ +export class MachinePinStore { + /** The node data dir holding `ssh-machine-pins.json` (config `dataDir`). */ + readonly dataDir: string; + + constructor(dataDir: string) { + this.dataDir = dataDir; + } + + /** + * The stored pin for a peer, or `null` when the peer has never been pinned. + * @throws Error when the pin file exists but cannot be read or parsed. + */ + get(nodeId: string): MachinePin | null { + return this.loadAll()[nodeId] ?? null; + } + + /** + * Store (or REPLACE) a peer's pin - the write of a first pairing and of + * §4.5's re-pair alike. Other peers' entries ride through untouched. + */ + pin(nodeId: string, pin: MachinePin): void { + const all = this.loadAll(); + all[nodeId] = { signing: pin.signing, encryption: pin.encryption }; + this.saveAll(all); + } + + /** + * Compare a candidate against the stored pin, BYTE-FOR-BYTE on both raw + * public strings (fingerprints are display, not trust). Any difference in + * either half is `"changed"`, and so is a peer that has no pin at all: the + * store fails closed, it never blesses a stranger. + * @throws Error on an unreadable pin file (fail closed, never "changed"). + */ + check(nodeId: string, candidate: MachinePin): MachinePinVerdict { + const pinned = this.get(nodeId); + if (pinned === null) return "changed"; + return pinned.signing === candidate.signing && pinned.encryption === candidate.encryption ? "ok" : "changed"; + } + + /** + * Read the whole map. ENOENT (first run) is the one legitimate empty set; + * any other failure quarantines the file and throws - the pin set is never + * silently reset (the pin-store.ts doctrine). + */ + private loadAll(): Record { + const file = machinePinPath(this.dataDir); + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new Error(`machine pin file ${file} is unreadable: ${err instanceof Error ? err.message : String(err)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw this.quarantine(file, err); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw this.quarantine(file, new Error("top level is not a JSON object")); + } + const all: Record = {}; + for (const [peerId, value] of Object.entries(parsed as Record)) { + if (!isMachinePin(value)) { + throw this.quarantine(file, new Error(`entry '${peerId}' is not a {signing, encryption} pin object`)); + } + all[peerId] = { signing: value.signing, encryption: value.encryption }; + } + return all; + } + + /** Write the whole map, keys sorted for stable diffs, then force 0600. */ + private saveAll(all: Record): void { + const file = machinePinPath(this.dataDir); + mkdirSync(this.dataDir, { recursive: true }); + const sorted: Record = {}; + for (const peerId of Object.keys(all).sort()) { + sorted[peerId] = all[peerId]; + } + // The mode option covers O_CREAT only, is masked by the umask, and is + // ignored for a file that already exists - chmod after write is the part + // that actually guarantees 0600 (same posture as identity.ts). + writeFileSync(file, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + chmodSync(file, 0o600); + } + + /** Moves an unparseable pin file aside and throws (fails the read closed). */ + private quarantine(file: string, cause: unknown): never { + let aside = `${file}.corrupt-${Date.now()}`; + try { + renameSync(file, aside); + } catch { + aside = `${file} (could not move aside)`; + } + throw new Error( + `machine pin file is corrupt (${cause instanceof Error ? cause.message : String(cause)}); ` + + `refusing to treat the pin set as empty. The file (${aside}) was NOT reset; ` + + "restore it by hand or re-pair each peer after verifying out-of-band.", + ); + } +} diff --git a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts new file mode 100644 index 000000000..0460b2b78 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { base64UrlNoPad, base64UrlToBytes, bytesOfJwk, fingerprintJwk } from "../ssh-pin-store.js"; + +/** + * A fresh P-256 public JWK, exactly the shape the relay carries: the ES256 + * signing key and the ECDH-ES encryption key are both EC P-256, and only the + * PUBLIC half is ever fingerprinted (spec 2026-10-08 §4.5). node:crypto + * generates it because Bun's WebCrypto lacks EC key generation entirely (the + * gap the pure module under test is built to sidestep); the module itself + * imports no node builtin. + */ +function freshPublicJwk(): Record { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + expect("d" in jwk).toBe(false); + return jwk; +} + +test("fingerprintJwk renders OpenSSH notation: SHA256: over 43 base64url chars, no padding", async () => { + const jwk = freshPublicJwk(); + const fp = await fingerprintJwk(JSON.stringify(jwk)); + expect(fp.startsWith("SHA256:")).toBe(true); + // SHA-256 is 32 bytes: 43 base64url characters with the '=' padding dropped, + // and no '+' or '/' anywhere in the alphabet. + expect(fp.slice("SHA256:".length)).toMatch(/^[A-Za-z0-9_-]{43}$/); +}); + +test("two JWK serializations of the SAME key (different member order) share one fingerprint", async () => { + // THE load-bearing property: the preimage is the DER SubjectPublicKeyInfo, + // never a JSON serialization, so key order in the JWK text is irrelevant + // (spec 2026-10-08 §4.5: "never a JSON-JWK serialization (which is not + // canonical)"). + const jwk = freshPublicJwk(); + const a = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); + const b = JSON.stringify({ y: jwk.y, x: jwk.x, kty: jwk.kty, crv: jwk.crv, ext: true, key_ops: ["verify"] }); + expect(a).not.toBe(b); + expect(await fingerprintJwk(a)).toBe(await fingerprintJwk(b)); +}); + +test("two different keys get two different fingerprints", async () => { + const one = freshPublicJwk(); + const two = freshPublicJwk(); + expect(await fingerprintJwk(JSON.stringify(one))).not.toBe(await fingerprintJwk(JSON.stringify(two))); +}); + +test("bytesOfJwk is the DER SPKI, byte-identical to node:crypto's independent encoder", () => { + // The hand-built fixed-head encoding must match a real X.509 encoder + // exactly, or the fingerprint is a private dialect, not the SubjectPublicKeyInfo. + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const der = publicKey.export({ type: "spki", format: "der" }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + expect(bytesOfJwk(jwk)).toEqual(new Uint8Array(der)); + expect(bytesOfJwk(JSON.stringify(jwk))).toEqual(new Uint8Array(der)); +}); + +test("the fingerprint is SHA-256 over that DER, verified against an independent node:crypto path", async () => { + // node:crypto is used HERE (a test file) only to double-check the module's + // own path with a different encoder and hasher; the module stays node-free. + const jwk = freshPublicJwk(); + const der = createPublicKey({ key: jwk as never, format: "jwk" }).export({ type: "spki", format: "der" }); + const expected = `SHA256:${createHash("sha256").update(der).digest("base64url")}`; + expect(await fingerprintJwk(JSON.stringify(jwk))).toBe(expected); +}); + +test("fingerprintJwk accepts the JWK as an object as well as a string", async () => { + const jwk = freshPublicJwk(); + expect(await fingerprintJwk(jwk)).toBe(await fingerprintJwk(JSON.stringify(jwk))); +}); + +test("private material and junk are refused, never fingerprinted", async () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const privateJwk = privateKey.export({ format: "jwk" }) as Record; + expect("d" in privateJwk).toBe(true); + expect(() => bytesOfJwk(privateJwk)).toThrow(/private/i); + await expect(fingerprintJwk(JSON.stringify(privateJwk))).rejects.toThrow(/private/i); + expect(() => bytesOfJwk("{ not json")).toThrow(); + expect(() => bytesOfJwk(JSON.stringify({ kty: "oct", k: "aaa" }))).toThrow(); + expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-384", x: "AAA", y: "BBB" }))).toThrow(/P-256/); + expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-256", x: "!!!" }))).toThrow(); +}); + +test("base64url round trip is pure and padding-free", () => { + const bytes = Uint8Array.from([0x00, 0xff, 0x10, 0x23, 0x45]); + const encoded = base64UrlNoPad(bytes); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(base64UrlToBytes(encoded)).toEqual(bytes); + expect(() => base64UrlToBytes("====")).toThrow(); + // The alphabet is the URL-safe one: 0xFB 0xFF encodes as '-' '_' where + // standard base64 would put '+' '/'. + expect(base64UrlNoPad(Uint8Array.from([0xfb, 0xff]))).toBe("-_8"); + expect(base64UrlToBytes("-_8")).toEqual(Uint8Array.from([0xfb, 0xff])); +}); + +test("ssh-pin-store.ts is Metro-safe: no node: builtin import in CODE", () => { + // This package's barrel reaches apps/client/mobile through Metro, which + // cannot resolve `node:*` (see the NOTE in src/index.ts). Comments carry the + // words "node:" and "Buffer" as PROSE, so the scanned text is the code with + // comments stripped. + const source = readFileSync(new URL("../ssh-pin-store.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 31bf6ab96..e2fc65ada 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -257,6 +257,9 @@ export { SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, } from "./ssh-limits.js"; +// PURE module (WebCrypto globals only, no node: builtins): safe in the +// Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. +export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; export type { NodeSshAliasListResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/ssh-pin-store.ts b/packages/subshell-protocol/src/ssh-pin-store.ts new file mode 100644 index 000000000..f70d273df --- /dev/null +++ b/packages/subshell-protocol/src/ssh-pin-store.ts @@ -0,0 +1,175 @@ +/** + * Fingerprint helpers for the SSH agent relay's machine pins and agent keys + * (docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md §4.5). + * + * A fingerprint is `SHA256:` + base64url(SHA-256(DER SubjectPublicKeyInfo)) + * with the padding stripped - the OpenSSH display notation. The preimage is + * pinned to the DER encoding, NEVER a JSON-JWK serialization: JWK member + * order is not canonical, so a JSON hash would make the trust card's honest + * comparison (§4.6) ill-posed. Both machine keys (ES256 signing, ECDH-ES + * encryption) are EC P-256, which is why one fixed DER structure covers the + * pair: a P-256 public key's SPKI is the constant 27-byte AlgorithmIdentifier + * header below plus the 64-byte uncompressed point X||Y, so the DER is BUILT + * from the coordinates rather than asked of a key import. That is also why + * this module does not go through `crypto.subtle.importKey`: Bun's WebCrypto + * does not implement EC import at all (the same gap identity.ts papers over + * with jose's node:crypto fallback), and this package runs there. `digest` is + * a WebCrypto global in Bun and the browser both, so the hash stays on the + * standard primitive. + * + * PURE and Metro-safe: no `node:` builtin import anywhere (pinned by test); + * it rides the package barrel consumed by apps/client/mobile. The node-side + * pin STORE (byte equality on the raw strings) is a separate concern and + * lives in apps/node/agent/src/machine-pin-store.ts. + */ + +/** The base64url alphabet (RFC 4648 §5): `-` and `_` in place of `+` and `/`. */ +const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** The reverse lookup for the alphabet above; -1 marks a non-alphabet byte. */ +const BASE64URL_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64URL_ALPHABET.length; i++) { + values[BASE64URL_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** + * The constant head of every EC P-256 public key's DER SubjectPublicKeyInfo: + * SEQUENCE { AlgorithmIdentifier(id-ecPublicKey, prime256v1), BIT STRING(66) }, + * ending at the `04` that opens the uncompressed point. 27 bytes; the point's + * X and Y follow. Independent encoders (node:crypto, browsers) produce + * byte-identical output - pinned by test. + */ +const SPKI_P256_HEAD = Uint8Array.of( + 0x30, + 0x59, + 0x30, + 0x13, + 0x06, + 0x07, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x02, + 0x01, + 0x06, + 0x08, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x03, + 0x01, + 0x07, + 0x03, + 0x42, + 0x00, + 0x04, +); + +/** A public JWK as the wire carries it: a JSON string, or its parsed shape. */ +export type PublicJwkInput = string | Record; + +/** + * Encodes bytes as base64url WITHOUT `=` padding. A small pure loop over the + * bytes - deliberately not Buffer (a `node:` builtin Metro cannot resolve) + * and not `btoa` (which wants a binary string glued together first). + */ +export function base64UrlNoPad(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64URL_ALPHABET[(n >> 18) & 63]; + out += BASE64URL_ALPHABET[(n >> 12) & 63]; + if (remaining > 1) out += BASE64URL_ALPHABET[(n >> 6) & 63]; + if (remaining > 2) out += BASE64URL_ALPHABET[n & 63]; + } + return out; +} + +/** + * Decodes unpadded base64url text into bytes (the inverse of + * {@link base64UrlNoPad}). Non-alphabet characters, stray `=` padding, and + * impossible trailing-bit spellings throw - a coordinate is not trimmable + * material, so junk is refused rather than repaired. + */ +export function base64UrlToBytes(text: string): Uint8Array { + if (/[^A-Za-z0-9_-]/.test(text)) { + throw new TypeError(`ssh-pin-store: not base64url text: ${JSON.stringify(text.slice(0, 12))}`); + } + const out = new Uint8Array(Math.floor((text.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of text) { + value = (value << 6) | BASE64URL_VALUES[ch.charCodeAt(0)]; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits >= 4) { + // 4 or 5 leftover bits mean an encoding whose last character carried data + // beyond the byte stream - not canonical, so not accepted. + throw new TypeError("ssh-pin-store: base64url text has non-canonical trailing bits"); + } + return out; +} + +/** + * The canonical fingerprint preimage of a PUBLIC EC P-256 JWK: its DER + * SubjectPublicKeyInfo bytes. JWK member order cannot change the result, + * because the coordinates are read by NAME and re-encoded from the fixed + * P-256 structure, never hashed from the text. + * + * @throws TypeError on private material (a `d` member), a curve other than + * P-256 (the check is curve-pinned by NAME, not text-trusting), a wrong coordinate + * width, or non-base64url text - a private half must never acquire a + * "public" fingerprint. + */ +export function bytesOfJwk(publicJwk: PublicJwkInput): Uint8Array { + const parsed: unknown = typeof publicJwk === "string" ? JSON.parse(publicJwk) : publicJwk; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TypeError("ssh-pin-store: fingerprint input is not a JWK object"); + } + const jwk = parsed as Record; + if (jwk.d !== undefined) { + throw new TypeError("ssh-pin-store: refusing to fingerprint private key material"); + } + if (jwk.kty !== "EC" || jwk.crv !== "P-256") { + throw new TypeError( + `ssh-pin-store: fingerprint input is not an EC P-256 public key (kty=${String(jwk.kty)} crv=${String(jwk.crv)})`, + ); + } + if (typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new TypeError("ssh-pin-store: EC public JWK is missing its x/y coordinates"); + } + const x = base64UrlToBytes(jwk.x); + const y = base64UrlToBytes(jwk.y); + if (x.length !== 32 || y.length !== 32) { + throw new TypeError(`ssh-pin-store: P-256 coordinates must be 32 bytes (got ${x.length}/${y.length})`); + } + const der = new Uint8Array(SPKI_P256_HEAD.length + 64); + der.set(SPKI_P256_HEAD); + der.set(x, SPKI_P256_HEAD.length); + der.set(y, SPKI_P256_HEAD.length + 32); + return der; +} + +/** + * The `SHA256:`-prefixed fingerprint of a public EC P-256 JWK (string or + * object): SHA-256 over {@link bytesOfJwk}, base64url without padding - the + * same notation OpenSSH prints for host keys, so an operator comparing the + * trust card (§8) against `ssh-keygen -lf` output reads one alphabet. + */ +export async function fingerprintJwk(publicJwk: PublicJwkInput): Promise { + const hash = await crypto.subtle.digest("SHA-256", bytesOfJwk(publicJwk)); + return `SHA256:${base64UrlNoPad(new Uint8Array(hash))}`; +} From 9b856424dacd887f6b26be13db531e5037ac5f39 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:57:25 -0700 Subject: [PATCH 086/170] fix(ssh-relay): Task 2 review - base64url round-trip, prototype-safe pin map, atomic write, comment fixes --- .../src/__tests__/machine-pin-store.test.ts | 17 ++++++++++ apps/node/agent/src/machine-pin-store.ts | 31 ++++++++++++++---- .../src/__tests__/ssh-pin-store.test.ts | 32 +++++++++++++++++++ .../subshell-protocol/src/ssh-pin-store.ts | 17 ++++++---- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts index d03f8c876..434acecd6 100644 --- a/apps/node/agent/src/__tests__/machine-pin-store.test.ts +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -63,6 +63,23 @@ test("get returns the raw pinned strings verbatim; an unknown peer has no pin", expect(store.get("node-1")).toEqual(keyA); }); +test("inherited Object members are never pins: get is null for __proto__/constructor/toString on an empty store", () => { + // The maps are Object.create(null)-built: a plain `{}` would hand back + // Object.prototype or a method for these ids, breaking the "null when + // never pinned" contract Tasks 6/8 branch on. + const store = new MachinePinStore(freshDir()); + expect(store.get("__proto__")).toBe(null); + expect(store.get("constructor")).toBe(null); + expect(store.get("toString")).toBe(null); + expect(store.check("toString", keyA)).toBe("changed"); + // A peer id that shadows an Object member is still ordinary data: pinning + // and reading it round-trips through the JSON file. + store.pin("toString", keyA); + expect(store.get("toString")).toEqual(keyA); + expect(store.check("toString", keyA)).toBe("ok"); + expect(new MachinePinStore(store.dataDir).get("toString")).toEqual(keyA); +}); + test("check is byte equality on both halves: ok for the same bytes, changed for either half moving", () => { const dir = freshDir(); const store = new MachinePinStore(dir); diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts index 777bb79ef..b3893564b 100644 --- a/apps/node/agent/src/machine-pin-store.ts +++ b/apps/node/agent/src/machine-pin-store.ts @@ -52,6 +52,16 @@ function isMachinePin(value: unknown): value is MachinePin { return typeof entry.signing === "string" && typeof entry.encryption === "string"; } +/** + * A fresh peer-keyed map with NO prototype. A plain `{}` would let + * `get("__proto__")` or `get("toString")` read back inherited members + * instead of "never pinned", breaking the null contract Tasks 6 and 8 + * branch on; prototype-free, those ids are ordinary missing keys. + */ +function emptyPinMap(): Record { + return Object.create(null) as Record; +} + /** * Byte-equality pins for relay peers, one JSON map per node data dir. Every * method reads the file fresh, so a re-pair written by another process is @@ -107,7 +117,7 @@ export class MachinePinStore { try { raw = readFileSync(file, "utf8"); } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + if ((err as NodeJS.ErrnoException).code === "ENOENT") return emptyPinMap(); throw new Error(`machine pin file ${file} is unreadable: ${err instanceof Error ? err.message : String(err)}`); } let parsed: unknown; @@ -119,7 +129,7 @@ export class MachinePinStore { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw this.quarantine(file, new Error("top level is not a JSON object")); } - const all: Record = {}; + const all = emptyPinMap(); for (const [peerId, value] of Object.entries(parsed as Record)) { if (!isMachinePin(value)) { throw this.quarantine(file, new Error(`entry '${peerId}' is not a {signing, encryption} pin object`)); @@ -129,18 +139,25 @@ export class MachinePinStore { return all; } - /** Write the whole map, keys sorted for stable diffs, then force 0600. */ + /** + * Write the whole map, keys sorted for stable diffs: a full tmp-then-rename + * (rename is atomic within a directory, so a crash mid-write leaves the + * previous file intact, never a truncated pin set), then force 0600. + */ private saveAll(all: Record): void { const file = machinePinPath(this.dataDir); mkdirSync(this.dataDir, { recursive: true }); - const sorted: Record = {}; + const sorted = emptyPinMap(); for (const peerId of Object.keys(all).sort()) { sorted[peerId] = all[peerId]; } // The mode option covers O_CREAT only, is masked by the umask, and is - // ignored for a file that already exists - chmod after write is the part - // that actually guarantees 0600 (same posture as identity.ts). - writeFileSync(file, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + // ignored for a file that already exists - chmod after the rename is the + // part that actually guarantees 0600 on the final file (same posture as + // identity.ts). + const tmp = `${file}.tmp`; + writeFileSync(tmp, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + renameSync(tmp, file); chmodSync(file, 0o600); } diff --git a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts index 0460b2b78..5d7925fb6 100644 --- a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts +++ b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts @@ -81,6 +81,34 @@ test("private material and junk are refused, never fingerprinted", async () => { expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-256", x: "!!!" }))).toThrow(); }); +test("base64UrlToBytes inverts base64UrlNoPad at every byte length 0..8", () => { + // Canonical encodings must decode, not just the convenient ones: byte + // lengths 1, 4, and 7 land 4 leftover bits, which earlier guards misread + // as non-canonical and threw on. + for (let len = 0; len <= 8; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 37 + 11) & 0xff); + expect(base64UrlToBytes(base64UrlNoPad(bytes))).toEqual(bytes); + } +}); + +test("base64UrlToBytes refuses non-canonical trailing bits", () => { + // "QQ" is the canonical encoding of the single byte 0x41 and must decode; + // "AB" claims the same byte with nonzero padding bits, which + // base64UrlNoPad never emits, so it must throw. + expect(base64UrlToBytes("QQ")).toEqual(Uint8Array.from([0x41])); + expect(() => base64UrlToBytes("AB")).toThrow(/non-canonical trailing bits/); + // One character past a full group is an impossible spelling: six leftover + // bits, whatever their value. + expect(() => base64UrlToBytes("A")).toThrow(/non-canonical trailing bits/); + // The coordinate width itself: 32 zero bytes encode to 43 characters whose + // last carries two zero pad bits; bytesOfJwk's length check alone would + // accept the flipped spelling, so the decoder must not. + const canonical = base64UrlNoPad(new Uint8Array(32)); + expect(canonical.length).toBe(43); + expect(canonical.endsWith("A")).toBe(true); + expect(() => base64UrlToBytes(`${canonical.slice(0, 42)}B`)).toThrow(/non-canonical trailing bits/); +}); + test("base64url round trip is pure and padding-free", () => { const bytes = Uint8Array.from([0x00, 0xff, 0x10, 0x23, 0x45]); const encoded = base64UrlNoPad(bytes); @@ -102,6 +130,10 @@ test("ssh-pin-store.ts is Metro-safe: no node: builtin import in CODE", () => { .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, ""); expect(source).not.toMatch(/from\s+["']node:/); + // The `from` line above cannot see a bare side-effect import or an + // unawaited dynamic one; this pattern covers `import "node:fs"` and + // `import("node:fs")` (with or without await). + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); expect(source).not.toMatch(/require\(\s*["']node:/); expect(source).not.toMatch(/\bBuffer\b/); expect(source).not.toMatch(/await\s+import\(/); diff --git a/packages/subshell-protocol/src/ssh-pin-store.ts b/packages/subshell-protocol/src/ssh-pin-store.ts index f70d273df..2236b612d 100644 --- a/packages/subshell-protocol/src/ssh-pin-store.ts +++ b/packages/subshell-protocol/src/ssh-pin-store.ts @@ -115,9 +115,12 @@ export function base64UrlToBytes(text: string): Uint8Array { bits -= 8; } } - if (bits >= 4) { - // 4 or 5 leftover bits mean an encoding whose last character carried data - // beyond the byte stream - not canonical, so not accepted. + // The leftover `bits` (always even: 0, 2, or 4 for canonical widths) are the + // trailing partial group, and they live in `value`'s low bits. A canonical + // encoding pads them with zeros, so any set bit is a non-canonical spelling. + // Six leftover bits mean a text one character past a full group: no byte + // string encodes to that length, an impossible spelling either way. + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { throw new TypeError("ssh-pin-store: base64url text has non-canonical trailing bits"); } return out; @@ -165,9 +168,11 @@ export function bytesOfJwk(publicJwk: PublicJwkInput): Uint8Array { /** * The `SHA256:`-prefixed fingerprint of a public EC P-256 JWK (string or - * object): SHA-256 over {@link bytesOfJwk}, base64url without padding - the - * same notation OpenSSH prints for host keys, so an operator comparing the - * trust card (§8) against `ssh-keygen -lf` output reads one alphabet. + * object): SHA-256 over {@link bytesOfJwk}, base64url without padding. Only + * the scheme is OpenSSH's notation (SHA-256, the `SHA256:` prefix); the + * encoding follows this spec's base64url rule. OpenSSH prints standard + * base64 (`+` and `/`) over a different preimage, so this string never + * matches `ssh-keygen -lf` output character for character. */ export async function fingerprintJwk(publicJwk: PublicJwkInput): Promise { const hash = await crypto.subtle.digest("SHA-256", bytesOfJwk(publicJwk)); From ab95cd90fa90037f3f4bc1d15314cb444c336a80 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:06:20 -0700 Subject: [PATCH 087/170] feat(ssh-relay): ssh_register_identity delivers signing keys for pre-M2 nodes --- .claude/rules/security-context.md | 2 +- .../__tests__/commands-ssh-identity.test.ts | 101 ++++++++ apps/node/agent/src/commands/index.ts | 10 +- apps/node/agent/src/commands/ssh-identity.ts | 33 +++ .../db/repositories/identities.repository.ts | 19 ++ .../__tests__/ssh-identity-register.test.ts | 216 ++++++++++++++++++ .../api/src/services/nodes/node-ws-handler.ts | 9 + .../api/src/services/nodes/ssh-identity.ts | 139 +++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 30 ++- docs/security.md | 5 + .../src/__tests__/node-frames.test.ts | 24 ++ .../src/__tests__/node-results-ssh.test.ts | 27 ++- packages/subshell-protocol/src/index.ts | 3 + packages/subshell-protocol/src/node-frames.ts | 10 +- .../subshell-protocol/src/node-results.ts | 22 +- packages/subshell-protocol/src/ssh-frames.ts | 37 ++- packages/subshell-protocol/src/ssh-results.ts | 13 ++ 17 files changed, 678 insertions(+), 22 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-identity.test.ts create mode 100644 apps/node/agent/src/commands/ssh-identity.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts create mode 100644 apps/server/api/src/services/nodes/ssh-identity.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 29215d351..78cee0db1 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts new file mode 100644 index 000000000..9d0c5c4fb --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseNodeSshIdentity } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine answers its + * SIGNING PUBLIC JWK and nothing else. Driven through `dispatchCommand` + * exactly as the daemon drives it (the commands-ssh.test.ts posture). + * + * What this file owns: + * - the answer IS `identity.signingPublicJwk`, byte for byte, stable across + * asks (a re-report after rotation must not read as a mismatch); + * - the private half NEVER rides out: no `d` in the answer's JWK, and the + * serialized result never contains the stored private JWK; + * - NO ssh-enabled gate: registration is the machine's own act, not an SSH + * act, and a pre-M2 node hits it while its mirror still says off; + * - a corrupt signing file fails closed through dispatch's whole-switch wrap + * (identity.ts quarantines and throws; the plane learns `ok:false`). + */ + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-id-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function makeCtx(dataDir: string): CommandContext { + return { config: { dataDir } } as unknown as CommandContext; +} + +function freshDir(tag: string): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("ssh_register_identity", () => { + it("answers the signing PUBLIC jwk byte-for-byte, stable across asks", async () => { + const dir = freshDir("basic"); + const identity = await loadOrCreateIdentity(dir); + const first = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(first.ok).toBe(true); + const parsed = parseNodeSshIdentity(first.ok ? first.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + // The §4.3 guard compares BYTES: a second ask must answer the same string. + const second = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(second.ok && second.data).toEqual(first.ok ? first.data : null); + }); + + it("the private half never leaves: no d member in the answer, no private jwk in the frame", async () => { + const dir = freshDir("private"); + const identity = await loadOrCreateIdentity(dir); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + const answer = parseNodeSshIdentity(result.ok ? result.data : null); + if (!answer) throw new Error("identity answer did not validate"); + const jwk = JSON.parse(answer.signingPublicKey) as Record; + expect("d" in jwk).toBe(false); + expect(jwk.kty).toBe("EC"); + expect(jwk.crv).toBe("P-256"); + // The whole wire frame, serialized, must not carry the stored private JWK. + expect(JSON.stringify(result)).not.toContain(identity.signingPrivateJwk); + }); + + it("ignores the ssh-enabled gate: an un-mirrored (fail-closed OFF) machine still registers", async () => { + // Deliberately NO ssh-enabled.json here: the mirror is absent, which the + // gate reads as OFF. Registration precedes the gate; a node that never + // turned SSH on must still be able to file its identity (§4.3). + const dir = freshDir("gate-off"); + expect(readdirSync(dir).length).toBe(0); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + // The pair the loader minted on this ask is exactly what the answer names. + const identity = await loadOrCreateIdentity(dir); + const parsed = parseNodeSshIdentity(result.ok ? result.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + }); + + it("a corrupt signing file fails closed: quarantine throw surfaces as ok:false", async () => { + const dir = freshDir("corrupt"); + writeFileSync(signingIdentityPath(dir), "not a keypair", { mode: 0o600 }); + // The handler may create the encryption pair, but the signing pair's + // quarantine throw must reach dispatch's whole-switch wrap as a refusal. + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("refusing to overwrite existing key material"); + // The corrupt file was moved aside, not answered over. + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt-")); + expect(quarantined.length).toBe(1); + expect(readdirSync(dir)).not.toContain("node-signing-identity.json"); + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 55342ee8c..1dbeb746e 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshRegisterIdentity } from "./ssh-identity.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -51,9 +52,12 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- - * to-machine write), and the two SSH read arms `ssh_discover_aliases` and + * to-machine write), the two SSH read arms `ssh_discover_aliases` and * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the - * local gate mirror before any lookup or spawn). Any + * local gate mirror before any lookup or spawn), and the M2 §4.3 bootstrap + * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing + * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act). + * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -134,6 +138,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshDiscoverAliases(ctx); case "ssh_resolve_config": return await execSshResolveConfig(ctx, cmd); + case "ssh_register_identity": + return await execSshRegisterIdentity(ctx); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts new file mode 100644 index 000000000..4290a55de --- /dev/null +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -0,0 +1,33 @@ +import { type JsonValue, parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "../identity.js"; +import type { CommandContext, CommandResult } from "./context.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine reports its + * ES256 relay SIGNING public key so a node that enrolled before M2 fills the + * plane's `signingPublicKey` slot over the already-authenticated, app-layer- + * encrypted node link. + * + * The answer is run through the frozen wire validator on the way out (the + * `ssh-aliases.ts` posture), and only `signingPublicJwk` is read: the private + * half never enters a result frame. The bytes are STABLE by construction - + * `loadOrCreateIdentity` never rotates a present file, and a corrupt one + * quarantines and throws (dispatch answers `ok:false`, the plane stores + * nothing) rather than letting a silent rotation ride out as a "re-report" + * the plane's §4.3 own-registration guard would refuse. + * + * NO ssh-enabled gate check, unlike the two config arms: this is the + * machine's own registration, not an SSH act. `set_ssh_enabled` is the + * precedent for a command the gate cannot govern, and a pre-M2 node reaches + * this BEFORE anyone has ever enabled SSH on it. + * + * @param ctx - the per-daemon execution context (data dir) + * @returns `{ ok: true, data: { signingPublicKey } }` with the PUBLIC JWK only + */ +export async function execSshRegisterIdentity(ctx: CommandContext): Promise { + const identity = await loadOrCreateIdentity(ctx.config.dataDir); + const validated = parseNodeSshIdentity({ signingPublicKey: identity.signingPublicJwk }); + if (validated === null) return { ok: false, error: "malformed signing identity" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/server/api/src/db/repositories/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index ab227773b..7e77ef604 100644 --- a/apps/server/api/src/db/repositories/identities.repository.ts +++ b/apps/server/api/src/db/repositories/identities.repository.ts @@ -42,6 +42,25 @@ export class IdentitiesRepository extends BaseRepository { return this.findByPrincipal(input.principalId) as Promise; } + /** + * Fill the principal's EMPTY signing slot (spec 2026-10-08 §4.3): a + * compare-and-write that refuses to land once the slot holds ANY value, so + * the anti-silent-rotation guard is structural in SQL rather than a + * read-then-write the caller could race. The encryption `publicKey` and + * `registeredAt` do not move - filling the signing half is not a rotation. + * @returns true when THIS call wrote the bytes (false: the slot was, or + * concurrently became, non-empty, or the row does not exist) + */ + async fillSigningPublicKey(principalId: string, signingPublicKey: string): Promise { + const res = await this.db + .updateTable("identities") + .set({ signingPublicKey }) + .where("principalId", "=", principalId) + .where("signingPublicKey", "is", null) + .executeTakeFirst(); + return Number(res.numUpdatedRows) > 0; + } + /** The principal's current identity, if registered. */ async findByPrincipal(principalId: string): Promise { return this.db.selectFrom("identities").selectAll().where("principalId", "=", principalId).executeTakeFirst(); diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts new file mode 100644 index 000000000..64c946b9c --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -0,0 +1,216 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { exportJWK, generateKeyPair } from "jose"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { bootstrapSshIdentityOnReady, deliverSigningKey } from "@/services/nodes/ssh-identity.js"; +import { attachScriptedNode, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * `deliverSigningKey` + the `ready`-time bootstrap (spec 2026-10-08 §4.3): + * the §4.3 delivery stores a first report, is idempotent on a re-report of + * the SAME bytes, and REFUSES to overwrite a slot holding a DIFFERENT key + * (the own-registration guard: a silent rotation must never be repairable + * by the machine that reported it). The property this suite exists to pin + * is that refusal, and that nothing the store writes is ever a private key: + * a JWK carrying `d` is refused exactly like junk. + * + * Posture copied from ssh-enabled.test.ts: real migrated DB, real rows, + * scripted node on the real registry for the RPC leg - no RPC mocks, the + * `ssh_register_identity` frame travels the real `sendCommand` chain and is + * gated by the real `parseNodeCommandBody`. + */ + +const nodes = new NodesRepository(db); +const identities = new IdentitiesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +const scripted: ScriptedNode[] = []; +let ownerId: string; +let KEY_A: string; +let KEY_B: string; +/** A real ES256 PRIVATE JWK: the store must refuse it exactly like garbage. */ +let PRIVATE_A: string; +/** A plausible ECDH-ES public JWK for the identity row's encryption half. */ +const ENC_JWK = + '{"kty":"EC","crv":"P-256","x":"BBBBBBBBBBBBBBBBBBBBBBBB-BBBBBBBBBBBBBBBBBBB","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'; + +async function mkUser(): Promise { + const email = `ssh-id-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-id-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node whose identity record carries an EMPTY signing slot. */ +async function mkNodeWithEmptySlot(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-id-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + await identities.register({ + principalId: `node:${id}`, + publicKey: ENC_JWK, + signingPublicKey: null, + displayName: null, + }); + return id; +} + +async function slotOf(nodeId: string): Promise { + const row = await identities.findByPrincipal(`node:${nodeId}`); + return row?.signingPublicKey ?? null; +} + +/** Audit rows for one target's registration, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: string | null }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_identity.register") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: r.metadataJson })); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); + const a = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_A = JSON.stringify(await exportJWK(a.publicKey)); + PRIVATE_A = JSON.stringify(await exportJWK(a.privateKey)); + const b = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_B = JSON.stringify(await exportJWK(b.publicKey)); +}); + +afterAll(async () => { + for (const s of scripted) s.detach(); + resetNodeRegistryForTests(); + for (const id of nodeIds) { + await db.deleteFrom("identities").where("principalId", "=", `node:${id}`).execute(); + await nodes.deleteById(id); + } + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("deliverSigningKey (spec 2026-10-08 §4.3)", () => { + it("stores a FIRST report into the empty slot and audits it (ids and via only)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + const rows = await auditFor(id); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBeNull(); + expect(JSON.parse(rows[0].meta ?? "null")).toEqual({ via: "link" }); + // The key VALUE never enters the audit row (Global Constraint: no key + // material in any audit row; the metadata names the delivery, nothing else). + expect(rows[0].meta).not.toContain(KEY_A); + expect(rows[0].meta).not.toContain("-----"); + }); + + it("is idempotent on a re-report of the SAME bytes: success, no second write", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // the re-report wrote nothing new + }); + + it("REFUSES a DIFFERENT key: returns the existing, overwrites nothing, audits nothing", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + // The §4.3 own-registration guard: a machine that reports a key other + // than the one on file gets the EXISTING answer back and changes nothing. + expect(await deliverSigningKey(id, KEY_B)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("refuses malformed input WITHOUT storing it (and never a private JWK)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, "not json at all")).toBeNull(); + expect(await deliverSigningKey(id, '"just a string"')).toBeNull(); + expect(await deliverSigningKey(id, PRIVATE_A)).toBeNull(); // has `d` + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("refuses a node with NO identity row: nothing to fill, no row invented", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-orphan-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + expect(await deliverSigningKey(id, KEY_A)).toBeNull(); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + expect(await auditFor(id)).toHaveLength(0); + }); +}); + +describe("bootstrapSshIdentityOnReady (the ready-time wire-up)", () => { + it("sends ssh_register_identity ONCE on an empty slot and files the answer", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("sends NOTHING when the slot is already filled (the no-spam gate)", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // only the direct delivery above + }); + + it("sends NOTHING for a node with no identity row", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-none-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + }); + + it("survives a malformed machine answer: no throw, nothing stored", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: "garbage{" }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); // the ask did go out + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("swallows an unreachable machine quietly (offline is routine at ready)", async () => { + const id = await mkNodeWithEmptySlot(); // no scripted node attached: sendCommand answers offline + await expect(bootstrapSshIdentityOnReady(id)).resolves.toBeUndefined(); + expect(await slotOf(id)).toBeNull(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 0c4b31dc9..ae200f60a 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -29,6 +29,7 @@ import { } from "@/services/nodes/link-session.js"; import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; +import { bootstrapSshIdentityOnReady } from "@/services/nodes/ssh-identity.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -980,6 +981,14 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // cheap; everything slow lives in the push's own best-effort swallow. if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); + // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity + // record has no signing key is ASKED ONCE, and the answer files itself. + // Fire-and-forget for the same reason the detect kick above is: the + // machine's answer arrives as a LATER `result` frame on this socket's + // own queue, and awaiting it inside this frame would deadlock the + // chain. The no-spam gate (filled slot / absent record = silent skip) + // lives in the call itself. + void bootstrapSshIdentityOnReady(nodeId); return; } case "heartbeat": diff --git a/apps/server/api/src/services/nodes/ssh-identity.ts b/apps/server/api/src/services/nodes/ssh-identity.ts new file mode 100644 index 000000000..3d7d35377 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-identity.ts @@ -0,0 +1,139 @@ +import { assertImportableSigningJwk } from "@/api/public-jwk.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sshRegisterIdentity } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The §4.3 signing-key bootstrap (spec 2026-10-08): nodes that enrolled + * before M2 hold a signing keypair on disk but have never reported its + * public half. On `ready`, a machine whose `signingPublicKey` slot is EMPTY + * is asked once ({@link sshRegisterIdentity}) over its already-authenticated + * link, and the answer lands through {@link deliverSigningKey}. + * + * **The guard that makes this safe is the anti-silent-rotation rule.** A + * report that DIFFERS from a key already on file is refused, NOT stored: + * the machine that rotated its identity unilaterally must never repair its + * own history by overwriting the record the relay verifies against (the + * peer-side repair path is §4.5, a separate deliberate owner act). Same + * bytes, or a first fill, are the only writes this seam can ever cause - + * plus {@link IdentitiesRepository.fillSigningPublicKey}'s CAS, which makes + * even a raced double-report answerable: whoever lands first owns the slot. + * + * No key VALUE ever enters a log line or an audit row (Global Constraint); + * the audit names the node and `via: "link"`, and the warnings name the node + * and the shape of the problem. + */ + +/** + * File one machine's reported signing public key into its `node:` identity + * record. The whole guard table (spec 2026-10-08 §4.3): + * + * | slot holds | report | → | + * |---|---|---| + * | nothing | importable public JWK | store it, audit `node.ssh_identity.register` (`via: "link"`), return it | + * | the SAME bytes | same bytes | no-op success, return it (idempotent: every re-report of a stable key is quiet) | + * | a DIFFERENT key | anything else | REFUSE: return the existing bytes, overwrite nothing | + * | anything | unparseable / non-importable / private-carrying JWK | refuse BEFORE any write, return null (the enroll lesson: nothing that fails the import gate is stored) | + * + * A node with no identity row at all gets `null`: the row is enroll's + * artifact (its encryption half is the seal key); this seam never invents + * one. + * + * @param nodeId - the node the report is about (the socket's authenticated + * identity at every call site; never a frame's self-claim) + * @param signingPublicKey - the machine's JSON-serialized public JWK, as the + * agent answered it (wire-validated string; importability checked HERE) + * @returns the key now on file (stored, or the existing one the report was + * refused against), or null when nothing is on file and nothing was stored + */ +export async function deliverSigningKey(nodeId: string, signingPublicKey: string): Promise { + const { repos } = getRequestlessContext(); + // Validate BEFORE touching the row: a malformed report is refused exactly + // like the enroll route refuses it, and the refusal never reaches the DB. + // assertImportableSigningJwk carries the public-store rule (EC/P-256 shape, + // real curve-point import for ES256, and NO `d` - a "public" key that is + // secretly a private one is refused, not stored). + let parsedKey: unknown; + try { + parsedKey = JSON.parse(signingPublicKey); + } catch { + parsedKey = null; + } + try { + await assertImportableSigningJwk(parsedKey); + } catch { + // The refuse line names the machine and the shape, never the payload. + logger.warn(`ssh identity: node ${nodeId} reported an unusable signing key; nothing stored`); + return null; + } + + const principalId = `node:${nodeId}`; + const row = await repos.identities.findByPrincipal(principalId); + if (!row) { + logger.warn(`ssh identity: node ${nodeId} has no identity record; refusing to invent one`); + return null; + } + if (row.signingPublicKey !== null) { + // The slot is owned. Same bytes: the quiet idempotent yes. Different + // bytes: the §4.3 own-registration refusal - return the EXISTING key and + // change nothing, whatever the caller expected. + if (row.signingPublicKey !== signingPublicKey) { + logger.warn( + `ssh identity: node ${nodeId} reported a signing key DIFFERENT from the one on file; ` + + "keeping the existing key (a genuine change needs a re-enroll plus per-peer re-pairs, spec §4.2/§4.5)", + ); + } + return row.signingPublicKey; + } + + // The CAS write: only this call's UPDATE can flip the empty slot, so two + // racy reports (two `ready` frames) cannot double-store or clobber. + const stored = await repos.identities.fillSigningPublicKey(principalId, signingPublicKey); + if (!stored) { + const after = await repos.identities.findByPrincipal(principalId); + return after?.signingPublicKey ?? null; + } + await audit({ + actorUserId: null, // the link delivered it; no human asked + action: "node.ssh_identity.register", + targetType: "node", + targetId: nodeId, + // ids/via only, never the key value (docs/security.md §10). + metadataJson: JSON.stringify({ via: "link" }), + }); + return signingPublicKey; +} + +/** + * The `ready`-time bootstrap body (spec 2026-10-08 §4.3): ask a node that has + * never delivered its signing key, once, and file the answer. + * + * The NO-SPAM gate is the first read: a slot that already holds a key (the + * overwhelming steady state) answers without a command, so an agent that + * reconnects hourly costs nothing after its first contact. A node with NO + * identity row is skipped too - there is no slot to fill and no write this + * seam may make. + * + * Swallows its own failures (offline/timeout/unsupported are routine at + * `ready`; a machine that did not answer simply asks again on the next + * contact), so the fire-and-forget call site can be a bare statement. + * + * @param nodeId - the socket's authenticated identity, never a frame's claim + */ +export async function bootstrapSshIdentityOnReady(nodeId: string): Promise { + if (nodeId === LOCAL_NODE_ID) return; // no socket to ask over + try { + const { repos } = getRequestlessContext(); + const row = await repos.identities.findByPrincipal(`node:${nodeId}`); + if (!row || row.signingPublicKey !== null) return; // no slot, or already filled + const reported = await sshRegisterIdentity(nodeId); + await deliverSigningKey(nodeId, reported); + } catch (err: unknown) { + // `debug`, not warn: an offline mid-boot or an agent that has not + // upgraded into the command is the ordinary case, and the next `ready` + // retries by construction. + logger.withError(err).debug(`ssh identity: §4.3 bootstrap for node ${nodeId} did not land; retrying next ready`); + } +} diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index 9156e5396..5e8f63264 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -2,13 +2,15 @@ import { type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, parseNodeSshAliasList, + parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "@internal/subshell-protocol"; import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; /** - * The two ssh discovery/resolve commands as plane-side RPC wrappers - * (spec 2026-10-07 §5). Each is `sendCommand` plus ITS frozen result parser: + * The ssh commands as plane-side RPC wrappers (spec 2026-10-07 §5 for the + * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap). + * Each is `sendCommand` plus ITS frozen result parser: * the value these return is a validated plain object (or the function throws), * so no caller can branch on a machine answer it has not parsed. * @@ -96,3 +98,27 @@ export async function sshResolve(nodeId: string, alias: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_register_identity" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshIdentity(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed signing identity`, nodeId); + } + return parsed.signingPublicKey; +} diff --git a/docs/security.md b/docs/security.md index 39bbdfede..d65262b28 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2277,6 +2277,11 @@ Audit events are written, grouped by family: - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, `node.maintenance.update`, `node.ssh_enabled.update`, + `node.ssh_identity.register` (spec 2026-10-08 §4.3: the signing-key + bootstrap lands when the link's answer fills the EMPTY slot, `via: "link"` + and nothing else in the metadata - never the key value; the idempotent + re-report of the same bytes and the refused different-bytes report write + no row), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 6e3f3e1b0..75e683051 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,6 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; +import { SSH_COMMAND_TYPES } from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -923,6 +924,29 @@ describe("ssh command arms and the launch ssh block", () => { expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); }); + it("the ssh_register_identity arm is its type alone (spec 2026-10-08 §4.3)", () => { + expect(parseNodeCommandBody({ type: "ssh_register_identity" })).toEqual({ type: "ssh_register_identity" }); + // Nothing beyond the type travels: the machine answers about itself, so + // a plane-sent field could only be an injection attempt at its own slot. + expect(parseNodeCommandBody({ type: "ssh_register_identity", signingPublicKey: "x" })).toEqual({ + type: "ssh_register_identity", + }); + expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); + // The family's census: exactly these three types, and the count is the + // tripwire - a fourth arm must show up here before it ships. + expect([...SSH_COMMAND_TYPES].sort()).toEqual([ + "ssh_discover_aliases", + "ssh_register_identity", + "ssh_resolve_config", + ]); + expect(SSH_COMMAND_TYPES).toHaveLength(3); + // Every census type is a type the dispatcher actually narrows. + for (const t of SSH_COMMAND_TYPES) { + const body = t === "ssh_resolve_config" ? { type: t, alias: "box-a" } : { type: t }; + expect(parseNodeCommandBody(body)).not.toBeNull(); + } + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index b4a013a1f..f6f46859a 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome } from "../node-results.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; +/** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ +const SIGNING_JWK = + '{"kty":"EC","crv":"P-256","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'; + describe("parseNodeSshAliasList", () => { test("narrows a well-formed answer", () => { expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ @@ -44,3 +48,24 @@ describe("parseNodeSshResolveOutcome", () => { expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); }); }); + +describe("parseNodeSshIdentity", () => { + test("narrows a well-formed signing-public-key answer", () => { + expect(parseNodeSshIdentity({ signingPublicKey: SIGNING_JWK })).toEqual({ signingPublicKey: SIGNING_JWK }); + }); + test("refuses a non-string or empty field", () => { + expect(parseNodeSshIdentity({ signingPublicKey: 7 })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "" })).toBeNull(); + expect(parseNodeSshIdentity({})).toBeNull(); + expect(parseNodeSshIdentity(null)).toBeNull(); + expect(parseNodeSshIdentity(SIGNING_JWK)).toBeNull(); // the answer is an OBJECT, not a bare string + }); + test("refuses a value that is not well-formed JSON of object shape", () => { + // The grammar stops at "JSON that parses to an object"; ES256 importability + // is the server's gate (assertImportableSigningJwk), never this one's. + expect(parseNodeSshIdentity({ signingPublicKey: "not json at all" })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: '"a string that parses"' })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "[1,2]" })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "null" })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index e2fc65ada..1c3724026 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -145,6 +145,7 @@ export { parseNodeProbeEntries, parseNodePromptDeliver, parseNodeSshAliasList, + parseNodeSshIdentity, parseNodeSshResolveOutcome, parseNodeStatDirResult, parseNodeTreeManifestPage, @@ -247,6 +248,7 @@ export { SSH_COMMAND_TYPES, type SshDiscoverAliasesCommand, type SshNodeCommandBody, + type SshRegisterIdentityCommand, type SshResolveConfigCommand, } from "./ssh-frames.js"; export { @@ -262,6 +264,7 @@ export { export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; export type { NodeSshAliasListResult, + NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; export { MAX_UPLOAD_BYTES } from "./uploads.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index f1d2c2031..cf5175460 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1124,10 +1124,9 @@ export type NodeCommandBody = */ maxBytes: number; } - // The SSH discovery/resolve reads: two commands folded in as one union - // member so the SSH grammar lives in `ssh-frames.ts` beside the commands it - // narrows; their answers are validated by the two ssh result parsers - // appended to `node-results.ts` in the same tier. + // The SSH commands: three members folded in as one union member so the SSH + // grammar lives in `ssh-frames.ts` beside the commands it narrows; their + // answers are validated by the ssh result parsers in `node-results.ts`. | SshNodeCommandBody; /** @@ -1739,7 +1738,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { : { type: "tree_manifest", root: value.root, maxBytes: value.maxBytes as number }; case "ssh_discover_aliases": case "ssh_resolve_config": - // Delegation, not a second parser: the SSH grammar (both arms, one + case "ssh_register_identity": + // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); default: diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index de2d1ea29..c8a30440a 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -24,7 +24,7 @@ import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; -import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +import type { NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -584,3 +584,23 @@ export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcome if (!isSshErrorCode(data.code) || !isStrArray(data.settings)) return null; return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; } + +/** + * Validates and narrows an `ssh_register_identity` command's `result{data}` + * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field + * is a non-empty string carrying JSON that parses to an object. ES256 + * importability (and the no-private-component rule) is the server's gate at + * the store, not the wire's; a machine answering its OWN key has nothing to + * inject beyond its own identity. + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodeSshIdentity(data: unknown): NodeSshIdentityResult | null { + if (!isRecord(data) || !isNonEmptyStr(data.signingPublicKey)) return null; + try { + if (!isRecord(JSON.parse(data.signingPublicKey) as unknown)) return null; + } catch { + return null; + } + return { signingPublicKey: data.signingPublicKey }; +} diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index da22d140a..96d1c4bd5 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -1,17 +1,20 @@ /** - * The two surviving SSH node commands (design 2026-10-05 §7): the bounded - * config DISCOVERY and the single-alias RESOLUTION that feed the wizard's - * review step. They ride the existing signed/encrypted command transport - * unchanged - signing proves WHO ordered it, this file freezes WHAT was - * ordered. + * The SSH node commands: the bounded config DISCOVERY and the single-alias + * RESOLUTION that feed the wizard's review step (design 2026-10-05 §7), and + * the M2 §4.3 IDENTITY bootstrap a pre-M2 node answers once. They ride the + * existing signed/encrypted command transport unchanged - signing proves WHO + * ordered it, this file freezes WHAT was ordered. * * The rest of the family the earlier command table named (`ssh_test_ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The brokered-session commands are NOT defined on this branch — they - * arrive with the sealed agent-relay milestone (M2); no file names them here yet - * (`ssh-limits.ts` carries their limits ahead of the frames, as it declares). + * refused. The sealed agent-relay milestone (M2) adds ONE command here, + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches + * the plane inside a signed command on the existing node link). The + * brokered-session commands are NOT defined yet - they land with the relay + * frames themselves (`ssh-limits.ts` carries their limits ahead of the frames, + * as it declares). * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact @@ -48,11 +51,21 @@ export interface SshResolveConfigCommand { alias: string; } +/** + * The machine reports its OWN ES256 relay signing public key (spec + * 2026-10-08 §4.3): the once-over-the-link bootstrap for a node that enrolled + * before the key existed. No input beyond the type - the answer is the + * machine's own identity, there is nothing for the plane to ask about. + */ +export interface SshRegisterIdentityCommand { + type: "ssh_register_identity"; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ -export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand; +export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ -export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config"] as const; +export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity"] as const; /* ------------------------------------------------------------------ */ /* arm validators (delegated from parseNodeCommandBody) */ @@ -86,6 +99,10 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu return { type: "ssh_discover_aliases" }; case "ssh_resolve_config": return isAliasName(value.alias) ? { type: "ssh_resolve_config", alias: value.alias } : null; + case "ssh_register_identity": + // The whole command is its type: the machine answers about ITSELF, and + // the answer's shape is `node-results.ts`'s business, not this grammar's. + return { type: "ssh_register_identity" }; default: return null; } diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index 0a246172f..f86027ea5 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -58,3 +58,16 @@ export type NodeSshResolveOutcomeWire = /** Config keywords that blocked acceptance; empty when the code names the whole cause. */ settings: string[]; }; + +/** + * `ssh_register_identity` answer (spec 2026-10-08 §4.3): the machine's OWN + * ES256 signing public key, for a node that enrolled before the key existed. + * The grammar here only proves the field is a non-empty string carrying + * well-formed JSON; the ES256/P-256 importability check (and the refusal of + * any JWK holding a private component) is the SERVER's gate, beside the one + * the enroll path runs. + */ +export interface NodeSshIdentityResult { + /** JSON-serialized public JWK (P-256 / ES256) - never the private half. */ + signingPublicKey: string; +} From 80658cde2d370d28100ca0afaec4a7959bf845cc Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:09:55 -0700 Subject: [PATCH 088/170] test(ssh-relay): cover the CAS-loser branch; fix dead JSDoc link (Task 3 review) --- .../__tests__/ssh-identity-register.test.ts | 29 +++++++++++++++++++ .../api/src/services/nodes/ssh-identity.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts index 64c946b9c..fe41ffba1 100644 --- a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -157,6 +157,35 @@ describe("deliverSigningKey (spec 2026-10-08 §4.3)", () => { expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); expect(await auditFor(id)).toHaveLength(0); }); + + it("a raced double-report lands exactly once (the CAS-loser branch)", async () => { + const id = await mkNodeWithEmptySlot(); + // Two concurrent deliveries against the EMPTY slot: their reads both see + // null (each chain yields at the same awaits, and bun:sqlite runs the + // query synchronously), so both reach the CAS. Exactly one UPDATE flips + // the slot; the loser re-reads and returns the winner's bytes instead of + // auditing a second row. + const results = await Promise.all([deliverSigningKey(id, KEY_B), deliverSigningKey(id, KEY_B)]); + expect(results).toEqual([KEY_B, KEY_B]); + expect(await slotOf(id)).toBe(KEY_B); + expect(await auditFor(id)).toHaveLength(1); // the loser wrote no audit row + }); +}); + +describe("IdentitiesRepository.fillSigningPublicKey (the CAS in SQL)", () => { + it("refuses every write once the slot holds a value, same bytes or different", async () => { + const id = await mkNodeWithEmptySlot(); + const principalId = `node:${id}`; + // The first fill lands: this is the write the empty slot exists for. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(true); + // Same bytes: the UPDATE still matches nothing (the `signingPublicKey is + // null` predicate fails), so even re-reporting the stored key answers + // false. Different bytes: refused the same way. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(false); + expect(await identities.fillSigningPublicKey(principalId, KEY_B)).toBe(false); + // The stored bytes never moved. + expect(await slotOf(id)).toBe(KEY_A); + }); }); describe("bootstrapSshIdentityOnReady (the ready-time wire-up)", () => { diff --git a/apps/server/api/src/services/nodes/ssh-identity.ts b/apps/server/api/src/services/nodes/ssh-identity.ts index 3d7d35377..0e78f3302 100644 --- a/apps/server/api/src/services/nodes/ssh-identity.ts +++ b/apps/server/api/src/services/nodes/ssh-identity.ts @@ -18,7 +18,7 @@ import { logger } from "@/utils/logger.js"; * own history by overwriting the record the relay verifies against (the * peer-side repair path is §4.5, a separate deliberate owner act). Same * bytes, or a first fill, are the only writes this seam can ever cause - - * plus {@link IdentitiesRepository.fillSigningPublicKey}'s CAS, which makes + * plus the identities repository's `fillSigningPublicKey` CAS, which makes * even a raced double-report answerable: whoever lands first owns the slot. * * No key VALUE ever enters a log line or an audit row (Global Constraint); From c134a334e170df06504e2af6f9d798147008483a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:18:31 -0700 Subject: [PATCH 089/170] feat(ssh-relay): protocol 18, relay open/close commands + blind relay frame --- .../agent/src/__tests__/commands-ssh.test.ts | 6 +- .../nodes/__tests__/node-ws-handler.test.ts | 66 ++++++++ .../nodes/__tests__/relay-frames.test.ts | 46 ++++++ .../api/src/services/nodes/node-ws-handler.ts | 12 ++ .../api/src/services/nodes/relay-frames.ts | 79 +++++++++ .../src/__tests__/node-frames.test.ts | 121 +++++++++++++- .../src/__tests__/relay-frames.test.ts | 89 ++++++++++ packages/subshell-protocol/src/index.ts | 11 ++ packages/subshell-protocol/src/node-frames.ts | 118 +++++++++++++- packages/subshell-protocol/src/ssh-frames.ts | 152 ++++++++++++++++-- packages/subshell-protocol/src/ssh-limits.ts | 46 ++++++ 11 files changed, 718 insertions(+), 28 deletions(-) create mode 100644 apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts create mode 100644 apps/server/api/src/services/nodes/relay-frames.ts create mode 100644 packages/subshell-protocol/src/__tests__/relay-frames.test.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts index ecd061c2e..51ffda5c3 100644 --- a/apps/node/agent/src/__tests__/commands-ssh.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -163,7 +163,7 @@ describe("ssh_discover_aliases / ssh_resolve_config arms (gate ON)", () => { }); }); -/** The bump this tier owns: literal-16 pins everywhere must now read 17. */ -it("NODE_PROTOCOL_VERSION is 17 (the ssh launch block + the two read arms)", () => { - expect(NODE_PROTOCOL_VERSION).toBe(17); +/** The bump M2's relay tier owns: the relay link frame + the open/close commands (spec 2026-10-08 §5.1). */ +it("NODE_PROTOCOL_VERSION is 18 (relay frames + ssh_relay_open/close on the wire)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(18); }); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index ff41e1bfa..f1ac2b1a6 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -7,6 +7,8 @@ import { NODE_MAX_FRAME_BYTES, NODE_PROTOCOL_VERSION, type NodeEvent, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, } from "@internal/subshell-protocol"; import { createClientSession, @@ -48,6 +50,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; +import { relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -1748,6 +1751,69 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ expect(big.closed[0]?.code).toBe(NODE_CLOSE_TOO_BIG); }); + it("(g2) a tier-17 binding is refused, and no relay frame EVER reaches the seam on that socket (spec 2026-10-08 §11)", async () => { + // THE bump gate: relay frames are a new link frame kind, so an agent + // that ships no relay grammar must not pair at all. The exact-match + // check in the binding is what refuses it - BEFORE establishment, which + // is the only door a relay frame has (the machine hands plaintext only + // after `established`, and the switch never runs on an unestablished + // socket). The relay frame here is a VALID shape; it fails on the + // version, which is the point. + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage( + h.deps, + ws, + client.session.sealFrame( + JSON.stringify({ nodeId: "n1", nodeKey: LIVE, protocolVersion: NODE_PROTOCOL_VERSION - 1 }), + ), + ); + expect(ws.closed[0]?.code).toBe(NODE_CLOSE_HANDSHAKE_REQUIRED); // 4410 + expect(ws.data.linkPhase).not.toBe("established"); + // What the pre-18 agent tries anyway - first as plaintext junk, then as + // a WELL-FORMED sealed relay frame its own session can still encrypt. + const relayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(relayFrame)); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // refused at the version match, never routed + }); + + it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is dropped", async () => { + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage(h.deps, ws, bindingBytes(f)); // established at 18 + const relayFrame: RelayFrame = { type: "relay", ref: "r-1", seq: 3, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + // The seam saw EXACTLY the frame, blob included - and nothing else did: + // the plane is a blind router (§5.5), the stub only records. + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); + // The over-cap blob is refused by the grammar upstream of the seam + // (§5.1: cap is law); the socket survives, the frame does not arrive. + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // unchanged + expect(ws.closed).toHaveLength(0); // a dropped frame is not fatal (spec §3.1 posture) + }); + + it("(g4) a HELD socket's relay frame is dropped by the held-gate, not routed", async () => { + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); // unclassified fake: straight to the gates/switch + handleNodeOpen(OPEN_DEPS, ws); // the hold needs the connection it attaches + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame({ protocolVersion: 999 }))); + expect(getHeld("n1")).toBeDefined(); + await handleNodeMessage( + h.deps, + ws, + JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }), + ); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // held speaks exactly one thing: `result` + }); + it("(h) the held `update` path traverses the machine untouched: plaintext out, plaintext result in, RPC completes", async () => { // spec 2026-09-24 §5 regression lock ("held update path unregressed"). A // LEGACY below-floor agent is held by the EXISTING floor gate — the diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts new file mode 100644 index 000000000..50ef338eb --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { onRelayFrame, relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; + +/** + * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream + * (parseRelayFrame via the handler's parse), the held/supersede guards + * upstream of that, and the version gate upstream of everything. What this + * file pins is the stub's CONTRACT that Task 8's broker inherits: route in, + * recorded verbatim out, blob never touched. The end-to-end routing (which + * socket a frame reaches only after a brokered open) is Task 8's suite. + */ + +const frame: RelayFrame = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", +}; + +describe("onRelayFrame (the pre-broker stub)", () => { + it("records a routed frame per node, verbatim and blob-intact", () => { + resetRelayFramesForTests(); + onRelayFrame("n-a", frame); + expect(relayFramesSeenFor("n-a")).toEqual({ frames: 1, last: frame }); + // The stub is not a reader: the stored frame is the SAME shape that went + // in, base64 kept as the opaque string §5.5 demands, decoded nowhere. + expect(relayFramesSeenFor("n-a")?.last.blob).toBe(frame.blob); + }); + + it("counts per node and keeps only the newest frame", () => { + resetRelayFramesForTests(); + onRelayFrame("n-b", frame); + onRelayFrame("n-b", { ...frame, seq: 1, direction: "A2B" }); + expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, last: { ...frame, seq: 1, direction: "A2B" } }); + // Per-node isolation: A's traffic is not B's record. + expect(relayFramesSeenFor("n-a")).toBeUndefined(); + }); + + it("reset clears the registry (the --parallel fresh-registry posture)", () => { + onRelayFrame("n-c", frame); + resetRelayFramesForTests(); + expect(relayFramesSeenFor("n-c")).toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index ae200f60a..fe3e853a2 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -47,6 +47,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; +import { onRelayFrame } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -1073,6 +1074,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: case "error": logger.withMetadata({ nodeId, code: event.code }).warn(`node reported error: ${event.message}`); return; + case "relay": { + // The sealed agent-relay leg (spec 2026-10-08 §5.1). Everything a + // router must know about this frame is already decided upstream: the + // grammar validated shape and the SSH_RELAY_FRAME_MAX_BYTES cap, the + // exact-match handshake refused any pre-18 agent BEFORE this socket + // could carry a frame, and the held/supersede guards above ran. The + // blob stays opaque right through here (§5.5) - Task 8's broker pairs + // refs and shuttles; this is the routing seam, not the broker. + onRelayFrame(nodeId, event); + return; + } } } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts new file mode 100644 index 000000000..83bb51e4e --- /dev/null +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -0,0 +1,79 @@ +import type { RelayFrame } from "@internal/subshell-protocol"; + +/** + * The plane-side recognition seam for `relay` link frames (spec 2026-10-08 + * §5.1/§5.3) - the single named point between "a decrypted, grammar-validated + * relay frame arrived on an authenticated node socket" and whoever brokers + * it. `node-ws-handler.ts` routes here from its event switch; nothing else + * calls in, and nothing here calls outward. + * + * **This is deliberately a stub.** Task 8 (the plane relay broker) replaces + * the body of {@link onRelayFrame} with the real broker. What is ALREADY + * decided before a frame reaches this file, and must not be re-decided here: + * the socket is authenticated (the upgrade chain), the link is encrypted and + * version-matched at NODE_PROTOCOL_VERSION (the handshake - which is why a + * pre-18 agent never gets a relay frame routed at all), the frame is + * well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, via + * `parseNodeEvent`), the node is not held, and the socket is not superseded + * (both guards run upstream in the handler). + * + * What Task 8's broker owns, and NOTHING here does: + * - pair the two live sockets by the frame's routing `ref` against the relay + * sessions their `ssh_relay_open` commands established (§5.1); + * - forward `blob` to the peer socket BLIND - the plane reads the ref and + * nothing else (§5.5; an unopened byte copy is the whole job); + * - enforce SSH_RELAY_MAX_PER_NODE at session-open time (§5.3's loud refusal); + * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, + * A dropping, grant revoke) with the named-reason close and the + * `node.ssh_relay.close` audit. + * The stub records what arrived per node so the wiring is testable today + * without the broker being real, and so the day it lands, the tests that + * assert "a routed frame reaches the seam" do not move. + */ + +/** What the stub holds for one node until Task 8's broker replaces it. */ +export interface RelayFrameStubRecord { + /** How many relay frames have been routed from this node. */ + frames: number; + /** The most recent frame, kept verbatim - blob included, still opaque. */ + last: RelayFrame; +} + +const seenByNode = new Map(); + +/** + * Route one validated relay frame from `nodeId`. Today: record it. Task 8: + * replace with the blind pair-and-forward of §5.1/§5.3/§5.5. + * @param nodeId - the node whose authenticated socket produced the frame + * @param frame - the grammar-validated frame (`parseRelayFrame` narrowed it) + */ +export function onRelayFrame(nodeId: string, frame: RelayFrame): void { + const record = seenByNode.get(nodeId); + if (record) { + record.frames += 1; + record.last = frame; + } else { + seenByNode.set(nodeId, { frames: 1, last: frame }); + } + // TODO(Task 8): pair by `frame.ref` against the relay session this + // pairing's ssh_relay_open established, forward the blob unopened to the + // peer's socket, and close-with-named-reason on every §5.6 cut. A frame + // with no brokered session behind it is refused there, with a reason. +} + +/** + * What the stub recorded for one node (the frame count and the last frame, + * verbatim). + * @internal test-only; Task 8's broker replaces this module's surface + */ +export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undefined { + return seenByNode.get(nodeId); +} + +/** + * Drop every stub record. + * @internal test-only; per-file isolation under `bun test --parallel` + */ +export function resetRelayFramesForTests(): void { + seenByNode.clear(); +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 75e683051..d08c3fa7a 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,8 +24,8 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES } from "../ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; +import { SSH_COMMAND_TYPES, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -45,6 +45,25 @@ const launchCmd = { resolve: { binaryName: "claude" }, }; +/** + * A complete relay-open pairing (spec 2026-10-08 §5.1): every field the + * brokered open carries is REQUIRED on the wire, so one fixture serves the + * accept paths and the field-by-field refusals below. + */ +const relayOpenCmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-9c31", + ref: "r-4f2a", + role: "A", + aNodeId: "node-a", + bNodeId: "node-b", + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + peerEncryptPublicKey: "SGVsbG9Xb3JsZEhlcmVJc1RoaXJ0eXR3b0J5dGVzMTI=", + grantId: "grant-77", + fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], + lifetimeMs: 30_000, +}; + describe("parseNodeCommandBody", () => { it("accepts a well-formed launch and preserves fields", () => { const cmd = parseNodeCommandBody(structuredClone(launchCmd)); @@ -323,7 +342,12 @@ describe("parseNodeCommandBody", () => { // `ssh` block and the `ssh_discover_aliases` / `ssh_resolve_config` arms. // A lagging agent ignores the launch block and spawns a bare `ssh` with no // `-F` config, so it is HELD (update-only) until crossed, not approximated. - expect(NODE_PROTOCOL_VERSION).toBe(17); + // 18 is the sealed agent relay (spec 2026-10-08 §5.1): the `relay` link + // frame (a new frame kind on the established link) and the `ssh_relay_open` + // / `ssh_relay_close` signed commands. A tier-17 agent understands no relay + // frame, and the exact-match gate refuses it BEFORE any relay command + // (spec §11: never half-relaying). + expect(NODE_PROTOCOL_VERSION).toBe(18); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -932,17 +956,26 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these three types, and the count is the - // tripwire - a fourth arm must show up here before it ships. + // The family's census: exactly these five types, and the count is the + // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_discover_aliases", "ssh_register_identity", + "ssh_relay_close", + "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(3); + expect(SSH_COMMAND_TYPES).toHaveLength(5); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { - const body = t === "ssh_resolve_config" ? { type: t, alias: "box-a" } : { type: t }; + const body = + t === "ssh_resolve_config" + ? { type: t, alias: "box-a" } + : t === "ssh_relay_open" + ? relayOpenCmd + : t === "ssh_relay_close" + ? { type: t, ref: "r-1", reason: "lifetime_expired" } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -970,6 +1003,80 @@ describe("ssh command arms and the launch ssh block", () => { }); }); +describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => { + it("narrows a complete open pairing, every field carried through", () => { + expect(parseNodeCommandBody(structuredClone(relayOpenCmd))).toEqual(structuredClone(relayOpenCmd)); + expect(parseNodeCommandBody(JSON.stringify(relayOpenCmd))).toEqual(relayOpenCmd); + // Role B is the same grammar; the plane decides which machine gets which. + expect(parseNodeCommandBody({ ...relayOpenCmd, role: "B" })).toMatchObject({ role: "B" }); + // An empty fingerprint set parses: scoping (serve nothing) is the + // responder's rule (§5.4), not a reason to refuse the frame. + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [] })).toMatchObject({ fingerprints: [] }); + }); + + it("refuses an open missing any half of the pairing", () => { + // The command IS the pairing; a partial one names no complete session, + // so every field is required (same posture as `update`'s three). + for (const drop of [ + "relayId", + "ref", + "role", + "aNodeId", + "bNodeId", + "peerSigningPublicKey", + "peerEncryptPublicKey", + "grantId", + "fingerprints", + "lifetimeMs", + ] as const) { + const partial = structuredClone(relayOpenCmd) as unknown as Record; + delete partial[drop]; + expect(parseNodeCommandBody(partial)).toBeNull(); + } + }); + + it("refuses malformed open fields", () => { + expect(parseNodeCommandBody({ ...relayOpenCmd, role: "C" })).toBeNull(); // only A or B + expect(parseNodeCommandBody({ ...relayOpenCmd, relayId: "" })).toBeNull(); // ids are non-empty + expect(parseNodeCommandBody({ ...relayOpenCmd, grantId: 42 })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: "SHA256:AAAA" })).toBeNull(); // array, not string + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["ok", 7] })).toBeNull(); + // The grant's selection is capped by SSH_MAX_GRANT_FINGERPRINTS, and the + // refusal is hard - never a silent truncation (§5.4). + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => `SHA256:${i}`), + }), + ).toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS }, (_, i) => `SHA256:${i}`), + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, lifetimeMs: 0 })).toBeNull(); // positive or refuse + expect(parseNodeCommandBody({ ...relayOpenCmd, lifetimeMs: 1.5 })).toBeNull(); + // The encryption half is a base64 key (same spelling the link pins carry); + // junk at the grammar is malformed, not "the pin will sort it out". + expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "!!!" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "" })).toBeNull(); + }); + + it("narrows the close: routing ref and named reason, nothing else", () => { + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toEqual({ + type: "ssh_relay_close", + ref: "r-4f2a", + reason: "grant_revoked", + }); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a" })).toBeNull(); // reason required + expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime_expired" })).toBeNull(); // ref required + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child_exit" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: "" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: 3 })).toBeNull(); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts new file mode 100644 index 000000000..91ee2ee02 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { NODE_PROTOCOL_VERSION, parseNodeEvent, parseRelayFrame } from "../node-frames.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; + +/** + * The sealed agent-relay link frame (spec 2026-10-08 §5.1): one sealed + * envelope per frame, keyed by the opaque routing ref, direction-tagged, + * blob-capped. The plane is a BLIND router - the grammar is the only thing + * that touches the frame's SHAPE, and it never decodes `blob`. + */ + +/** Strict base64 that decodes to EXACTLY n raw bytes (padded last group). */ +function b64OfRawLength(n: number): string { + const full = Math.floor(n / 3); + const rem = n % 3; + const body = "A".repeat(full * 4); + if (rem === 0) return body; + return rem === 1 ? `${body}AA==` : `${body}AAA=`; +} + +const good = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: b64OfRawLength(1024), +} as const; + +describe("NODE_PROTOCOL_VERSION 18 (spec 2026-10-08 §5.1)", () => { + it("18 is the relay tier: relay frames + the open/close commands", () => { + // The bump is load-bearing: a tier-17 agent (M1) understands no relay + // frame, and the exact-match gate is what refuses it BEFORE any frame is + // accepted. Never half-relay. + expect(NODE_PROTOCOL_VERSION).toBe(18); + }); +}); + +describe("parseRelayFrame", () => { + it("accepts a valid frame and preserves every field", () => { + expect(parseRelayFrame(good)).toEqual({ ...good }); + expect(parseRelayFrame(JSON.stringify(good))).toEqual({ ...good }); + expect(parseRelayFrame({ ...good, direction: "A2B", seq: 7 })).toMatchObject({ direction: "A2B", seq: 7 }); + }); + + it("accepts a blob at exactly the cap and refuses one over it", () => { + // The cap is on the RAW payload the base64 decodes to, measured exactly + // (padding groups included) - a padded group that reads one byte longer + // than a full one at the same spelling must not smuggle past it. + const atCap = { ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) }; + expect(parseRelayFrame(atCap)).not.toBeNull(); + expect(parseRelayFrame({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES + 1) })).toBeNull(); + expect(parseRelayFrame({ ...good, blob: "A".repeat(atCap.blob.length + 4) })).toBeNull(); + // One padded extra group appended: one raw byte over, and the char + // count reads almost like the at-cap frame - the raw measurement is what + // refuses it (§5.1's cap is law, not a char-count approximation). + expect(parseRelayFrame({ ...good, blob: `${atCap.blob}AB==` })).toBeNull(); + // One byte UNDER the cap, padded last group: still legal. + expect(parseRelayFrame({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES - 1) })).not.toBeNull(); + }); + + it("refuses malformed shapes", () => { + expect(parseRelayFrame({ ...good, type: "not-relay" })).toBeNull(); + expect(parseRelayFrame({ ref: "r", seq: 0, direction: "B2A", blob: "AA==" })).toBeNull(); // no type + expect(parseRelayFrame({ ...good, ref: "" })).toBeNull(); // empty routing ref + expect(parseRelayFrame({ ...good })).not.toBeNull(); // (sanity for the line above) + expect(parseRelayFrame({ ...good, seq: -1 })).toBeNull(); // seq is a monotonic counter + expect(parseRelayFrame({ ...good, seq: 1.5 })).toBeNull(); + expect(parseRelayFrame({ ...good, direction: "A2A" })).toBeNull(); // only the two spellings + expect(parseRelayFrame({ ...good, blob: "not base64 !!!" })).toBeNull(); + expect(parseRelayFrame({ ...good, blob: 42 })).toBeNull(); + expect(parseRelayFrame("nope")).toBeNull(); + expect(parseRelayFrame("null")).toBeNull(); // parses, and null is not a frame + expect(parseRelayFrame(JSON.stringify([good]))).toBeNull(); // an array is not a record either + }); + + it("the relay kind joins the node-event narrowing (the plane's one inbound parse)", () => { + // Every relay frame the PLANE sees arrives on the agent-to-plane inbound + // path, which dispatches through parseNodeEvent; the kind must come back + // from there, not fall to the unrecognized-drop. + const event = parseNodeEvent({ ...good }); + expect(event?.type).toBe("relay"); + if (event?.type === "relay") { + expect(event.ref).toBe("r-4f2a"); + expect(event.direction).toBe("B2A"); + } + // A malformed relay frame stays NULL there, exactly as parseRelayFrame refuses it. + expect(parseNodeEvent({ ...good, direction: "sideways" })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 1c3724026..c3ccb2c85 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -111,7 +111,10 @@ export { parseNodeMaintenance, parseNodeRuntimeReport, parseNodeSshEnabled, + parseRelayFrame, partPathOf, + type RelayDirection, + type RelayFrame, } from "./node-frames.js"; export { type DetectResultWire, @@ -249,15 +252,23 @@ export { type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, + type SshRelayCloseCommand, + type SshRelayOpenCommand, + type SshRelayRole, type SshResolveConfigCommand, } from "./ssh-frames.js"; export { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, + SSH_RELAY_FRAME_MAX_BYTES, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, } from "./ssh-limits.js"; // PURE module (WebCrypto globals only, no node: builtins): safe in the // Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index cf5175460..1d71a8f99 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1,7 +1,7 @@ import { BASE64_RE, isBool, isInt, isNum, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import type { JsonValue } from "./json.js"; import { parseSshNodeCommandBody, type SshNodeCommandBody } from "./ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; +import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS, SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -150,15 +150,25 @@ import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; * mirror simply says no. A capability gate cannot live with that ambiguity * (spec §4.3), so the number is what refuses the old agent, not the frame. * - * **16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve - * commands. A lagging agent is HELD (update-only) until crossed, per the + * **16 -> 17:** the ssh launch block and the two discovery/resolve commands. + * A lagging agent is HELD (update-only) until crossed, per the * capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md * §4.3; the desktop update path is the crossing. The ambiguity the bump * refuses: a tier-1 agent that ignores the launch frame's `ssh` member would * spawn a bare `ssh` pane with no `-F` config (§4.3's gate doctrine refuses to * live with that). + * + * **17 -> 18 (this tier):** the sealed agent relay + * (docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md §5.1). The + * `relay` link frame - a NEW frame kind on the established link, carrying one + * sealed envelope keyed by the opaque routing ref, with no per-message + * command signing - plus the `ssh_relay_open` / `ssh_relay_close` signed + * commands that broker a session. This one cannot ride a silent tier: a + * tier-17 agent understands no `relay` frame at all, and the exact-match + * gate is what refuses pairing BEFORE any relay command is sent (§5.1's own + * bump argument, §11's never-half-relay rule). Server first, as always. */ -export const NODE_PROTOCOL_VERSION = 17; +export const NODE_PROTOCOL_VERSION = 18; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -1274,6 +1284,87 @@ export type PluginReportWire = { restartRequired?: boolean; }; +/* ------------------------------------------------------------------ */ +/* sealed agent relay link frame (spec 2026-10-08 §5.1) */ +/* ------------------------------------------------------------------ */ + +/** Which way one relay envelope travels inside its session (spec §5.1). */ +export type RelayDirection = "B2A" | "A2B"; + +/** + * The `relay` link frame: ONE sealed envelope per frame, on the established + * (protocol-18) link, keyed by the opaque routing ref (§5.1). It is NOT a + * command and NOT signed per message: the link already authenticates the + * machine, and origin rides the inner ES256 signature the endpoints exchange + * (§5.6), which the plane never sees. The plane's only roles are open, + * route, close - so `blob` is opaque BY CONTRACT: this grammar validates the + * SHAPE and the size and decodes nothing in between. `seq` is the frame's + * transport slot (the anti-replay `seq` lives SIGNED inside the envelope, + * §5.6); here it only needs to be a non-negative integer both ends can log. + * + * The frame travels BOTH directions of the link. On the plane's inbound path + * it arrives like every other agent frame (it joins {@link NodeEvent} for + * that); the plane-to-node leg serializes the same object down the + * established link and the node parses it with {@link parseRelayFrame} + * directly. The routing ref pairs the two legs; the broker (Task 8) owns + * that pairing, never this grammar. + */ +export interface RelayFrame { + type: "relay"; + /** The opaque routing ref the relay-open command established. */ + ref: string; + /** Per-frame counter on THIS leg; monotonic, non-negative. */ + seq: number; + /** The envelope's logical direction inside the session, both spellings. */ + direction: RelayDirection; + /** The sealed envelope, strict base64, raw payload at most SSH_RELAY_FRAME_MAX_BYTES. */ + blob: string; +} + +/** + * Exact raw length a strict-base64 string carries: 3 bytes per whole group + * minus the pad. BASE64_RE already forces the length to a multiple of 4, so + * the trailing-`=` count alone closes the last group. Measuring the cap on + * the RAW payload (not the inflated string) is what makes the bound honest: + * a char-length cap would let a padded group carry one byte past + * {@link SSH_RELAY_FRAME_MAX_BYTES} while reading exactly as long. + */ +function base64RawLength(b64: string): number { + let pad = 0; + for (let i = b64.length - 1; i >= 0 && b64[i] === "="; i -= 1) pad += 1; + return (b64.length / 4) * 3 - pad; +} + +/** + * Validates and narrows a relay link frame (raw JSON text or the + * already-parsed object, same intake as {@link parseNodeEvent}). A present- + * but-undecodable blob is malformed, not deferred; an over-cap blob is + * REFUSED here because the cap is law (§5.1: over-cap frame refused, session + * closed with a named reason - the close is the broker's, the refusal is + * this function's). The blob's CONTENTS are never inspected: blindness is + * the point (§5.5). + * + * @param raw - candidate frame as received + * @returns the narrowed frame, or null when malformed / not a relay frame + */ +export function parseRelayFrame(raw: string | object): RelayFrame | null { + let value: unknown = raw; + if (typeof raw === "string") { + try { + value = JSON.parse(raw); + } catch { + return null; + } + } + if (!isRecord(value) || value.type !== "relay") return null; + if (!isStr(value.ref) || value.ref.length === 0) return null; + if (!isInt(value.seq) || (value.seq as number) < 0) return null; + if (value.direction !== "B2A" && value.direction !== "A2B") return null; + if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; + if (base64RawLength(value.blob) > SSH_RELAY_FRAME_MAX_BYTES) return null; + return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; +} + export type NodeEvent = | { type: "ready"; @@ -1407,7 +1498,17 @@ export type NodeEvent = | { type: "output"; subshellId: string; subId: string; fromByte: number; toByte: number; data_b64: string } | { type: "exit"; subshellId: string; exitCode: number | null; at: string } | { type: "subshells_report"; subshells: { subshellId: string; alive: boolean; exitCode: number | null }[] } - | { type: "error"; code: string; message: string }; + | { type: "error"; code: string; message: string } + /** + * The sealed agent-relay leg (spec 2026-10-08 §5.1). Folded in for the + * PLANE's inbound path: every relay frame the plane reads arrives on an + * agent socket, where `parseNodeEvent` is the one kind dispatcher and the + * held-gate/supersede-probe that follow are exactly the guards a shuttle + * frame should pass through. On the plane-to-node leg the node parses the + * same shape with {@link parseRelayFrame} directly; "event" here names the + * inbound-to-plane position, not a claim that the node volunteered it. + */ + | RelayFrame; /* ------------------------------------------------------------------ */ /* validators (hand-rolled, parseClientFrame style — spec §3) */ @@ -1739,6 +1840,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_discover_aliases": case "ssh_resolve_config": case "ssh_register_identity": + case "ssh_relay_open": + case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); @@ -1862,6 +1965,11 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { return isStr(value.code) && isStr(value.message) ? { type: "error", code: value.code, message: value.message } : null; + case "relay": + // Delegation, like the SSH commands: the relay frame's whole contract + // (ref, seq, direction, capped opaque blob) lives in parseRelayFrame + // beside the type it narrows. + return parseRelayFrame(value); default: return null; } diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 96d1c4bd5..3e19810c1 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,12 +9,14 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds ONE command here, - * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches - * the plane inside a signed command on the existing node link). The - * brokered-session commands are NOT defined yet - they land with the relay - * frames themselves (`ssh-limits.ts` carries their limits ahead of the frames, - * as it declares). + * refused. The sealed agent-relay milestone (M2) adds three commands here: + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node’s signing key reaches + * the plane inside a signed command on the existing node link), and the + * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane’s OPEN + * hands one machine the whole pairing, the CLOSE names why it ends). What + * rides the link AFTER an open - the sealed blobs themselves - is not a + * command at all: it is the `relay` link frame in `node-frames.ts`, which + * carries no per-message signing because the link already authenticates. * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact @@ -23,12 +25,13 @@ * * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, so * it rides the Metro-safe barrel with the rest of the grammar. The wiring - * landed with the launcher tier: `parseNodeCommandBody` dispatches these two - * arms here, and `node-results.ts` validates their answers. + * landed with the launcher tier: `parseNodeCommandBody` dispatches every + * `ssh_*` arm here, and `node-results.ts` validates the answers that carry + * data (the relay pair answer with a plain ack - no envelope to validate). */ -import { isRecord, isStr } from "./guards.js"; -import { SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -61,11 +64,83 @@ export interface SshRegisterIdentityCommand { type: "ssh_register_identity"; } +/** + * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the + * key home whose agent signs, B is the connecting machine that holds none. + */ +export type SshRelayRole = "A" | "B"; + +/** + * The brokered OPEN (spec §5.1): the plane hands one machine the WHOLE + * pairing in this one signed command - relay-session id, opaque routing ref, + * both node ids with their roles, the peer's registered signing AND + * encryption public keys to pin (§4.4), the grant id with its selected + * key-fingerprint set (§5.4, delivered so the responder enforces it without + * a REST call it cannot make), and the lifetime. Every field is REQUIRED: + * a partial pairing names no session the endpoint could honor, so the + * grammar refuses it rather than let an executor guess a half. + * + * The peer keys travel as their registered spellings: the ES256 signing half + * as a JSON-serialized public JWK (what §4.2/§4.3 store and report), the + * ECDH-ES encryption half as canonical base64 (what the link pins). The + * byte-equality enforcement is the receiver's machine pin store (§4.4), + * not this grammar's. + */ +export interface SshRelayOpenCommand { + type: "ssh_relay_open"; + /** The relay-session id the plane minted (audit + §5.6's signature covers it). */ + relayId: string; + /** Opaque routing ref - the plane's blind pairing key for later relay frames. */ + ref: string; + /** THIS machine's role in the pairing; the executor branches on it. */ + role: SshRelayRole; + /** The key home's node id (names A whichever machine reads this). */ + aNodeId: string; + /** The connecting machine's node id (names B likewise). */ + bNodeId: string; + /** The peer's registered ES256 signing public key (JSON public JWK) to pin. */ + peerSigningPublicKey: string; + /** The peer's registered ECDH-ES encryption public key (canonical base64) to pin. */ + peerEncryptPublicKey: string; + /** The live grant this session runs under (revoke cuts the session, §6.3). */ + grantId: string; + /** The grant's selected key fingerprints, at most SSH_MAX_GRANT_FINGERPRINTS. */ + fingerprints: string[]; + /** Session ceiling in ms; SSH_RELAY_LIFETIME_MS is what the plane sends. */ + lifetimeMs: number; +} + +/** + * The brokered TEARDOWN (spec §5.1/§5.6): stop the session named by the + * routing ref and say why, in the open. The reason is a named cause + * (lifetime expiry, grant revoke, A drop, child exit, over-cap frame...) + * because §5.1's "closed with a named reason" is the whole failure-mode + * posture; an unnamed close is the silence this family refuses. + */ +export interface SshRelayCloseCommand { + type: "ssh_relay_close"; + /** The routing ref of the session to tear down. */ + ref: string; + /** The named reason this session ends; relayed to the endpoint's log. */ + reason: string; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ -export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand; +export type SshNodeCommandBody = + | SshDiscoverAliasesCommand + | SshResolveConfigCommand + | SshRegisterIdentityCommand + | SshRelayOpenCommand + | SshRelayCloseCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ -export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity"] as const; +export const SSH_COMMAND_TYPES = [ + "ssh_discover_aliases", + "ssh_resolve_config", + "ssh_register_identity", + "ssh_relay_open", + "ssh_relay_close", +] as const; /* ------------------------------------------------------------------ */ /* arm validators (delegated from parseNodeCommandBody) */ @@ -82,9 +157,26 @@ function isAliasName(value: unknown): value is string { ); } +/** Identifiers on this grammar are non-empty strings; shape only, existence is what the command asks. */ +function isIdStr(value: unknown): value is string { + return isStr(value) && value.length > 0; +} + +/** + * The grant's selected fingerprint set: strings, every one non-empty (an + * empty entry matches no key yet would ride a grant as a silent never-serve + * line), and at most {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a + * refusal at the grammar so the truncation nobody may silently perform is + * impossible by construction (§5.4). An EMPTY set parses: §5.4's + * "names no fingerprint, serves nothing" is a decision, not a defect. + */ +function isGrantFingerprints(value: unknown): value is string[] { + return isStrArray(value) && value.every((f) => f.length > 0) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; +} + /** * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` - * routes its two `type` arms here, so the SSH grammar lives in ONE file + * routes every `ssh_` arm here, so the SSH grammar lives in ONE file * beside the commands it narrows. The contract this upholds is * the same as the node commands' today: a NON-null return is safe to switch * on by `type`. @@ -103,6 +195,40 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // The whole command is its type: the machine answers about ITSELF, and // the answer's shape is `node-results.ts`'s business, not this grammar's. return { type: "ssh_register_identity" }; + case "ssh_relay_open": { + // EVERY pairing field is required (§5.1): this command IS the pairing, + // and an executor guessing a missing half would be guessing trust + // (whose key to pin) or safety (which fingerprints to serve). The + // role is only "A" or "B" - there is no third machine in this design. + if (value.role !== "A" && value.role !== "B") return null; + if ( + !isIdStr(value.relayId) || + !isIdStr(value.ref) || + !isIdStr(value.aNodeId) || + !isIdStr(value.bNodeId) || + !isIdStr(value.grantId) + ) { + return null; + } + // The signing half is a JSON public JWK - the grammar proves it is a + // non-empty string; importability and the private-part refusal are the + // server's gate, beside the one enroll and `ssh_register_identity` run. + if (!isIdStr(value.peerSigningPublicKey)) return null; + // The encryption half is a base64 key (the link pin's own spelling); a + // present-but-undecodable one is malformed, not "the pin sorts it out". + if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; + if (!isGrantFingerprints(value.fingerprints)) return null; + // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS + // as the ceiling the broker sends) is the broker's, this is the shape. + if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; + return value as unknown as SshRelayOpenCommand; + } + case "ssh_relay_close": + // Ref + named reason, and nothing else may ride it: the close selects + // a session, it does not amend one (§5.1/§5.6). + return isIdStr(value.ref) && isIdStr(value.reason) + ? { type: "ssh_relay_close", ref: value.ref, reason: value.reason } + : null; default: return null; } diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index 20803a982..e27b738fc 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -131,6 +131,52 @@ export const SSH_REQ_ID_MAX_CHARS = 64; */ export const SSH_SESSION_LOG_WINDOW_BYTES = 128 * 1024; +/* ------------------------------------------------------------------ */ +/* sealed agent relay (spec 2026-10-08 §5.1/§5.3/§5.4/§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * Largest RAW payload one sealed relay frame may carry (spec 2026-10-08 + * §5.1). 128 KiB is the channel-envelope scale the spec names the cap + * bounded to: base64 inflates 4/3, so a full blob spells about 174,764 + * characters inside the frame, well under the node link's own + * NODE_MAX_FRAME_BYTES ceiling - the same arithmetic that sized + * {@link SSH_SESSION_LOG_WINDOW_BYTES}. An over-cap frame is refused and + * the session closed with a named reason; the cap is law, not a hint. + */ +export const SSH_RELAY_FRAME_MAX_BYTES = 131_072; + +/** + * Concurrent relay sessions one node may hold on the plane's broker + * (spec §5.3). Exceeding the cap is a LOUD refusal, never a queue. + */ +export const SSH_RELAY_MAX_PER_NODE = 8; + +/** + * Hard ceiling on a relay session's life (spec §5.6). Modeled on + * {@link SSH_SESSION_OPEN_DEADLINE_MS}: the relay exists only for the + * handshake window, and the lifetime is the last-resort cut beneath the + * earlier ones (grace elapsed, child exit, A drop, grant revoke). + */ +export const SSH_RELAY_LIFETIME_MS = 30_000; + +/** + * How long B's proxy keeps the relay open after the `ssh` child is alive + * with no agent error before tearing it down (spec §5.6: B cannot see + * ssh's handshake to D, so a quiet grace stands in for the signal, and + * child exit cuts immediately). Same 5 s family as {@link SSH_CANCEL_GRACE_MS}. + */ +export const SSH_RELAY_TEARDOWN_GRACE_MS = 5_000; + +/** + * Key fingerprints one grant may select (spec §5.4). Deliberately + * DISTINCT from {@link SSH_MAX_IDENTITY_REFS}, which bounds snapshot + * identity PATHS, not grant selections; an approval selecting more is a + * hard refusal, never a silent truncation. Public data end to end: + * `SHA256:` base64 fingerprints, never key material. + */ +export const SSH_MAX_GRANT_FINGERPRINTS = 8; + /** * Largest raw stdout slice one brokered `session_frame` event may carry * (design §3's pump, restated as a number: the node-frames `session_frame` From 84def3deaadc88329bff3af95d031e989bf3dc7e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:28:35 -0700 Subject: [PATCH 090/170] fix(ssh-relay): Task 4 review - signing-key shape gate, observable over-cap, typed close reasons --- .../nodes/__tests__/node-ws-handler.test.ts | 45 ++++++- .../nodes/__tests__/relay-frames.test.ts | 34 ++++- .../api/src/services/nodes/node-ws-handler.ts | 15 ++- .../api/src/services/nodes/relay-frames.ts | 50 +++++++- .../src/__tests__/node-frames.test.ts | 79 ++++++++++-- .../src/__tests__/relay-frames.test.ts | 35 +++++- packages/subshell-protocol/src/index.ts | 4 + packages/subshell-protocol/src/node-frames.ts | 38 ++++++ packages/subshell-protocol/src/ssh-frames.ts | 116 ++++++++++++++---- 9 files changed, 375 insertions(+), 41 deletions(-) diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index f1ac2b1a6..be5cb0218 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -50,7 +50,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; -import { relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -1780,7 +1780,7 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ expect(relayFramesSeenFor("n1")).toBeUndefined(); // refused at the version match, never routed }); - it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is dropped", async () => { + it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is refused by name", async () => { resetRelayFramesForTests(); const f = await handshakeFixture(); const { h, ws, client } = f; @@ -1791,12 +1791,45 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ // The seam saw EXACTLY the frame, blob included - and nothing else did: // the plane is a blind router (§5.5), the stub only records. expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); - // The over-cap blob is refused by the grammar upstream of the seam - // (§5.1: cap is law); the socket survives, the frame does not arrive. + // The over-cap blob is refused by the grammar upstream of the ROUTER seam + // (§5.1: cap is law); the socket survives, the frame is not forwarded. + // But §5.1's named-reason close needs the refusal to be OBSERVABLE, and + // the handler's over-cap probe is what routes it - ref only, no blob. const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // unchanged - expect(ws.closed).toHaveLength(0); // a dropped frame is not fatal (spec §3.1 posture) + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // router seam: NOT the normal route + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-1" }); // over-cap seam, ref carried + // The blob never crossed: the record has no byte slot, and the only + // bytes the seam could know are the ref (§5.5 blindness end to end). + expect(JSON.stringify(relayOverCapsSeenFor("n1"))).not.toContain("AAAA"); + expect(ws.closed).toHaveLength(0); // a refused frame is not fatal (spec §3.1 posture) + }); + + it("(g3b) an over-cap relay frame lands on the over-cap seam, NOT the router seam; junk lands on neither (Task 4 review)", async () => { + // The plain-path twin of g3's encrypted route (the fake socket skips the + // link machine), pinning the branch the handler added to its + // parseNodeEvent-null path: shape probe first, then the drop. + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + const overFrame = { type: "relay", ref: "r-9", seq: 1, direction: "B2A", blob: over }; + await handleNodeMessage(h.deps, ws, JSON.stringify(overFrame)); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-9" }); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // never routed, blob never forwarded + // A second refusal increments the same node's count with the newer ref. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, ref: "r-10" })); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 2, lastRef: "r-10" }); + // A MALFORMED over-cap frame is junk, not an over-cap: it stays on the + // silent-drop path the probe deliberately does not widen. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, seq: -5 })); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); + // And a well-formed UNDER-cap frame goes the other way: routed, not refused. + const goodFrame: RelayFrame = { type: "relay", ref: "r-11", seq: 0, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(goodFrame)); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: goodFrame }); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); // still just the two + expect(ws.closed).toHaveLength(0); }); it("(g4) a HELD socket's relay frame is dropped by the held-gate, not routed", async () => { diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts index 50ef338eb..fe971f092 100644 --- a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { RelayFrame } from "@internal/subshell-protocol"; -import { onRelayFrame, relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { + onRelayFrame, + onRelayFrameOverCap, + relayFramesSeenFor, + relayOverCapsSeenFor, + resetRelayFramesForTests, +} from "../relay-frames.js"; /** * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream @@ -44,3 +50,29 @@ describe("onRelayFrame (the pre-broker stub)", () => { expect(relayFramesSeenFor("n-c")).toBeUndefined(); }); }); + +describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () => { + it("counts refusals per node and keeps ONLY the ref; the blob never comes", () => { + resetRelayFramesForTests(); + onRelayFrameOverCap("n-d", "r-1"); + expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 1, lastRef: "r-1" }); + onRelayFrameOverCap("n-d", "r-2"); + expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 2, lastRef: "r-2" }); + // Per-node isolation, same posture as the routed stub. + expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); + // The record has no blob slot at all: an over-cap frame is refused + // unread, and the stub could not store the bytes even if it wanted to. + expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); + }); + + it("the two seams are independent, and reset clears both", () => { + resetRelayFramesForTests(); + onRelayFrameOverCap("n-f", "r-9"); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); // a refusal is not a routed frame + onRelayFrame("n-f", frame); + expect(relayOverCapsSeenFor("n-f")).toEqual({ overCaps: 1, lastRef: "r-9" }); // a route is not a refusal + resetRelayFramesForTests(); + expect(relayOverCapsSeenFor("n-f")).toBeUndefined(); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index fe3e853a2..a8cb5911f 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -7,6 +7,7 @@ import { type NodeEvent, nodeVersionSupported, parseNodeEvent, + relayFrameRefIfOverCap, } from "@internal/subshell-protocol"; import type { LinkSession } from "@internal/subshell-protocol/node-link-crypto"; import { HttpError } from "@/api/auth-guard.js"; @@ -47,7 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onRelayFrame } from "./relay-frames.js"; +import { onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -746,6 +747,18 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const event = parseNodeEvent(resolved); if (!event) { + // A WELL-FORMED relay frame whose blob exceeds SSH_RELAY_FRAME_MAX_BYTES + // is refused by the strict grammar (parseRelayFrame stays strict so the + // NodeEvent union cannot carry an unlawful frame), but §5.1 refuses to + // answer it with silence: "closed with a named reason" needs the refusal + // to be OBSERVABLE. Probe the shape before logging "unrecognized" - the + // probe reads shape and the blob's raw length only, never the bytes + // (§5.5) - and route the named refusal to the relay seam. + const overCapRef = relayFrameRefIfOverCap(resolved); + if (overCapRef !== null) { + onRelayFrameOverCap(nodeId, overCapRef); + return; + } logger.debug(`node ws: dropped unrecognized frame from ${nodeId}`); return; } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 83bb51e4e..48d636c56 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -26,6 +26,10 @@ import type { RelayFrame } from "@internal/subshell-protocol"; * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, * A dropping, grant revoke) with the named-reason close and the * `node.ssh_relay.close` audit. + * A second entry point, {@link onRelayFrameOverCap}, carries the refusal + * this grammar performs but cannot express in a `NodeEvent` (an over-cap + * blob) so §5.1's named-reason close has something to be ABOUT. + * * The stub records what arrived per node so the wiring is testable today * without the broker being real, and so the day it lands, the tests that * assert "a routed frame reaches the seam" do not move. @@ -39,7 +43,16 @@ export interface RelayFrameStubRecord { last: RelayFrame; } +/** What the over-cap stub holds for one node until Task 8 replaces it. */ +export interface RelayOverCapStubRecord { + /** How many over-cap relay frames have been refused from this node. */ + overCaps: number; + /** The routing ref of the most recent refusal. NEVER the blob: an over-cap frame is refused unread (§5.1, §5.5). */ + lastRef: string; +} + const seenByNode = new Map(); +const overCapByNode = new Map(); /** * Route one validated relay frame from `nodeId`. Today: record it. Task 8: @@ -61,6 +74,31 @@ export function onRelayFrame(nodeId: string, frame: RelayFrame): void { // with no brokered session behind it is refused there, with a reason. } +/** + * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1). This is the + * observability half of the refusal the strict grammar already performed: + * `parseRelayFrame` answers an over-cap blob with null (so the `NodeEvent` + * union stays safe) and `node-ws-handler.ts` probes the shape with + * `relayFrameRefIfOverCap` and routes the named case here instead of the + * silent "unrecognized" drop. The blob never comes along - the probe reads + * its raw length and nothing else (§5.5). + * @param nodeId - the node whose authenticated socket produced the frame + * @param ref - the routing ref the well-formed candidate carried + */ +export function onRelayFrameOverCap(nodeId: string, ref: string): void { + const record = overCapByNode.get(nodeId); + if (record) { + record.overCaps += 1; + record.lastRef = ref; + } else { + overCapByNode.set(nodeId, { overCaps: 1, lastRef: ref }); + } + // TODO(Task 8): this is where §5.1's named-reason close lands: look the + // session up by `ref`, cut BOTH ends with ssh_relay_close reason + // "over-cap" (SSH_RELAY_CLOSE_REASONS), and audit `node.ssh_relay.close`. + // Until the broker exists, the record only proves the refusal is visible. +} + /** * What the stub recorded for one node (the frame count and the last frame, * verbatim). @@ -71,9 +109,19 @@ export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undef } /** - * Drop every stub record. + * What the over-cap stub recorded for one node (the refusal count and the + * last refused ref; never a blob). + * @internal test-only; Task 8's broker replaces this module's surface + */ +export function relayOverCapsSeenFor(nodeId: string): RelayOverCapStubRecord | undefined { + return overCapByNode.get(nodeId); +} + +/** + * Drop every stub record (routed frames and over-cap refusals alike). * @internal test-only; per-file isolation under `bun test --parallel` */ export function resetRelayFramesForTests(): void { seenByNode.clear(); + overCapByNode.clear(); } diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index d08c3fa7a..1081f734f 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,7 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshRelayOpenCommand } from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -974,7 +974,7 @@ describe("ssh command arms and the launch ssh block", () => { : t === "ssh_relay_open" ? relayOpenCmd : t === "ssh_relay_close" - ? { type: t, ref: "r-1", reason: "lifetime_expired" } + ? { type: t, ref: "r-1", reason: "lifetime-expiry" } : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } @@ -1063,17 +1063,82 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "" })).toBeNull(); }); - it("narrows the close: routing ref and named reason, nothing else", () => { - expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toEqual({ + it("refuses a peer signing key whose JSON is not a plain public-JWK record (Task 4 review)", () => { + // The shape gate mirrors parseNodeSshIdentity: a non-empty string is not + // enough; it must PARSE to a plain record. A bare word and a serialized + // PRIVATE JWK are the two shapes the old isIdStr-only check let through, + // so both are named here. Deep private-material refusal (importability, + // curve, coordinates) stays `bytesOfJwk` on the node - Task 6. + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "not json" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "hello" })).toBeNull(); + // A real private-JWK spelling: plain record, carries the `d` member. + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB","d":"pr1v4t3"}', + }), + ).toBeNull(); + // JSON scalars and arrays are not records. + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "null" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "[1,2]" })).toBeNull(); + // The public shape (the fixture) still parses, and a public JWK with + // EXTRA public members is still a record. + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB","use":"sig"}', + }), + ).not.toBeNull(); + }); + + it("rebuilds the open from validated fields; stray wire members do not ride", () => { + const parsed = parseNodeCommandBody({ ...relayOpenCmd, sneaky: { should: "not pass" } }); + expect(parsed).not.toBeNull(); + expect(parsed).not.toHaveProperty("sneaky"); + expect(parsed).toEqual(relayOpenCmd); + }); + + it("refuses fingerprint entries outside the SHA256 display form", () => { + // The tight form is what stops control chars (and plain junk) reaching a + // future audit row through a field that promises to be a fingerprint. + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["ok"] })).toBeNull(); // no prefix + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["SHA256:"] })).toBeNull(); // empty digest + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["SHA256:AAA\tBBB"] })).toBeNull(); // control char + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"A".repeat(129)}`] })).toBeNull(); // over the bound + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"a-_0".repeat(32)}`] })).not.toBeNull(); // alphabet, at bound + }); + + it("narrows the close: routing ref and a NAMED reason from the typed union, nothing else", () => { + // The census members are the ONLY accepted spellings; each round-trips. + for (const reason of SSH_RELAY_CLOSE_REASONS) { + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason })).toEqual({ + type: "ssh_relay_close", + ref: "r-4f2a", + reason, + }); + } + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant-revoked" })).toEqual({ type: "ssh_relay_close", ref: "r-4f2a", - reason: "grant_revoked", + reason: "grant-revoked", }); + // An unknown reason is a malformed close: §5.1's "closed with a named + // reason" cannot hold if the grammar accepts any string. + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "because-i-said-so" })).toBeNull(); + // The old underscore spellings are not the names either. + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a" })).toBeNull(); // reason required - expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime_expired" })).toBeNull(); // ref required - expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child_exit" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime-expiry" })).toBeNull(); // ref required + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child-exit" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: "" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: 3 })).toBeNull(); + // A stray member on a valid close does not ride (the open arm's rebuild + // style; the close always rebuilt, and it still drops the extra). + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-1", reason: "a-dropped", extra: 1 })).toEqual({ + type: "ssh_relay_close", + ref: "r-1", + reason: "a-dropped", + }); }); }); diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts index 91ee2ee02..f61b33ebb 100644 --- a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { NODE_PROTOCOL_VERSION, parseNodeEvent, parseRelayFrame } from "../node-frames.js"; +import { + NODE_PROTOCOL_VERSION, + parseNodeEvent, + parseRelayFrame, + relayFrameOverCap, + relayFrameRefIfOverCap, +} from "../node-frames.js"; import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; /** @@ -73,6 +79,33 @@ describe("parseRelayFrame", () => { expect(parseRelayFrame(JSON.stringify([good]))).toBeNull(); // an array is not a record either }); + it("parseRelayFrame stays STRICT about over-cap, and the over-cap probe names it (Task 4 review)", () => { + // The strict parse answers an over-cap blob with the same null as junk, + // which keeps the NodeEvent union safe but is invisible to the future + // broker (§5.1 owes the refusal a named close). relayFrameOverCap is the + // tiebreaker: true ONLY for a well-formed candidate whose decoded blob + // is over the cap; the blob is measured, never decoded. + const over = { ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES + 1) }; + expect(parseRelayFrame(over)).toBeNull(); // strict, unchanged + expect(relayFrameOverCap(over)).toBe(true); + expect(relayFrameRefIfOverCap(over)).toBe("r-4f2a"); // the ref a named close needs + expect(relayFrameOverCap(JSON.stringify(over))).toBe(true); // raw-text intake + // At the cap is lawful, not over-cap. + expect(relayFrameOverCap({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) })).toBe(false); + expect(relayFrameRefIfOverCap({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) })).toBeNull(); + // A malformed shape is NOT an over-cap, even when its blob is huge: the + // probe follows the same shape checks as the strict parse, in order. + expect(relayFrameOverCap({ ...over, direction: "sideways" })).toBe(false); + expect(relayFrameOverCap({ ...over, seq: -1 })).toBe(false); + expect(relayFrameOverCap({ ...over, ref: "" })).toBe(false); + expect(relayFrameOverCap({ ...over, blob: "!!!" })).toBe(false); + // Junk answers false/false, never an error. + expect(relayFrameOverCap("nope")).toBe(false); + expect(relayFrameRefIfOverCap("nope")).toBeNull(); + expect(relayFrameOverCap(null)).toBe(false); + expect(relayFrameOverCap(good)).toBe(false); + }); + it("the relay kind joins the node-event narrowing (the plane's one inbound parse)", () => { // Every relay frame the PLANE sees arrives on the agent-to-plane inbound // path, which dispatches through parseNodeEvent; the kind must come back diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index c3ccb2c85..b2f24c4ff 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -115,6 +115,8 @@ export { partPathOf, type RelayDirection, type RelayFrame, + relayFrameOverCap, + relayFrameRefIfOverCap, } from "./node-frames.js"; export { type DetectResultWire, @@ -249,10 +251,12 @@ export { export { parseSshNodeCommandBody, SSH_COMMAND_TYPES, + SSH_RELAY_CLOSE_REASONS, type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, type SshRelayCloseCommand, + type SshRelayCloseReason, type SshRelayOpenCommand, type SshRelayRole, type SshResolveConfigCommand, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 1d71a8f99..47ef83d1d 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1365,6 +1365,44 @@ export function parseRelayFrame(raw: string | object): RelayFrame | null { return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; } +/** + * The routing ref of a WELL-FORMED `relay` candidate (the same shape checks + * {@link parseRelayFrame} runs, in the same order) whose decoded blob + * EXCEEDS SSH_RELAY_FRAME_MAX_BYTES - i.e. exactly the one refusal reason + * `parseRelayFrame` answers with a plain null, which §5.1 says must end the + * session with a NAMED reason instead. The strict parse stays strict (the + * `NodeEvent` union must not carry an over-cap frame); this probe exists so + * the caller that just got a null back can tell "junk, drop quietly" from + * "over-cap, name the refusal". Only shape + the raw length of the blob are + * examined; the bytes themselves are never decoded or copied (§5.5's + * blindness). Junk of any kind answers null, never an error. + * + * @param value - candidate frame, raw JSON text or the already-parsed object + * @returns the routing ref when the candidate is relay-shaped AND over-cap, + * null for anything else (junk, malformed relay, under-cap) + */ +export function relayFrameRefIfOverCap(value: unknown): string | null { + let parsed: unknown = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + return null; + } + } + if (!isRecord(parsed) || parsed.type !== "relay") return null; + if (!isStr(parsed.ref) || parsed.ref.length === 0) return null; + if (!isInt(parsed.seq) || (parsed.seq as number) < 0) return null; + if (parsed.direction !== "B2A" && parsed.direction !== "A2B") return null; + if (!isStr(parsed.blob) || parsed.blob.length === 0 || !BASE64_RE.test(parsed.blob)) return null; + return base64RawLength(parsed.blob) > SSH_RELAY_FRAME_MAX_BYTES ? parsed.ref : null; +} + +/** Whether `value` is a well-formed `relay` candidate carrying an over-cap blob. */ +export function relayFrameOverCap(value: unknown): boolean { + return relayFrameRefIfOverCap(value) !== null; +} + export type NodeEvent = | { type: "ready"; diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 3e19810c1..4348f161d 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -10,9 +10,9 @@ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not * refused. The sealed agent-relay milestone (M2) adds three commands here: - * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node’s signing key reaches + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches * the plane inside a signed command on the existing node link), and the - * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane’s OPEN + * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN * hands one machine the whole pairing, the CLOSE names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which @@ -110,19 +110,43 @@ export interface SshRelayOpenCommand { lifetimeMs: number; } +/** + * Every reason a relay session may be closed with (spec 2026-10-08 §5.1/§5.6): + * §5.1's "closed with a named reason" is only kept if the grammar knows every + * name, so a close carrying anything else is malformed, not a fresh idea. + * - `handshake-grace`: the pairing's handshake window elapsed unanswered. + * - `child-exit`: the A-side agent child died; the session ends with it. + * - `lifetime-expiry`: SSH_RELAY_LIFETIME_MS ran out. + * - `a-dropped`: A's link dropped and no re-pair restored it. + * - `grant-revoked`: the grant underneath the session was revoked (§6.3). + * - `over-cap`: a frame over SSH_RELAY_FRAME_MAX_BYTES arrived (§5.1: cap + * is law; the refusal closes the session by name rather than silently). + */ +export const SSH_RELAY_CLOSE_REASONS = [ + "handshake-grace", + "child-exit", + "lifetime-expiry", + "a-dropped", + "grant-revoked", + "over-cap", +] as const; + +/** One named close reason; the runtime census is {@link SSH_RELAY_CLOSE_REASONS}. */ +export type SshRelayCloseReason = (typeof SSH_RELAY_CLOSE_REASONS)[number]; + /** * The brokered TEARDOWN (spec §5.1/§5.6): stop the session named by the - * routing ref and say why, in the open. The reason is a named cause - * (lifetime expiry, grant revoke, A drop, child exit, over-cap frame...) - * because §5.1's "closed with a named reason" is the whole failure-mode - * posture; an unnamed close is the silence this family refuses. + * routing ref and say why, in the open. The reason is a NAMED cause drawn + * from {@link SSH_RELAY_CLOSE_REASONS} because §5.1's "closed with a named + * reason" is the whole failure-mode posture; an unnamed close is the silence + * this family refuses. */ export interface SshRelayCloseCommand { type: "ssh_relay_close"; /** The routing ref of the session to tear down. */ ref: string; /** The named reason this session ends; relayed to the endpoint's log. */ - reason: string; + reason: SshRelayCloseReason; } /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ @@ -163,15 +187,28 @@ function isIdStr(value: unknown): value is string { } /** - * The grant's selected fingerprint set: strings, every one non-empty (an - * empty entry matches no key yet would ride a grant as a silent never-serve - * line), and at most {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a - * refusal at the grammar so the truncation nobody may silently perform is - * impossible by construction (§5.4). An EMPTY set parses: §5.4's - * "names no fingerprint, serves nothing" is a decision, not a defect. + * The OpenSSH display notation the grant stores: the `SHA256:` prefix over + * base64url digest text (43 chars in practice; bounded generously at 128). + * Shape, not truth - whether the digest matches a key is the responder's + * §5.4 enforcement. The alphabet rule is also what stops control characters + * reaching a future audit row through a field that promises to be a + * fingerprint. + */ +const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; + +/** + * The grant's selected fingerprint set: strings, every one in the `SHA256:` + * display form (an empty or free-text entry matches no key yet would ride a + * grant as a silent never-serve line), and at most + * {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a refusal at the grammar + * so the truncation nobody may silently perform is impossible by construction + * (§5.4). An EMPTY set parses: §5.4's "names no fingerprint, serves nothing" + * is a decision, not a defect. */ function isGrantFingerprints(value: unknown): value is string[] { - return isStrArray(value) && value.every((f) => f.length > 0) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; + return ( + isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS + ); } /** @@ -210,10 +247,23 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu ) { return null; } - // The signing half is a JSON public JWK - the grammar proves it is a - // non-empty string; importability and the private-part refusal are the - // server's gate, beside the one enroll and `ssh_register_identity` run. + // The signing half is a JSON public JWK. The shape gate mirrors + // `parseNodeSshIdentity` (node-results.ts) for the enroll answer: a + // non-empty string AND `JSON.parse` yielding a plain record - and it + // also refuses the shallow private-material tell (a top-level `d` + // member), so "hello", a JSON scalar/array, and a serialized PRIVATE + // JWK all read as malformed here. Deep private-material and key-validity + // refusal stays at `bytesOfJwk` on the node (Task 6), which is beside + // the one import; this gate keeps obvious nonsense out of the signed + // command's narrowed body. if (!isIdStr(value.peerSigningPublicKey)) return null; + let peerSigningJwk: unknown; + try { + peerSigningJwk = JSON.parse(value.peerSigningPublicKey); + } catch { + return null; + } + if (!isRecord(peerSigningJwk) || "d" in peerSigningJwk) return null; // The encryption half is a base64 key (the link pin's own spelling); a // present-but-undecodable one is malformed, not "the pin sorts it out". if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; @@ -221,14 +271,32 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS // as the ceiling the broker sends) is the broker's, this is the shape. if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; - return value as unknown as SshRelayOpenCommand; + // Rebuilt from the validated fields, never a cast of the candidate: a + // stray member on the wire drops here, exactly as the close arm drops. + return { + type: "ssh_relay_open", + relayId: value.relayId, + ref: value.ref, + role: value.role, + aNodeId: value.aNodeId, + bNodeId: value.bNodeId, + peerSigningPublicKey: value.peerSigningPublicKey, + peerEncryptPublicKey: value.peerEncryptPublicKey, + grantId: value.grantId, + fingerprints: [...value.fingerprints], + lifetimeMs: value.lifetimeMs as number, + }; + } + case "ssh_relay_close": { + // Ref + a reason from the NAMED set, and nothing else may ride it: the + // close selects a session, it does not amend one (§5.1/§5.6). Membership + // in SSH_RELAY_CLOSE_REASONS is the check - §5.1's "closed with a named + // reason" is worthless as a free string, so an unknown reason is a + // malformed close refused at the grammar. + if (!isIdStr(value.ref) || !isStr(value.reason)) return null; + if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(value.reason)) return null; + return { type: "ssh_relay_close", ref: value.ref, reason: value.reason as SshRelayCloseReason }; } - case "ssh_relay_close": - // Ref + named reason, and nothing else may ride it: the close selects - // a session, it does not amend one (§5.1/§5.6). - return isIdStr(value.ref) && isIdStr(value.reason) - ? { type: "ssh_relay_close", ref: value.ref, reason: value.reason } - : null; default: return null; } From b03e73225b24d47242ee04c480cbd6416c4b79e5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:34:28 -0700 Subject: [PATCH 091/170] feat(ssh-relay): sealed envelope codec with signed canonical payload + seq gate --- .../src/__tests__/ssh-relay.test.ts | 566 ++++++++++++++++++ packages/subshell-protocol/src/index.ts | 25 + packages/subshell-protocol/src/ssh-relay.ts | 544 +++++++++++++++++ 3 files changed, 1135 insertions(+) create mode 100644 packages/subshell-protocol/src/__tests__/ssh-relay.test.ts create mode 100644 packages/subshell-protocol/src/ssh-relay.ts diff --git a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts new file mode 100644 index 000000000..74af5424d --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts @@ -0,0 +1,566 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { exportJWK, flattenedDecrypt, GeneralEncrypt, generateKeyPair, importJWK, SignJWT } from "jose"; +import { BASE64_RE } from "../guards.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; +import { base64UrlNoPad, base64UrlToBytes } from "../ssh-pin-store.js"; +import { + bindNonces, + canonicalizeRelayMessage, + newNonce, + openRelayEnvelope, + RELAY_SIG_ISSUER, + type RelayInnerMessage, + type RelayOwnIdentity, + type RelaySealFn, + type SealRelayEnvelopeInput, + SeqGate, + sealRelayEnvelope, + signRelayEnvelope, + verifyRelayEnvelope, +} from "../ssh-relay.js"; + +/** + * The relay envelope codec (spec 2026-10-08 §5.6): canonicalization binds the + * fields, the ES256 signature carries origin, the seal carries confidentiality, + * and the SeqGate carries anti-replay. The seal/open pair is INJECTED (the + * production one is `packages/mcp-core/src/crypto.ts`; this package must not + * import it, or the workspace cycle would break the build order and Metro). + * The doubles below mirror crypto.ts's jose recipe verbatim so the codec is + * exercised against the real wire shape: GeneralEncrypt ECDH-ES+A256KW / + * A256GCM with a plaintext recipient `kid`, and the flattened-slot open. + */ + +/* ---------------------------- key material ---------------------------- */ + +async function es256Jwks(): Promise<{ privateJwk: JsonWebKey; publicJwk: JsonWebKey }> { + const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true }); + return { + privateJwk: (await exportJWK(privateKey)) as unknown as JsonWebKey, + publicJwk: (await exportJWK(publicKey)) as unknown as JsonWebKey, + }; +} + +async function ecdhStrings(): Promise<{ publicJwk: string; privateJwk: string }> { + // Same recipe as mcp-core's generateKeypair: P-256 ECDH-ES, JWK JSON strings. + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +/* ------------------------- seal/open doubles -------------------------- */ + +const sealImpl: RelaySealFn = async (text, recipients) => { + if (recipients.length === 0) throw new Error("seal requires at least one recipient"); + const ge = new GeneralEncrypt(new TextEncoder().encode(text)); + ge.setProtectedHeader({ alg: "ECDH-ES+A256KW", enc: "A256GCM" }); + for (const r of recipients) { + ge.addRecipient(await importJWK(JSON.parse(r.publicJwk), "ECDH-ES")).setUnprotectedHeader({ kid: r.principalId }); + } + const envelope = await ge.encrypt(); + return { envelope: JSON.stringify(envelope), recipientIds: recipients.map((r) => r.principalId) }; +}; + +const openImpl = async (envelope: string, own: RelayOwnIdentity): Promise => { + const env = JSON.parse(envelope) as { + protected?: string; + iv: string; + ciphertext: string; + tag: string; + recipients: { header?: { kid?: string }; encrypted_key: string }[]; + }; + const mine = (env.recipients ?? []).find((r) => r.header?.kid === own.principalId); + if (!mine) throw new Error(`envelope has no recipient slot for principal: ${own.principalId}`); + const { plaintext } = await flattenedDecrypt( + { + protected: env.protected, + header: mine.header, + encrypted_key: mine.encrypted_key, + iv: env.iv, + tag: env.tag, + ciphertext: env.ciphertext, + }, + await importJWK(JSON.parse(own.privateJwk), "ECDH-ES"), + ); + return new TextDecoder().decode(plaintext); +}; + +/* ----------------------------- a message ------------------------------ */ + +const NB = base64UrlNoPad(Uint8Array.from({ length: 16 }, (_, i) => i)); +const NA = base64UrlNoPad(Uint8Array.from({ length: 16 }, (_, i) => i + 16)); +const AGENT = base64UrlNoPad(Uint8Array.from([0x00, 0x00, 0x00, 0x0d])); + +function relayMessage(overrides: Partial = {}): RelayInnerMessage { + return { + relaySessionId: "relay-7d21", + routingRef: "rt-04a1", + direction: "B2A", + seq: 0, + nB: NB, + agentBytesB64: AGENT, + ...overrides, + }; +} + +/* ------------------------------ canonicalize -------------------------- */ + +describe("canonicalizeRelayMessage", () => { + it("is insertion-order independent (same logical message, two field orders)", () => { + const ordered = { + agentBytesB64: AGENT, + direction: "B2A", + nA: NA, + nB: NB, + relaySessionId: "relay-7d21", + routingRef: "rt-04a1", + seq: 3, + } satisfies RelayInnerMessage; + const shuffled = { + seq: 3, + routingRef: "rt-04a1", + nB: NB, + relaySessionId: "relay-7d21", + direction: "B2A", + agentBytesB64: AGENT, + nA: NA, + } satisfies RelayInnerMessage; + expect(canonicalizeRelayMessage(ordered)).toBe(canonicalizeRelayMessage(shuffled)); + // And the same again through a JSON round trip of the object itself. + expect(canonicalizeRelayMessage(JSON.parse(JSON.stringify(shuffled)))).toBe(canonicalizeRelayMessage(ordered)); + }); + + it("emits sorted-key JSON (the canonical spelling is fixed, not incidental)", () => { + const s = canonicalizeRelayMessage(relayMessage({ nA: NA, seq: 42 })); + expect(Object.keys(JSON.parse(s) as Record)).toEqual([ + "agentBytesB64", + "direction", + "nA", + "nB", + "relaySessionId", + "routingRef", + "seq", + ]); + // Fixed number formatting: an integer spells as itself, no exponent form. + expect(s).toContain('"seq":42'); + }); + + it("is change-sensitive on EVERY bound field", () => { + const base = canonicalizeRelayMessage(relayMessage()); + const variants: RelayInnerMessage[] = [ + relayMessage({ relaySessionId: "relay-7d22" }), + relayMessage({ routingRef: "rt-04a2" }), + relayMessage({ direction: "A2B" }), + relayMessage({ seq: 1 }), + relayMessage({ nB: NA }), // swapped nonce value + relayMessage({ nA: NA }), // nonce added (was absent) + relayMessage({ agentBytesB64: base64UrlNoPad(Uint8Array.from([0x00, 0x00, 0x00, 0x0b])) }), + relayMessage({ agentBytesB64: "" }), // empty is its own message + ]; + const strings = variants.map((v) => canonicalizeRelayMessage(v)); + for (const s of strings) expect(s).not.toBe(base); + // Pairwise distinct too - two mutations never collide. + const set = new Set([base, ...strings]); + expect(set.size).toBe(strings.length + 1); + }); + + it("binds nonce PRESENCE: nB-only, both, and neither are three different strings", () => { + const nBOnly = canonicalizeRelayMessage(relayMessage()); + const both = canonicalizeRelayMessage(relayMessage({ nA: NA })); + const neither = canonicalizeRelayMessage(relayMessage({ nB: undefined })); + expect(new Set([nBOnly, both, neither]).size).toBe(3); + expect(Object.keys(JSON.parse(neither) as Record)).not.toContain("nA"); + }); + + it("refuses malformed messages rather than canonicalizing junk into a signature", () => { + expect(() => canonicalizeRelayMessage(relayMessage({ direction: "C2D" as "B2A" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: 1.5 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: -1 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: Number.NaN }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: 2 ** 53 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ relaySessionId: "" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ routingRef: "" }))).toThrow(); + // Standard-base64 spellings are NOT base64url: refused, not repaired. + expect(() => canonicalizeRelayMessage(relayMessage({ agentBytesB64: "ab+cd/ef==" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ nB: NB.slice(0, 21) }))).toThrow(); // 15.75 bytes + expect(() => canonicalizeRelayMessage(relayMessage({ nA: `${NB}=` }))).toThrow(); // stray padding + }); +}); + +/* ------------------------------ bindNonces ---------------------------- */ + +describe("bindNonces", () => { + it("passes the present nonces through and omits the absent", () => { + expect(bindNonces()).toEqual({}); + expect(bindNonces(undefined, NB)).toEqual({ nB: NB }); + expect(bindNonces(NA, NB)).toEqual({ nA: NA, nB: NB }); + }); + it("refuses anything that is not a 128-bit base64url nonce", () => { + expect(() => bindNonces("short")).toThrow(); // too short to be 16 bytes + expect(() => bindNonces(`${NA}A`)).toThrow(); // 23 chars: no byte string is 16 and spells 23 + expect(() => bindNonces(undefined, `${NA.slice(0, 20)}+/`)).toThrow(); // standard-base64 chars + }); +}); + +/* -------------------------------- nonces ------------------------------ */ + +describe("newNonce", () => { + it("mints distinct 128-bit base64url nonces", () => { + const seen = new Set(); + for (let i = 0; i < 64; i++) { + const n = newNonce(); + expect(n).toMatch(/^[A-Za-z0-9_-]+$/); + expect(base64UrlToBytes(n)).toHaveLength(16); + seen.add(n); + } + expect(seen.size).toBe(64); + }); +}); + +/* --------------------------- sign and verify -------------------------- */ + +describe("signRelayEnvelope / verifyRelayEnvelope", () => { + it("a message signed by A verifies against A's pinned public key and yields the message back", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + expect(jws.split(".")).toHaveLength(3); // compact JWS + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }); + expect(got).toEqual(m); + }); + + it("FAILS against a different pinned key (a swapped machine key is a refused origin)", async () => { + const a = await es256Jwks(); + const b = await es256Jwks(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: relayMessage() }); + await expect( + verifyRelayEnvelope({ + jws, + publicJwk: b.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + }); + + it("flipping a bound field AFTER signing fails verify (signature covers the canonical string)", async () => { + const a = await es256Jwks(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: relayMessage() }); + const [header, payload, signature] = jws.split("."); + // Flip the last payload character to another alphabet character: the + // segment still base64url-decodes, but the canonical string inside the + // signed bytes changes, so the signature no longer covers the message. + const last = payload[payload.length - 1]; + const tamperedPayload = `${payload.slice(0, -1)}${last === "A" ? "B" : "A"}`; + const tampered = `${header}.${tamperedPayload}.${signature}`; + await expect( + verifyRelayEnvelope({ + jws: tampered, + publicJwk: a.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + // And a wrapper claiming the UNtampered routing triple fails the bind: + // verify must refuse a JWS whose canonical fields differ from the wrapper's. + const m2 = relayMessage({ seq: 5 }); + const jws2 = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m2 }); + await expect( + verifyRelayEnvelope({ + jws: jws2, + publicJwk: a.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + }); + + it("requires the relay issuer claim and the ES256 alg (header/claim swaps refused)", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + // A well-signed JWS WITHOUT our iss claim: right crypto, wrong document. + const key = (await importJWK( + { ...(a.privateJwk as object), kty: "EC", crv: "P-256" } as never, + "ES256", + )) as CryptoKey; + const noIss = await new SignJWT({ msg: canonicalizeRelayMessage(m) }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .sign(key); + await expect( + verifyRelayEnvelope({ + jws: noIss, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }), + ).rejects.toThrow(); + // A valid EdDSA signature over the same claims, presented to the ES256 pin: + // the alg allow-list refuses it before the key is even consulted. + const ed = await generateKeyPair("EdDSA", { extractable: true }); + const edPrivate = ed.privateKey as unknown as CryptoKey; + const swappedAlg = await new SignJWT({ iss: RELAY_SIG_ISSUER, msg: canonicalizeRelayMessage(m) }) + .setProtectedHeader({ alg: "EdDSA", typ: "JWT" }) + .sign(edPrivate); + await expect( + verifyRelayEnvelope({ + jws: swappedAlg, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }), + ).rejects.toThrow(); + }); + + it("refuses malformed JWS input without throwing anything uncatchable", async () => { + const a = await es256Jwks(); + for (const junk of ["", "not.a.jws", "a.b.c", `${"x".repeat(8)}.${"y".repeat(8)}.${"z".repeat(8)}`]) { + await expect( + verifyRelayEnvelope({ + jws: junk, + publicJwk: a.publicJwk, + expect: { routingRef: "r", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + } + }); +}); + +/* -------------------------------- SeqGate ----------------------------- */ + +describe("SeqGate", () => { + it("accepts 0,1,2 per direction and rejects an equal or lower seq", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", 0)).toBe(true); // the first accepted value may be 0 + expect(gate.accept("B2A", 1)).toBe(true); + expect(gate.accept("B2A", 2)).toBe(true); + expect(gate.accept("B2A", 2)).toBe(false); // resend + expect(gate.accept("B2A", 1)).toBe(false); // rewind + expect(gate.accept("B2A", 0)).toBe(false); + }); + + it("gaps are legal (the gate is monotonic, not contiguous)", () => { + const gate = new SeqGate(); + expect(gate.accept("A2B", 0)).toBe(true); + expect(gate.accept("A2B", 7)).toBe(true); + expect(gate.accept("A2B", 6)).toBe(false); + }); + + it("tracks the two directions independently", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", 0)).toBe(true); + expect(gate.accept("B2A", 1)).toBe(true); + expect(gate.accept("A2B", 0)).toBe(true); // A2B starts fresh at 0 though B2A is at 1 + expect(gate.accept("A2B", 1)).toBe(true); + expect(gate.accept("B2A", 0)).toBe(false); // B2A's own history still refuses + expect(gate.accept("A2B", 2)).toBe(true); + }); + + it("rejects non-integers and negatives outright", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", -1)).toBe(false); + expect(gate.accept("B2A", 0.5)).toBe(false); + expect(gate.accept("B2A", Number.NaN)).toBe(false); + expect(gate.accept("B2A", Number.POSITIVE_INFINITY)).toBe(false); + expect(gate.accept("B2A", 0)).toBe(true); // and the gate still works afterwards + }); +}); + +/* --------------------------- sealed envelope -------------------------- */ + +describe("sealRelayEnvelope / openRelayEnvelope", () => { + it("round-trips: open yields the JWS + routing fields, and it verifies to the original message", async () => { + const signer = await es256Jwks(); // B's signing key + const recipient = await ecdhStrings(); // A's pinned encryption key + const m = relayMessage({ nA: NA, seq: 2 }); // both nonces, a later message + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + expect(blob).toMatch(BASE64_RE); // what RelayFrame.blob requires + const opened = await openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }); + expect(opened.routingRef).toBe(m.routingRef); + expect(opened.direction).toBe(m.direction); + expect(opened.seq).toBe(m.seq); + const got = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: signer.publicJwk, + expect: { routingRef: opened.routingRef, direction: opened.direction, seq: opened.seq }, + }); + expect(got).toEqual(m); + }); + + it("opening with the WRONG recipient key throws (same label, foreign keypair)", async () => { + const signer = await es256Jwks(); + const right = await ecdhStrings(); + const wrong = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: right.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", publicJwk: wrong.publicJwk, privateJwk: wrong.privateJwk }, + open: openImpl, + }), + ).rejects.toThrow(); + }); + + it("opening under the WRONG principal label throws even with the right key (no slot)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ blob, own: { principalId: "node:B", ...recipient }, open: openImpl }), + ).rejects.toThrow(); + }); + + it("a tampered sealed blob throws", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + // Flip one alphabet character mid-blob (past the headers, inside the + // ciphertext region): base64 stays valid, the AEAD tag check must refuse. + const at = Math.floor(blob.length / 2); + const c = blob[at] === "A" ? "B" : "A"; + const tampered = `${blob.slice(0, at)}${c}${blob.slice(at + 1)}`; + await expect( + openRelayEnvelope({ blob: tampered, own: { principalId: "node:A", ...recipient }, open: openImpl }), + ).rejects.toThrow(); + }); + + it("refuses a blob that is not base64, and base64 that is not an envelope", async () => { + const recipient = await ecdhStrings(); + const own = { principalId: "node:A", ...recipient }; + await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: "", own, open: openImpl })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl })).rejects.toThrow(); + }); + + it("the sender refuses an envelope over SSH_RELAY_FRAME_MAX_BYTES (cap is law, both directions)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const fat = relayMessage({ agentBytesB64: "A".repeat(SSH_RELAY_FRAME_MAX_BYTES) }); + await expect( + sealRelayEnvelope({ + message: fat, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }), + ).rejects.toThrow(/cap|SSH_RELAY_FRAME_MAX_BYTES|too large|over/i); + }); + + it("open cross-checks the wrapper against the frame it rode (ref/direction swap refused)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: "rt-EVIL", direction: "B2A" }, + }), + ).rejects.toThrow(); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: "A2B" }, + }), + ).rejects.toThrow(); + }); + + it("the sealed wire blob carries no plaintext of the agent bytes or the JWS", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + expect(blob).not.toContain(m.agentBytesB64); + expect(atob(blob)).not.toContain("routingRef"); + }); + + it("sealRelayEnvelope refuses a malformed message before any crypto runs", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + let sealCalls = 0; + const input: SealRelayEnvelopeInput = { + message: relayMessage({ seq: -1 }), + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: async () => { + sealCalls += 1; + return { envelope: "", recipientIds: [] }; + }, + }; + await expect(sealRelayEnvelope(input)).rejects.toThrow(/non-negative safe integer/); + expect(sealCalls).toBe(0); + }); +}); + +/* ------------------------------ Metro purity -------------------------- */ + +describe("ssh-relay.ts is Metro-safe", () => { + it("has no node: builtin import in CODE", () => { + // Same pin as ssh-pin-store.test.ts: comments carry "node:" as prose, so + // the scanned text is the code with comments stripped. + const source = readFileSync(new URL("../ssh-relay.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); + }); +}); + +/** Standard base64 of text, for the junk-blob test (kept out of the codec). */ +function base64OfText(text: string): string { + const bytes = new TextEncoder().encode(text); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63]; + out += i + 1 < bytes.length ? alphabet[(n >> 6) & 63] : "="; + out += i + 2 < bytes.length ? alphabet[n & 63] : "="; + } + return out; +} diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index b2f24c4ff..ec9d76f11 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -277,6 +277,31 @@ export { // PURE module (WebCrypto globals only, no node: builtins): safe in the // Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; +// PURE module (jose + WebCrypto globals, no node: builtins; seal/open are +// injected, never imported - see the file header): safe in the Metro barrel; +// pinned by __tests__/ssh-relay.test.ts. +export { + bindNonces, + canonicalizeRelayMessage, + newNonce, + type OpenRelayEnvelopeInput, + openRelayEnvelope, + RELAY_SIG_ISSUER, + type RelayBinding, + type RelayEnvelopeOpen, + type RelayInnerMessage, + type RelayOpenFn, + type RelayOwnIdentity, + type RelaySealFn, + type RelaySealRecipient, + type SealRelayEnvelopeInput, + SeqGate, + type SignRelayEnvelopeInput, + sealRelayEnvelope, + signRelayEnvelope, + type VerifyRelayEnvelopeInput, + verifyRelayEnvelope, +} from "./ssh-relay.js"; export type { NodeSshAliasListResult, NodeSshIdentityResult, diff --git a/packages/subshell-protocol/src/ssh-relay.ts b/packages/subshell-protocol/src/ssh-relay.ts new file mode 100644 index 000000000..72ddf8dce --- /dev/null +++ b/packages/subshell-protocol/src/ssh-relay.ts @@ -0,0 +1,544 @@ +import { compactVerify, importJWK, type JWK, type JWTPayload, SignJWT } from "jose"; +import { BASE64_RE, isRecord, isStr } from "./guards.js"; +import type { RelayDirection } from "./node-frames.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; +import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; + +/** + * The sealed agent relay's envelope codec (spec 2026-10-08 §5.6). One ssh-agent + * message crosses the relay as a PURE data flow: + * + * message -> canonicalize (fixed sorted-key JSON, binds every field) + * -> sign (ES256 compact JWS over the canonical string; + * ORIGIN - §5.6: confidentiality is not authenticity) + * -> wrap ({ jws, routingRef, direction, seq } - the fields + * the receiver reads FIRST to locate the session, + * sealed so the plane never sees them either) + * -> seal (the injected mcp-core recipe: ECDH-ES+A256KW / + * A256GCM to the recipient's PINNED encryption key) + * -> base64 (the strict padded spelling RelayFrame.blob wants) + * + * `open` yields the JWS + routing fields, `verifyRelayEnvelope` binds the rest, + * and the receiver's {@link SeqGate} refuses resends and rewinds. The agent + * payload never leaves the signed-and-sealed inner: the plane routes blobs. + * + * seal/open are INJECTED, not imported: the production pair lives in + * `packages/mcp-core/src/crypto.ts`, which depends on this package - importing + * it back would be a workspace cycle (and Metro cannot see mcp-core at all). + * {@link RelaySealFn}/{@link RelayOpenFn} are mcp-core's exact signatures, so + * Tasks 6/7 pass `seal`/`open` straight through. The pin byte-equality check + * that produces those keys is the node-side MachinePinStore (§4.4), the + * caller's side of honesty; this codec never touches storage. + * + * PURE and Metro-safe: no `node:` builtin import (pinned by test); all crypto + * goes through jose (Bun's WebCrypto lacks EC importKey/exportKey - the same + * gap node-signing.ts papers over, so key import mirrors THAT file) and the + * WebCrypto globals `crypto.getRandomValues` / `crypto.subtle`. + */ + +/** `iss` claim every relay signature carries; the codec refuses any other. */ +export const RELAY_SIG_ISSUER = "subshell-ssh-relay"; + +/** Session nonces are 128 bits, base64url-spelled (spec §5.6). */ +const NONCE_BYTES = 16; + +/** The canonical message's field census, in the emission order (§5.6). */ +const MESSAGE_FIELDS = ["agentBytesB64", "direction", "nA", "nB", "relaySessionId", "routingRef", "seq"] as const; + +/** + * The signed inner message (spec §5.6): the signature covers a canonicalized + * encoding of EXACTLY these fields, so tampering any one of them - or moving + * an envelope to another session, direction, or slot - fails verification. + */ +export interface RelayInnerMessage { + /** The relay-session id the plane minted (closes cross-session replay) */ + relaySessionId: string; + /** The opaque routing ref this session's frames ride (binds envelope to lane) */ + routingRef: string; + /** Which way this message travels inside its session */ + direction: RelayDirection; + /** The SIGNED anti-replay seq: strictly increasing per direction, first may be 0 */ + seq: number; + /** B's endpoint nonce; present once B has sent (always in B2A, both in A2B) */ + nB?: string; + /** A's endpoint nonce; absent in B's very first message (A has not spoken yet) */ + nA?: string; + /** The agent payload, canonical base64url (no padding) - opaque to the codec */ + agentBytesB64: string; +} + +/** The sealed inner the wire carries: the JWS plus the locate-first fields. */ +export interface RelayEnvelopeOpen { + /** The compact JWS whose canonical payload binds everything else */ + jws: string; + /** Wrapper copy of the routing ref - cross-checked against the signature */ + routingRef: string; + /** Wrapper copy of the direction - cross-checked against the signature */ + direction: RelayDirection; + /** Wrapper copy of the SIGNED seq - the value the SeqGate judges */ + seq: number; +} + +/** A recipient to seal to: label + PUBLIC encryption JWK as a JSON string. */ +export interface RelaySealRecipient { + /** The address label sealed as the JWE recipient `kid` (e.g. `node:`) */ + principalId: string; + /** Public JWK JSON of the recipient's PINNED ECDH-ES encryption key */ + publicJwk: string; +} + +/** The opener's own identity: label + BOTH encryption JWK halves as strings. */ +export interface RelayOwnIdentity { + /** The principal label whose recipient slot to open (must match the `kid`) */ + principalId: string; + /** Public JWK JSON (kept for symmetry with mcp-core's IdentityKeyPair) */ + publicJwk: string; + /** Private JWK JSON - stays in the local process, like every private half */ + privateJwk: string; +} + +/** mcp-core `crypto.ts` seal's signature (structurally assignable to it). */ +export type RelaySealFn = ( + text: string, + recipients: RelaySealRecipient[], +) => Promise<{ envelope: string; recipientIds: string[] }>; + +/** mcp-core `crypto.ts` open's signature (throws on foreign/tampered input). */ +export type RelayOpenFn = (envelope: string, own: RelayOwnIdentity) => Promise; + +/** Inputs to {@link signRelayEnvelope}. */ +export interface SignRelayEnvelopeInput { + /** The sender's ES256 signing private JWK (the §4.1 per-machine key) */ + privateJwk: JsonWebKey; + /** The message to canonicalize and sign */ + message: RelayInnerMessage; +} + +/** The routing triple a verifier cross-checks the signature against. */ +export interface RelayBinding { + routingRef: string; + direction: RelayDirection; + seq: number; +} + +/** Inputs to {@link verifyRelayEnvelope}. */ +export interface VerifyRelayEnvelopeInput { + /** The compact JWS from the opened wrapper */ + jws: string; + /** The sender's PINNED ES256 signing public key (the §4.4 machine pin) */ + publicJwk: JsonWebKey; + /** + * The wrapper's locate-first fields. Every provided member MUST equal what + * the signature says, so a sealed-but-hostile wrapper cannot pair one + * session's ref with another session's signed bytes. + */ + expect?: Partial; +} + +/** Inputs to {@link sealRelayEnvelope}. */ +export interface SealRelayEnvelopeInput { + /** The message to canonicalize, sign, wrap, and seal */ + message: RelayInnerMessage; + /** The sender's ES256 signing private JWK */ + privateJwk: JsonWebKey; + /** The recipient's PINNED encryption key and its address label */ + recipient: RelaySealRecipient; + /** The sealing primitive (mcp-core's `seal`; see the file header) */ + seal: RelaySealFn; +} + +/** Inputs to {@link openRelayEnvelope}. */ +export interface OpenRelayEnvelopeInput { + /** The RelayFrame blob: strict padded base64 of the sealed envelope */ + blob: string; + /** THIS machine's encryption identity (the slot `kid` to open) */ + own: RelayOwnIdentity; + /** The opening primitive (mcp-core's `open`; see the file header) */ + open: RelayOpenFn; + /** + * The frame's own routing facts. When given, the wrapper must agree with + * them - a frame relayed under ref X cannot deliver an envelope addressed + * through ref Y, checked before any key material is consulted. + */ + expect?: { ref: string; direction: RelayDirection }; +} + +/** The EC (P-256) JWK shape jose's static types demand (mirrors node-signing). */ +type EcJwk = JWK & { kty: "EC"; crv: "P-256" }; + +/** Force a stored JWK into the EC/P-256 shape jose's types demand. */ +function asEcJwk(jwk: JsonWebKey): EcJwk { + return { ...(jwk as unknown as Record), kty: "EC", crv: "P-256" } as EcJwk; +} + +async function importPrivateES256(jwk: JsonWebKey): Promise { + return (await importJWK({ ...asEcJwk(jwk), d: jwk.d ?? "" }, "ES256")) as CryptoKey; +} + +async function importPublicES256(jwk: JsonWebKey): Promise { + return (await importJWK(asEcJwk(jwk), "ES256")) as CryptoKey; +} + +/* ------------------------------------------------------------------ */ +/* base64: the STANDARD (padded) spelling the wire's blob field uses */ +/* ------------------------------------------------------------------ */ + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +const BASE64_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64_ALPHABET.length; i++) { + values[BASE64_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ +function base64FromBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64_ALPHABET[(n >> 18) & 63]; + out += BASE64_ALPHABET[(n >> 12) & 63]; + out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; + out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; + } + return out; +} + +/** + * Decodes standard padded base64 to bytes. Callers pre-screen with + * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and + * non-canonical trailing bits - junk is refused, never repaired. + */ +function base64ToBytes(text: string): Uint8Array { + if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); + let pad = 0; + while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; + const body = text.slice(0, text.length - pad); + const out = new Uint8Array(Math.floor((body.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of body) { + const v = BASE64_VALUES[ch.charCodeAt(0)]; + if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); + value = (value << 6) | v; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { + throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* canonicalization (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** Refuse anything that is not a canonical 128-bit base64url nonce. */ +function requireNonce(label: string, nonce: unknown): string { + if (!isStr(nonce)) throw new TypeError(`ssh-relay: ${label} is not a base64url string`); + let bytes: Uint8Array; + try { + bytes = base64UrlToBytes(nonce); + } catch { + throw new TypeError(`ssh-relay: ${label} is not canonical base64url`); + } + if (bytes.length !== NONCE_BYTES) { + throw new TypeError(`ssh-relay: ${label} must be ${NONCE_BYTES} bytes (got ${bytes.length})`); + } + return nonce; +} + +/** + * The nonce pair as the canonical message spells it (spec §5.6): `nB` alone + * in B's first message, both once each side has seen the peer's, never `nA` + * without `nB` (B speaks first - though the codec only binds what is present, + * the endpoints own that protocol fact). Validates through {@link newNonce}'s + * shape: 16 bytes, canonical base64url. + */ +export function bindNonces(nA?: string, nB?: string): { nA?: string; nB?: string } { + const bound: { nA?: string; nB?: string } = {}; + if (nA !== undefined) bound.nA = requireNonce("nA", nA); + if (nB !== undefined) bound.nB = requireNonce("nB", nB); + return bound; +} + +/** Validate a loose record as the closed inner-message grammar, or throw. */ +function assertRelayInnerMessage(raw: Record): RelayInnerMessage { + for (const key of Object.keys(raw)) { + if (!(MESSAGE_FIELDS as readonly string[]).includes(key) && raw[key] !== undefined) { + throw new TypeError(`ssh-relay: unknown relay message field: ${key}`); + } + } + if (!isStr(raw.relaySessionId) || raw.relaySessionId.length === 0) { + throw new TypeError("ssh-relay: relaySessionId must be a non-empty string"); + } + if (!isStr(raw.routingRef) || raw.routingRef.length === 0) { + throw new TypeError("ssh-relay: routingRef must be a non-empty string"); + } + if (raw.direction !== "B2A" && raw.direction !== "A2B") { + throw new TypeError("ssh-relay: direction must be B2A or A2B"); + } + if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0) { + throw new TypeError("ssh-relay: seq must be a non-negative safe integer"); + } + if (!isStr(raw.agentBytesB64)) { + throw new TypeError("ssh-relay: agentBytesB64 must be a base64url string"); + } + try { + base64UrlToBytes(raw.agentBytesB64); // "" (empty payload) is canonical and legal + } catch { + throw new TypeError("ssh-relay: agentBytesB64 is not canonical base64url"); + } + const message: RelayInnerMessage = { + agentBytesB64: raw.agentBytesB64, + direction: raw.direction, + relaySessionId: raw.relaySessionId, + routingRef: raw.routingRef, + seq: raw.seq as number, + }; + const { nA, nB } = bindNonces(raw.nA as string | undefined, raw.nB as string | undefined); + if (nA !== undefined) message.nA = nA; + if (nB !== undefined) message.nB = nB; + return message; +} + +/** + * The deterministic canonical encoding the signature covers: JSON with the + * fixed (alphabetically sorted) field order above, integers in plain spelling, + * optional nonces emitted only when present. JS key insertion order cannot + * reach it (fields are read by NAME into a fresh object), and changing ANY + * field changes the string, so the signature binds every one. + */ +export function canonicalizeRelayMessage(m: RelayInnerMessage): string { + const message = assertRelayInnerMessage(m as unknown as Record); + const canonical: Record = {}; + for (const field of MESSAGE_FIELDS) { + const value = message[field as keyof RelayInnerMessage]; + if (value !== undefined) canonical[field] = value; + } + return JSON.stringify(canonical); +} + +/** Re-derive the message from its canonical string; throws on anything else. */ +function parseCanonicalRelayMessage(canonical: string): RelayInnerMessage { + let raw: unknown; + try { + raw = JSON.parse(canonical); + } catch { + throw new TypeError("ssh-relay: canonical payload is not JSON"); + } + if (!isRecord(raw)) throw new TypeError("ssh-relay: canonical payload is not an object"); + const message = assertRelayInnerMessage(raw); + // The re-derivation must reproduce the verified bytes EXACTLY: an encoding + // that differs only in spelling (key order, spacing) never came from here. + if (canonicalizeRelayMessage(message) !== canonical) { + throw new TypeError("ssh-relay: canonical payload is not in canonical spelling"); + } + return message; +} + +/* ------------------------------------------------------------------ */ +/* origin: the ES256 signature (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * Sign a relay message into a compact JWS: the canonical string rides as the + * single `msg` claim under our issuer. There is deliberately NO `exp` - the + * session's lifetime is the endpoints'/broker's cut (§5.6), and per-message + * expiry would make node clock skew an origin failure; replay is the seq + * gate's job, forgery is this signature's. + * + * @throws TypeError when the message is malformed (before any crypto runs) + */ +export async function signRelayEnvelope(input: SignRelayEnvelopeInput): Promise { + const key = await importPrivateES256(input.privateJwk); + return new SignJWT({ msg: canonicalizeRelayMessage(input.message) }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setIssuer(RELAY_SIG_ISSUER) + .sign(key); +} + +/** + * Verify a relay signature against the peer's PINNED signing key and return + * the message the canonical string spells. Every failure THROWS - callers + * treat any throw as origin refusal (the machine pin, not this codec, is what + * made `publicJwk` trustworthy). jose v6's `compactVerify` checks crypto and + * format only, so the `iss` claim is evaluated here, and `algorithms` pins + * ES256 so a header swap can never downgrade the check (node-signing's rule, + * verbatim). When `expect` is given, the wrapper's routing fields must match + * what the signature says - a mismatch is a refused binding, not a soft hint. + */ +export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Promise { + let payload: JWTPayload; + try { + const key = await importPublicES256(input.publicJwk); + const verified = await compactVerify(input.jws, key, { algorithms: ["ES256"] }); + payload = JSON.parse(new TextDecoder().decode(verified.payload)) as JWTPayload; + } catch { + throw new TypeError("ssh-relay: relay envelope signature is invalid"); + } + if (payload.iss !== RELAY_SIG_ISSUER || typeof payload.msg !== "string") { + throw new TypeError("ssh-relay: relay envelope claims are invalid"); + } + const message = parseCanonicalRelayMessage(payload.msg); + const expect = input.expect; + if (expect) { + if (expect.routingRef !== undefined && expect.routingRef !== message.routingRef) { + throw new TypeError("ssh-relay: routingRef does not match the signature"); + } + if (expect.direction !== undefined && expect.direction !== message.direction) { + throw new TypeError("ssh-relay: direction does not match the signature"); + } + if (expect.seq !== undefined && expect.seq !== message.seq) { + throw new TypeError("ssh-relay: seq does not match the signature"); + } + } + return message; +} + +/* ------------------------------------------------------------------ */ +/* the sealed envelope: wrap + seal, open + bind */ +/* ------------------------------------------------------------------ */ + +/** + * Produce the wire blob for one relay message: canonicalize, sign, wrap as + * `{ jws, routingRef, direction, seq }`, and seal the wrapper to the + * recipient's PINNED encryption key. The returned string is strict padded + * base64 of the General JWE JSON - exactly what `RelayFrame.blob` carries - + * and is refused here (not on the wire) when it would exceed + * SSH_RELAY_FRAME_MAX_BYTES: the cap is law on both sides of the link. + * + * @throws TypeError on a malformed message, recipient label/key, or over-cap blob + */ +export async function sealRelayEnvelope(input: SealRelayEnvelopeInput): Promise { + const message = assertRelayInnerMessage(input.message as unknown as Record); + if (!isStr(input.recipient?.principalId) || input.recipient.principalId.length === 0) { + throw new TypeError("ssh-relay: recipient principalId must be a non-empty string"); + } + if (!isStr(input.recipient.publicJwk) || input.recipient.publicJwk.length === 0) { + throw new TypeError("ssh-relay: recipient publicJwk must be a non-empty string"); + } + const jws = await signRelayEnvelope({ privateJwk: input.privateJwk, message }); + const wrapper = JSON.stringify({ + jws, + routingRef: message.routingRef, + direction: message.direction, + seq: message.seq, + }); + const { envelope } = await input.seal(wrapper, [ + { principalId: input.recipient.principalId, publicJwk: input.recipient.publicJwk }, + ]); + const bytes = new TextEncoder().encode(envelope); + if (bytes.length > SSH_RELAY_FRAME_MAX_BYTES) { + throw new TypeError( + `ssh-relay: sealed envelope is ${bytes.length} bytes, over the SSH_RELAY_FRAME_MAX_BYTES cap of ${SSH_RELAY_FRAME_MAX_BYTES}`, + ); + } + return base64FromBytes(bytes); +} + +/** + * Open a relay frame's blob with THIS machine's encryption key and return the + * JWS + routing fields. Throws when the blob is not base64, holds no slot for + * `own.principalId`, is not addressed to us cryptographically, or is tampered + * (the injected `open` fails all of those); the wrapper's shape is validated + * before it is handed back. This does NOT verify origin - that is + * {@link verifyRelayEnvelope} with the sender's pinned signing key, then the + * caller's {@link SeqGate}. Anyone holding the recipient's PUBLIC encryption + * key can craft a seal the recipient opens (§5.6's first line), so an opened + * wrapper is unsigned noise until the signature says otherwise. + */ +export async function openRelayEnvelope(input: OpenRelayEnvelopeInput): Promise { + if (!isStr(input.blob) || input.blob.length === 0 || !BASE64_RE.test(input.blob)) { + throw new TypeError("ssh-relay: blob is not strict padded base64"); + } + const envelopeText = new TextDecoder().decode(base64ToBytes(input.blob)); + const wrapperText = await input.open(envelopeText, input.own); + let wrapper: unknown; + try { + wrapper = JSON.parse(wrapperText); + } catch { + throw new TypeError("ssh-relay: opened envelope holds no JSON wrapper"); + } + if (!isRecord(wrapper)) throw new TypeError("ssh-relay: opened envelope wrapper is not an object"); + const { jws, routingRef, direction, seq, ...rest } = wrapper; + if (Object.keys(rest).length > 0) { + throw new TypeError("ssh-relay: relay wrapper carries unknown fields"); + } + if (!isStr(jws) || jws.length === 0) throw new TypeError("ssh-relay: wrapper jws is not a non-empty string"); + if (!isStr(routingRef) || routingRef.length === 0) { + throw new TypeError("ssh-relay: wrapper routingRef is not a non-empty string"); + } + if (direction !== "B2A" && direction !== "A2B") { + throw new TypeError("ssh-relay: wrapper direction is not B2A or A2B"); + } + if (!Number.isSafeInteger(seq) || (seq as number) < 0) { + throw new TypeError("ssh-relay: wrapper seq is not a non-negative safe integer"); + } + if (input.expect) { + if (input.expect.ref !== routingRef) { + throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); + } + if (input.expect.direction !== direction) { + throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); + } + } + return { jws, routingRef, direction, seq: seq as number }; +} + +/* ------------------------------------------------------------------ */ +/* replay: the per-direction seq gate (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * The per-direction anti-replay judge for ONE relay session (spec §5.6): the + * receiver keeps the highest accepted `seq` for each direction and accepts + * only a strictly greater one, so a multi-key handshake (several + * SIGN_REQUESTs, increasing seq) completes while a byte-identical resend or + * any rewind is refused - even though the plane routes every envelope and can + * resend any of them. The two directions are independent: B2A and A2B are + * different senders, and one's stream must not throttle the other. Built per + * session; seq restarts with the session (the signature binds the session id, + * so a stale envelope cannot ride a fresh session's gate). + */ +export class SeqGate { + #last: Record = { B2A: -1, A2B: -1 }; + + /** + * True iff `seq` advances this direction (first accepted value may be 0; + * gaps are legal, regressions and resends are not). Malformed seq (negative, + * fractional, non-finite) or an unknown direction answers false and changes + * nothing - a hostile frame must never move the watermark. + */ + accept(direction: RelayDirection, seq: number): boolean { + if (direction !== "B2A" && direction !== "A2B") return false; + if (!Number.isSafeInteger(seq) || seq < 0) return false; + if (seq <= this.#last[direction]) return false; + this.#last[direction] = seq; + return true; + } +} + +/* ------------------------------------------------------------------ */ +/* endpoint nonces (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * A fresh 128-bit session nonce, canonical base64url (22 characters). Each + * endpoint mints ONE at session start - B carries `nB` in its first sealed + * request, A carries `nA` in its first sealed response - and thereafter signs + * the PEER's value into every message, so an envelope from a different pairing + * never validates, byte-identical payload or not. WebCrypto's + * `crypto.getRandomValues` is a global in Bun, node, and the browser alike. + */ +export function newNonce(): string { + return base64UrlNoPad(crypto.getRandomValues(new Uint8Array(NONCE_BYTES))); +} From 1059f82ec808d691eb7e20d2de99dbc9d0097b91 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:42:06 -0700 Subject: [PATCH 092/170] fix(ssh-relay): Task 5 review - structural nonce binding, required expect, base64 hardening + split --- .../src/__tests__/ssh-base64-std.test.ts | 94 ++++++++++++ .../src/__tests__/ssh-relay.test.ts | 88 ++++++++++- packages/subshell-protocol/src/index.ts | 2 + packages/subshell-protocol/src/node-frames.ts | 17 ++- .../subshell-protocol/src/ssh-base64-std.ts | 68 +++++++++ packages/subshell-protocol/src/ssh-relay.ts | 144 ++++++++---------- 6 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts create mode 100644 packages/subshell-protocol/src/ssh-base64-std.ts diff --git a/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts b/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts new file mode 100644 index 000000000..620d217c7 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { BASE64_RE } from "../guards.js"; +import { base64FromBytes, base64ToBytes } from "../ssh-base64-std.js"; + +/** + * The standard padded base64 codec split out of ssh-relay.ts (Task 5 review + * M-5). The pins below are the contract the relay wire depends on: what this + * admits must stay exactly what BASE64_RE admits (Task 4's `parseRelayFrame` + * measures the raw length of such strings for the frame cap), and everything + * else is refused by name, never repaired. + */ + +function bytesOf(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +describe("base64FromBytes", () => { + it("encodes the known vectors, padded", () => { + expect(base64FromBytes(new Uint8Array(0))).toBe(""); + expect(base64FromBytes(bytesOf("A"))).toBe("QQ=="); + expect(base64FromBytes(bytesOf("AB"))).toBe("QUI="); + expect(base64FromBytes(bytesOf("ABC"))).toBe("QUJD"); + expect(base64FromBytes(Uint8Array.from([0x00, 0x01, 0x02]))).toBe("AAEC"); + }); + + it("agrees with the platform encoder and BASE64_RE for every input length", () => { + for (let len = 0; len <= 20; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 37) & 0xff); + const s = base64FromBytes(bytes); + expect(s).toMatch(BASE64_RE); // the blob grammar a relay frame must satisfy + // The same bytes through the Web-exposed encoder spell identically. + expect(s).toBe(btoa(String.fromCharCode(...Array.from(bytes)))); + } + }); +}); + +describe("base64ToBytes", () => { + it("round-trips what base64FromBytes emits", () => { + for (let len = 0; len <= 20; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 91 + 7) & 0xff); + expect(Array.from(base64ToBytes(base64FromBytes(bytes)))).toEqual(Array.from(bytes)); + } + }); + + it("decodes the known vectors", () => { + expect(Array.from(base64ToBytes("QQ=="))).toEqual([0x41]); + expect(Array.from(base64ToBytes("QUI="))).toEqual([0x41, 0x42]); + expect(Array.from(base64ToBytes("QUJD"))).toEqual([0x41, 0x42, 0x43]); + expect(Array.from(base64ToBytes(""))).toEqual([]); + }); + + it("refuses non-group lengths, mid-text padding, and non-canonical trailing bits", () => { + expect(() => base64ToBytes("QQQ")).toThrow(/padded base64 group/); + expect(() => base64ToBytes("Q=Q=")).toThrow(/non-base64 character/); // "=" is not alphabet + expect(() => base64ToBytes("=AAA")).toThrow(/non-base64 character/); + expect(() => base64ToBytes("AB==")).toThrow(/non-canonical trailing bits/); // 10 bits for 1 byte + }); + + it("refuses ASCII junk by name", () => { + expect(() => base64ToBytes("A!BC")).toThrow(/non-base64 character/); + expect(() => base64ToBytes("A BC")).toThrow(/non-base64 character/); // a space (0x20) + expect(() => base64ToBytes("AB-_")).toThrow(/non-base64 character/); // base64URL is not standard + }); + + it("refuses a code point at or above U+0080 instead of corrupting (M-2)", () => { + // The table spans codes 0..127 only: before the guard these read as + // `undefined`, sailed past the `v < 0` screen, and poisoned `value` with + // NaN. Each string below is a padded 4-char group whose body holds one + // high code point, and each must throw. + expect(() => base64ToBytes("Q\u00e9BC")).toThrow(/non-base64 character/); // U+00E9 + expect(() => base64ToBytes("Q\u3042BC")).toThrow(/non-base64 character/); // U+3042 + expect(() => base64ToBytes("Q\u2028BC")).toThrow(/non-base64 character/); // U+2028 + }); + + it("refuses the U+0080 boundary itself inside a group", () => { + expect(() => base64ToBytes("Q\u0080BC")).toThrow(/non-base64 character/); + }); +}); + +describe("ssh-base64-std.ts is Metro-safe", () => { + it("has no node: builtin import in CODE", () => { + // Same pin as ssh-relay.test.ts: comments carry "node:" as prose, so + // the scanned text is the code with comments stripped. + const source = readFileSync(new URL("../ssh-base64-std.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts index 74af5424d..a47bb7b3c 100644 --- a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts +++ b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts @@ -178,6 +178,7 @@ describe("canonicalizeRelayMessage", () => { expect(() => canonicalizeRelayMessage(relayMessage({ direction: "C2D" as "B2A" }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: 1.5 }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: -1 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: -0 }))).toThrow(); // -0 JSON-spells as 0: refused, not coerced (M-3) expect(() => canonicalizeRelayMessage(relayMessage({ seq: Number.NaN }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: 2 ** 53 }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ relaySessionId: "" }))).toThrow(); @@ -312,6 +313,53 @@ describe("signRelayEnvelope / verifyRelayEnvelope", () => { ).rejects.toThrow(); }); + it("enforces the bound nonces (I-1: the endpoint-minted replay defense, enforced when supplied)", async () => { + const a = await es256Jwks(); + const m = relayMessage({ nA: NA }); // both nonces present + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + const base = { routingRef: m.routingRef, direction: m.direction, seq: m.seq }; + // Matching bindings pass, exactly like the routing triple does. + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { ...base, nA: NA, nB: NB, relaySessionId: m.relaySessionId }, + }); + expect(got).toEqual(m); + // A different value for a bound nonce fails, in either spelling. + await expect(verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, nB: NA } })).rejects.toThrow( + /nB does not match/, + ); + await expect(verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, nA: NB } })).rejects.toThrow( + /nA does not match/, + ); + // A bound nonce the signed message does not carry is a mismatch too: + // B's first message signed without nA cannot satisfy a caller who bound one. + const mNoNA = relayMessage(); + const jwsNoNA = await signRelayEnvelope({ privateJwk: a.privateJwk, message: mNoNA }); + await expect( + verifyRelayEnvelope({ jws: jwsNoNA, publicJwk: a.publicJwk, expect: { ...base, nA: NA } }), + ).rejects.toThrow(/nA does not match/); + // An absent binding member enforces nothing (the caller may not hold it yet). + const loose = await verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: base }); + expect(loose).toEqual(m); + }); + + it("enforces the bound relaySessionId (a reused-ref envelope from another session is refused)", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + const base = { routingRef: m.routingRef, direction: m.direction, seq: m.seq }; + await expect( + verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, relaySessionId: "relay-EVIL" } }), + ).rejects.toThrow(/relaySessionId does not match/); + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { ...base, relaySessionId: m.relaySessionId }, + }); + expect(got).toEqual(m); + }); + it("refuses malformed JWS input without throwing anything uncatchable", async () => { const a = await es256Jwks(); for (const junk of ["", "not.a.jws", "a.b.c", `${"x".repeat(8)}.${"y".repeat(8)}.${"z".repeat(8)}`]) { @@ -413,6 +461,7 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { blob, own: { principalId: "node:A", publicJwk: wrong.publicJwk, privateJwk: wrong.privateJwk }, open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, }), ).rejects.toThrow(); }); @@ -428,7 +477,12 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { seal: sealImpl, }); await expect( - openRelayEnvelope({ blob, own: { principalId: "node:B", ...recipient }, open: openImpl }), + openRelayEnvelope({ + blob, + own: { principalId: "node:B", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }), ).rejects.toThrow(); }); @@ -448,16 +502,24 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { const c = blob[at] === "A" ? "B" : "A"; const tampered = `${blob.slice(0, at)}${c}${blob.slice(at + 1)}`; await expect( - openRelayEnvelope({ blob: tampered, own: { principalId: "node:A", ...recipient }, open: openImpl }), + openRelayEnvelope({ + blob: tampered, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }), ).rejects.toThrow(); }); it("refuses a blob that is not base64, and base64 that is not an envelope", async () => { const recipient = await ecdhStrings(); const own = { principalId: "node:A", ...recipient }; - await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl })).rejects.toThrow(); - await expect(openRelayEnvelope({ blob: "", own, open: openImpl })).rejects.toThrow(); - await expect(openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl })).rejects.toThrow(); + const binding = { ref: "rt-04a1", direction: "B2A" as const }; + await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl, expect: binding })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: "", own, open: openImpl, expect: binding })).rejects.toThrow(); + await expect( + openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl, expect: binding }), + ).rejects.toThrow(); }); it("the sender refuses an envelope over SSH_RELAY_FRAME_MAX_BYTES (cap is law, both directions)", async () => { @@ -532,6 +594,22 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { await expect(sealRelayEnvelope(input)).rejects.toThrow(/non-negative safe integer/); expect(sealCalls).toBe(0); }); + + it("open refuses a wrapper whose seq is spelled -0 (a hostile sender can write it; JSON.parse yields -0)", async () => { + const recipient = await ecdhStrings(); + // sealImpl takes arbitrary text: exactly what a hostile sender seals. + const { envelope } = await sealImpl('{"jws":"a.b.c","routingRef":"rt-04a1","direction":"B2A","seq":-0}', [ + { principalId: "node:A", publicJwk: recipient.publicJwk }, + ]); + await expect( + openRelayEnvelope({ + blob: base64OfText(envelope), + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: "rt-04a1", direction: "B2A" }, + }), + ).rejects.toThrow(/non-negative safe integer/); + }); }); /* ------------------------------ Metro purity -------------------------- */ diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index ec9d76f11..8a4d1d276 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -72,6 +72,7 @@ export { type HarnessSessionWire, isNodeServiceVerb, isNodeSubshellId, + isRelayDirection, type LaunchResolveWire, MAX_ARCHIVE_BYTES, MAX_ARCHIVE_ENTRIES, @@ -113,6 +114,7 @@ export { parseNodeSshEnabled, parseRelayFrame, partPathOf, + RELAY_DIRECTIONS, type RelayDirection, type RelayFrame, relayFrameOverCap, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 47ef83d1d..d5bf04075 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1288,8 +1288,19 @@ export type PluginReportWire = { /* sealed agent relay link frame (spec 2026-10-08 §5.1) */ /* ------------------------------------------------------------------ */ +/** + * The relay directions as a runtime list beside the type: one definition, and + * everything that iterates or validates reads these (code-style rule). + */ +export const RELAY_DIRECTIONS = ["B2A", "A2B"] as const; + /** Which way one relay envelope travels inside its session (spec §5.1). */ -export type RelayDirection = "B2A" | "A2B"; +export type RelayDirection = (typeof RELAY_DIRECTIONS)[number]; + +/** Whether `value` is one of the two relay directions (narrows to the type). */ +export function isRelayDirection(value: unknown): value is RelayDirection { + return (RELAY_DIRECTIONS as readonly unknown[]).includes(value); +} /** * The `relay` link frame: ONE sealed envelope per frame, on the established @@ -1359,7 +1370,7 @@ export function parseRelayFrame(raw: string | object): RelayFrame | null { if (!isRecord(value) || value.type !== "relay") return null; if (!isStr(value.ref) || value.ref.length === 0) return null; if (!isInt(value.seq) || (value.seq as number) < 0) return null; - if (value.direction !== "B2A" && value.direction !== "A2B") return null; + if (!isRelayDirection(value.direction)) return null; if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; if (base64RawLength(value.blob) > SSH_RELAY_FRAME_MAX_BYTES) return null; return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; @@ -1393,7 +1404,7 @@ export function relayFrameRefIfOverCap(value: unknown): string | null { if (!isRecord(parsed) || parsed.type !== "relay") return null; if (!isStr(parsed.ref) || parsed.ref.length === 0) return null; if (!isInt(parsed.seq) || (parsed.seq as number) < 0) return null; - if (parsed.direction !== "B2A" && parsed.direction !== "A2B") return null; + if (!isRelayDirection(parsed.direction)) return null; if (!isStr(parsed.blob) || parsed.blob.length === 0 || !BASE64_RE.test(parsed.blob)) return null; return base64RawLength(parsed.blob) > SSH_RELAY_FRAME_MAX_BYTES ? parsed.ref : null; } diff --git a/packages/subshell-protocol/src/ssh-base64-std.ts b/packages/subshell-protocol/src/ssh-base64-std.ts new file mode 100644 index 000000000..0a51003a5 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-base64-std.ts @@ -0,0 +1,68 @@ +/** + * The STANDARD (padded) base64 spelling the relay wire's `blob` field uses, + * as a pure module: no `node:` builtin, no `Buffer` (pinned by test, same + * regime as ssh-relay.ts, which imports this file through the Metro-consumed + * barrel). These are the exact helpers that lived inline in ssh-relay.ts; + * Task 5 review M-5 split them out to keep that file inside the size + * guideline. No logic change: the acceptance set stays BASE64_RE-compatible + * (Task 4's `parseRelayFrame` cap rule measures the raw payload of strings + * this grammar admits), and junk is refused, never repaired. + */ + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +const BASE64_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64_ALPHABET.length; i++) { + values[BASE64_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ +export function base64FromBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64_ALPHABET[(n >> 18) & 63]; + out += BASE64_ALPHABET[(n >> 12) & 63]; + out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; + out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; + } + return out; +} + +/** + * Decodes standard padded base64 to bytes. Callers pre-screen with + * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and + * non-canonical trailing bits - junk is refused, never repaired. A code point + * at or above U+0080 is NOT in the table's range: it is refused by name + * (Task 5 review M-2 - reading past the table yields `undefined`, which + * `v < 0` would wave through and silently corrupt into NaN-poisoned bytes). + */ +export function base64ToBytes(text: string): Uint8Array { + if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); + let pad = 0; + while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; + const body = text.slice(0, text.length - pad); + const out = new Uint8Array(Math.floor((body.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of body) { + const code = ch.charCodeAt(0); + const v = code < 128 ? BASE64_VALUES[code] : -1; + if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); + value = (value << 6) | v; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { + throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); + } + return out; +} diff --git a/packages/subshell-protocol/src/ssh-relay.ts b/packages/subshell-protocol/src/ssh-relay.ts index 72ddf8dce..bbd562f02 100644 --- a/packages/subshell-protocol/src/ssh-relay.ts +++ b/packages/subshell-protocol/src/ssh-relay.ts @@ -1,6 +1,7 @@ import { compactVerify, importJWK, type JWK, type JWTPayload, SignJWT } from "jose"; import { BASE64_RE, isRecord, isStr } from "./guards.js"; -import type { RelayDirection } from "./node-frames.js"; +import { isRelayDirection, type RelayDirection } from "./node-frames.js"; +import { base64FromBytes, base64ToBytes } from "./ssh-base64-std.js"; import { SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; @@ -16,7 +17,8 @@ import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; * sealed so the plane never sees them either) * -> seal (the injected mcp-core recipe: ECDH-ES+A256KW / * A256GCM to the recipient's PINNED encryption key) - * -> base64 (the strict padded spelling RelayFrame.blob wants) + * -> base64 (the strict padded spelling RelayFrame.blob wants; + * that codec lives in ssh-base64-std.ts) * * `open` yields the JWS + routing fields, `verifyRelayEnvelope` binds the rest, * and the receiver's {@link SeqGate} refuses resends and rewinds. The agent @@ -114,11 +116,26 @@ export interface SignRelayEnvelopeInput { message: RelayInnerMessage; } -/** The routing triple a verifier cross-checks the signature against. */ +/** + * The facts a verifier cross-checks the signature against: the routing + * triple the wrapper locates its session by, plus the session facts only the + * ENDPOINT knows (I-1, Task 5 review): when the caller holds a session, it + * supplies what it recorded and {@link verifyRelayEnvelope} enforces every + * present member. The codec owns none of these facts itself. + */ export interface RelayBinding { + /** The opaque routing ref the session's frames ride */ routingRef: string; + /** Which way the message travels inside the session */ direction: RelayDirection; + /** The signed anti-replay seq */ seq: number; + /** B's endpoint nonce as this endpoint recorded it for this relay-open */ + nB?: string; + /** A's endpoint nonce as this endpoint recorded it for this relay-open */ + nA?: string; + /** The relay-session id from the `ssh_relay_open` command */ + relaySessionId?: string; } /** Inputs to {@link verifyRelayEnvelope}. */ @@ -128,9 +145,12 @@ export interface VerifyRelayEnvelopeInput { /** The sender's PINNED ES256 signing public key (the §4.4 machine pin) */ publicJwk: JsonWebKey; /** - * The wrapper's locate-first fields. Every provided member MUST equal what - * the signature says, so a sealed-but-hostile wrapper cannot pair one - * session's ref with another session's signed bytes. + * The wrapper's locate-first fields and, once the caller holds session + * state, the nonces and relay-session id it recorded (see + * {@link RelayBinding}). Every provided member MUST equal what the + * signature says, so a sealed-but-hostile wrapper cannot pair one + * session's ref with another session's signed bytes, and an envelope + * carrying another pairing's nonce is refused the same way. */ expect?: Partial; } @@ -156,11 +176,15 @@ export interface OpenRelayEnvelopeInput { /** The opening primitive (mcp-core's `open`; see the file header) */ open: RelayOpenFn; /** - * The frame's own routing facts. When given, the wrapper must agree with - * them - a frame relayed under ref X cannot deliver an envelope addressed - * through ref Y, checked before any key material is consulted. + * The frame's own routing facts - REQUIRED (Task 5 review M-1: every real + * call site holds the frame). The wrapper must agree with them: a frame + * relayed under ref X cannot deliver an envelope addressed through ref Y, + * checked before any key material is consulted. The SIGNED anti-replay seq + * is deliberately absent: the frame's transport seq is a different number + * (Task 4's grammar), and the wrapper's is sealed inside and only known + * after opening - bind it with {@link verifyRelayEnvelope}'s `expect`. */ - expect?: { ref: string; direction: RelayDirection }; + expect: { ref: string; direction: RelayDirection }; } /** The EC (P-256) JWK shape jose's static types demand (mirrors node-signing). */ @@ -179,64 +203,6 @@ async function importPublicES256(jwk: JsonWebKey): Promise { return (await importJWK(asEcJwk(jwk), "ES256")) as CryptoKey; } -/* ------------------------------------------------------------------ */ -/* base64: the STANDARD (padded) spelling the wire's blob field uses */ -/* ------------------------------------------------------------------ */ - -const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -const BASE64_VALUES: number[] = (() => { - const values = new Array(128).fill(-1); - for (let i = 0; i < BASE64_ALPHABET.length; i++) { - values[BASE64_ALPHABET.charCodeAt(i)] = i; - } - return values; -})(); - -/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ -function base64FromBytes(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 3) { - const remaining = bytes.length - i; - const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); - out += BASE64_ALPHABET[(n >> 18) & 63]; - out += BASE64_ALPHABET[(n >> 12) & 63]; - out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; - out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; - } - return out; -} - -/** - * Decodes standard padded base64 to bytes. Callers pre-screen with - * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and - * non-canonical trailing bits - junk is refused, never repaired. - */ -function base64ToBytes(text: string): Uint8Array { - if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); - let pad = 0; - while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; - const body = text.slice(0, text.length - pad); - const out = new Uint8Array(Math.floor((body.length * 6) / 8)); - let bits = 0; - let value = 0; - let at = 0; - for (const ch of body) { - const v = BASE64_VALUES[ch.charCodeAt(0)]; - if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); - value = (value << 6) | v; - bits += 6; - if (bits >= 8) { - out[at++] = (value >>> (bits - 8)) & 0xff; - bits -= 8; - } - } - if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { - throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); - } - return out; -} - /* ------------------------------------------------------------------ */ /* canonicalization (§5.6) */ /* ------------------------------------------------------------------ */ @@ -283,10 +249,12 @@ function assertRelayInnerMessage(raw: Record): RelayInnerMessag if (!isStr(raw.routingRef) || raw.routingRef.length === 0) { throw new TypeError("ssh-relay: routingRef must be a non-empty string"); } - if (raw.direction !== "B2A" && raw.direction !== "A2B") { + if (!isRelayDirection(raw.direction)) { throw new TypeError("ssh-relay: direction must be B2A or A2B"); } - if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0) { + // `-0` passes the safe-integer and `< 0` screens yet JSON-spells as `0`, so + // it would smuggle a value that === 0 into every later comparison (M-3). + if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0 || Object.is(raw.seq, -0)) { throw new TypeError("ssh-relay: seq must be a non-negative safe integer"); } if (!isStr(raw.agentBytesB64)) { @@ -373,8 +341,10 @@ export async function signRelayEnvelope(input: SignRelayEnvelopeInput): Promise< * made `publicJwk` trustworthy). jose v6's `compactVerify` checks crypto and * format only, so the `iss` claim is evaluated here, and `algorithms` pins * ES256 so a header swap can never downgrade the check (node-signing's rule, - * verbatim). When `expect` is given, the wrapper's routing fields must match - * what the signature says - a mismatch is a refused binding, not a soft hint. + * verbatim). When `expect` is given, EVERY provided member (the routing + * triple, and - once the caller holds session state - the recorded nonces and + * relay-session id) must match what the signature says; a mismatch is a + * refused binding, not a soft hint. */ export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Promise { let payload: JWTPayload; @@ -400,6 +370,18 @@ export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Prom if (expect.seq !== undefined && expect.seq !== message.seq) { throw new TypeError("ssh-relay: seq does not match the signature"); } + if (expect.relaySessionId !== undefined && expect.relaySessionId !== message.relaySessionId) { + throw new TypeError("ssh-relay: relaySessionId does not match the signature"); + } + // I-1: the endpoint-minted nonces. The plane cannot mint these, so a + // resent envelope from another pairing is refused HERE once the caller + // binds the values it recorded for this relay-open. + if (expect.nB !== undefined && expect.nB !== message.nB) { + throw new TypeError("ssh-relay: nB does not match the signature"); + } + if (expect.nA !== undefined && expect.nA !== message.nA) { + throw new TypeError("ssh-relay: nA does not match the signature"); + } } return message; } @@ -477,19 +459,17 @@ export async function openRelayEnvelope(input: OpenRelayEnvelopeInput): Promise< if (!isStr(routingRef) || routingRef.length === 0) { throw new TypeError("ssh-relay: wrapper routingRef is not a non-empty string"); } - if (direction !== "B2A" && direction !== "A2B") { + if (!isRelayDirection(direction)) { throw new TypeError("ssh-relay: wrapper direction is not B2A or A2B"); } - if (!Number.isSafeInteger(seq) || (seq as number) < 0) { + if (!Number.isSafeInteger(seq) || (seq as number) < 0 || Object.is(seq, -0)) { throw new TypeError("ssh-relay: wrapper seq is not a non-negative safe integer"); } - if (input.expect) { - if (input.expect.ref !== routingRef) { - throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); - } - if (input.expect.direction !== direction) { - throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); - } + if (input.expect.ref !== routingRef) { + throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); + } + if (input.expect.direction !== direction) { + throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); } return { jws, routingRef, direction, seq: seq as number }; } @@ -519,7 +499,7 @@ export class SeqGate { * nothing - a hostile frame must never move the watermark. */ accept(direction: RelayDirection, seq: number): boolean { - if (direction !== "B2A" && direction !== "A2B") return false; + if (!isRelayDirection(direction)) return false; if (!Number.isSafeInteger(seq) || seq < 0) return false; if (seq <= this.#last[direction]) return false; this.#last[direction] = seq; From d86bc56764fc11bc457e9f850508eede50117f1b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:51:37 -0700 Subject: [PATCH 093/170] feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames --- apps/node/agent/src/__tests__/daemon.test.ts | 42 ++ .../agent/src/__tests__/relay-proxy.test.ts | 537 ++++++++++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 229 ++++++++ apps/node/agent/src/daemon.ts | 45 +- apps/node/agent/src/relay-proxy.ts | 455 +++++++++++++++ 5 files changed, 1304 insertions(+), 4 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-proxy.test.ts create mode 100644 apps/node/agent/src/commands/ssh-relay.ts create mode 100644 apps/node/agent/src/relay-proxy.ts diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 9b8577ee2..504c6f29d 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -26,6 +26,7 @@ import { type NodeEvent, type NodeRuntimeReport, parseNodeEvent, + type RelayFrame, signCommand, } from "@internal/subshell-protocol"; import { @@ -37,6 +38,7 @@ import { parseLinkBinding, } from "@internal/subshell-protocol/node-link-crypto"; import { run as runCli } from "../cli.js"; +import { RelaySessions } from "../commands/ssh-relay.js"; import { TAIL_POLL_MS } from "../commands/tail.js"; import { configPath, type NodeConfig, saveConfig } from "../config.js"; import { @@ -467,6 +469,7 @@ async function startDaemon( | "runtime" | "retentionMs" | "retentionPass" + | "relaySessions" > > & { config?: Partial; @@ -2580,3 +2583,42 @@ describe("pane-log retention wiring", () => { } }); }); + +/* ------------------------------------------------------------------ */ +/* relay inbound routing (spec 2026-10-08 §5.1): frames are not commands */ +/* ------------------------------------------------------------------ */ + +test("an inbound relay frame reaches the relay seam and never the JWS-command path", async () => { + // The registry is INJECTED: this pins the daemon's routing decision, and the + // seam is the one Task 7's A-side responder registers into (a single + // onInboundRelayFrame dispatch, routed by the ref the relay-open paired). + const relay = new RelaySessions(); + const seen: RelayFrame[] = []; + relay.register("r-1", { onRelayFrame: (f) => seen.push(f), close: () => {} }); + const h = await startDaemon({ relaySessions: relay }); + await waitFor(h, (e) => e.type === "inventory", "connect inventory push"); + + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAA" })); + await waitUntil(() => seen.length === 1, "the frame routed to its session handler"); + await sleep(120); + // NOT a command: the JWS path answers every non-command text frame with a + // `verify: malformed` error - a relay frame must draw NOTHING from it. + expect(eventsAs(h, "error").length).toBe(0); + expect(count(h, (e) => e.type === "result")).toBe(0); + + // A malformed relay candidate is relay-TYPED noise: dropped, still never a + // command refusal the plane would read as a command anomaly. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "!!!!" })); + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "", seq: 0, direction: "A2B", blob: "AAAA" })); + // A frame for an unregistered ref is dropped by the registry, not re-routed. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-none", seq: 0, direction: "A2B", blob: "AAAA" })); + await sleep(120); + expect(seen.length).toBe(1); + expect(eventsAs(h, "error").length).toBe(0); + + // And the command path is UNTOUCHED for what is not relay-shaped: the old + // non-JWS text frame still draws the ordinary malformed answer. + h.plane.sendToAgent(JSON.stringify({ jws: 42 })); + await waitFor(h, (e) => e.type === "error" && e.code === "verify", "non-relay junk still answers verify/malformed"); + expect(seen.length).toBe(1); +}); diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts new file mode 100644 index 000000000..f0355872e --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -0,0 +1,537 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, statSync } from "node:fs"; +import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { RelaySessions } from "../commands/ssh-relay.js"; +import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from "../relay-proxy.js"; + +/** + * The B-side ssh-agent proxy (spec 2026-10-08 §5.2/§5.6) over the Task-5 codec. + * seal/open are the REAL mcp-core pair (the production pair, injected as the + * codec's own doctrine demands), and the A-side test keys are generated the + * same way the protocol tests do it, so the envelope the proxy produces and + * consumes is the shipped wire shape end to end. + */ + +/** The ssh-agent wire type bytes (OpenSSH `agent-proto.h`). */ +const SSH2_AGENTC_REQUEST_IDENTITIES = 17; +const SSH2_AGENTC_SIGN_REQUEST = 13; +const SSH2_AGENT_IDENTITIES = 18; + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +// One cast of keys for the whole file: generation is the slow part, and the +// pins are per-proxy inputs anyway. A = key home, B = this machine. +const aKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +const bKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +/** An ES256 pair nobody pinned: replies signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/** The one frame grammar the tests accept: 4-byte BE length + payload. */ +function agentFrame(payload: number[]): Buffer { + const body = Buffer.from(payload); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +function connectSocket(socketPath: string): Socket { + const conn = createConnection({ path: socketPath }); + conn.on("error", () => { + /* the tests read failures through data/close events, not error throws */ + }); + return conn; +} + +/** Collect everything a socket received, and whether it was closed. */ +function spy(conn: Socket): { chunks: Buffer[]; closed: () => boolean } { + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + return { chunks, closed: () => closed }; +} + +interface ProxyFixture { + proxy: AgentProxyHandle; + frames: RelayFrame[]; + dataDir: string; + relayId: string; + ref: string; + bNodeId: string; + cleanup(): void; +} + +async function startFixture(paneId = "pane-1"): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const frames: RelayFrame[] = []; + const proxy = await startAgentProxy({ + dataDir, + paneId, + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + peerSigningJwk: aKeys.signing.publicJwk, + peerEncryptionJwk: aKeys.encryption.publicJwk, + ownEncryptionPublicJwk: bKeys.encryption.publicJwk, + ownEncryptionPrivateJwk: bKeys.encryption.privateJwk, + ownSigningPrivateJwk: bKeys.signing.privateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: (frame) => { + frames.push(frame); + }, + }); + return { + proxy, + frames, + dataDir, + relayId: "relay-1", + ref: "r-1", + bNodeId: "b-node", + cleanup: () => proxy.close(), + }; +} + +/** + * Open one outbound B2A frame's blob AS A (the seal is addressed to A's + * encryption key), and verify its signature against B's public signing key: + * the mirror of the checklist the proxy runs on replies. + */ +async function openAndVerifyB2A( + fixture: ProxyFixture, + frame: RelayFrame, +): Promise<{ agentBytes: Buffer; nB: string; nA?: string; seq: number }> { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:a-node", + publicJwk: aKeys.encryption.publicJwk, + privateJwk: aKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(bKeys.signing.publicJwk) as JsonWebKey, + expect: { routingRef: fixture.ref, direction: "B2A", seq: opened.seq }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nB: message.nB ?? "", + nA: message.nA, + seq: opened.seq, + }; +} + +/** Seal one A2B reply exactly as A's responder will (Task 7): A's signing key, B's pinned encryption key. */ +async function sealReply( + fixture: ProxyFixture, + opts: { + seq: number; + nB: string; + nA?: string; + agentBytes: number[]; + signerPrivateJwk?: string; + relaySessionId?: string; + }, +): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? fixture.relayId, + routingRef: fixture.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? aKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: `node:${fixture.bNodeId}`, publicJwk: bKeys.encryption.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: fixture.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* outbound: each agent request becomes exactly one sealed B2A frame */ +/* ------------------------------------------------------------------ */ + +test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq 0, carrying the agent bytes", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const w = await new Promise((res, rej) => { + conn.on("connect", () => res()); + conn.on("error", rej); + }); + void w; + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "one outbound relay frame"); + await sleep(30); + expect(f.frames.length).toBe(1); // exactly one + + const frame = f.frames[0]; + expect(parseRelayFrame(JSON.stringify(frame))).not.toBeNull(); // it is wire-legal + expect(frame.type).toBe("relay"); + expect(frame.ref).toBe("r-1"); + expect(frame.direction).toBe("B2A"); + expect(frame.seq).toBe(0); // the transport slot + + const opened = await openAndVerifyB2A(f, frame); + expect(opened.seq).toBe(0); // the SIGNED anti-replay seq agrees for the first request + expect(opened.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(opened.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // a 128-bit base64url endpoint nonce + expect(opened.nA).toBeUndefined(); // B speaks first; nA has not been minted yet + conn.destroy(); + } finally { + f.cleanup(); + } +}); + +test("a second request gets seq 1 and carries the recorded nA onward", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "first request frame"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [SSH2_AGENT_IDENTITIES, 0, 0, 0, 0] }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); + + conn.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + await waitUntil(() => f.frames.length === 2, "second request frame"); + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(second.seq).toBe(1); // signed anti-replay seq advanced + expect(second.nB).toBe(first.nB); // B keeps its own nonce + expect(second.nA).toBe(nA); // and signs A's onward (§5.6: recorded peer value) + expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* inbound: a verified reply is written back; a refused one never is */ +/* ------------------------------------------------------------------ */ + +test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent bytes back on the socket", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const replyBytes = [SSH2_AGENT_IDENTITIES, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); + + const written = Buffer.concat(spyC.chunks); + const len = written.readUInt32BE(0); + expect(len).toBe(replyBytes.length); + expect(written.subarray(4)).toEqual(Buffer.from(replyBytes)); + } finally { + f.cleanup(); + } +}); + +test("a reply signed by the WRONG key is refused: nothing written, the pending connection fails", async () => { + const f = await startFixture(); + const evil = await evilKeysReady; + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const forged = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure + }); + f.proxy.deliverInboundRelayFrame(forged); + await waitUntil(() => spyC.closed(), "connection failed"); + expect(spyC.chunks.length).toBe(0); // NEVER a forged reply on the wire to ssh + } finally { + f.cleanup(); + } +}); + +test("a reply with a mismatched relay-session id or nB nonce is refused and not written", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + relaySessionId: "relay-OTHER", + }); + f.proxy.deliverInboundRelayFrame(wrongSession); + await waitUntil(() => spyC.closed(), "session-id mismatch fails the pending connection"); + expect(spyC.chunks.length).toBe(0); + + // Foreign nonce: this machine never minted it. + const conn2 = connectSocket(f.proxy.socketPath); + const spy2 = spy(conn2); + await new Promise((res) => conn2.on("connect", () => res())); + conn2.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const wrongNonce = await sealReply(f, { + seq: 0, + nB: newNonce(), + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + }); + f.proxy.deliverInboundRelayFrame(wrongNonce); + await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); + expect(spy2.chunks.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +test("a repeated seq is refused by the SeqGate and not written; a valid next-seq reply still is", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "first reply written"); + + // The plane can resend ANY envelope it routed (§5.6): a byte-identical + // resend and a rewind are both refused, and a refusal costs nothing on + // the live stream - the connection stays open for the next request. + f.proxy.deliverInboundRelayFrame(good); // resend, seq 0 again + const rewind = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [9, 9, 9] }); + f.proxy.deliverInboundRelayFrame(rewind); + const lower = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [8, 8, 8] }); + f.proxy.deliverInboundRelayFrame(lower); + await sleep(50); + expect(spyC.chunks.length).toBe(1); // nothing further was written + expect(spyC.closed()).toBe(false); // a replay refusal is not a connection failure + + // The stream continues: seq 1 over seq 0 is accepted. + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const next = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [4, 5, 6] }); + f.proxy.deliverInboundRelayFrame(next); + await waitUntil(() => spyC.chunks.length === 2, "next-seq reply written"); + expect(spyC.chunks[1].subarray(4)).toEqual(Buffer.from([4, 5, 6])); + } finally { + f.cleanup(); + } +}); + +test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient slot) is dropped without killing the pending request", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + + // Not even valid base64-of-a-JWE: the open fails before any trust question. + f.proxy.deliverInboundRelayFrame({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAAAAAAAAAAAAAA" }); + await sleep(30); + expect(spyC.chunks.length).toBe(0); + expect(spyC.closed()).toBe(false); // noise from the routing plane costs the pane nothing + + // And the session still works: the real reply lands. + const first = await openAndVerifyB2A(f, f.frames[0]); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "valid reply still written"); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the socket file and its directory */ +/* ------------------------------------------------------------------ */ + +test("the socket lives at /ssh//agent.sock, mode 0600, not in the pane log dir", async () => { + const f = await startFixture("pane-42"); + try { + const expected = join(f.dataDir, "ssh", "pane-42", "agent.sock"); + expect(f.proxy.socketPath).toBe(expected); + expect(buildAgentSocketPath(f.dataDir, "pane-42")).toBe(expected); + // The M1 pane CONFIG lands in the same dir (spec §5.2 "beside the rendered + // config"); the pane LOG dir is /subshells - a different tree. + expect(f.proxy.socketPath).not.toContain(join(f.dataDir, "subshells")); + const st = statSync(f.proxy.socketPath); + expect(st.mode & 0o777).toBe(0o600); + const dir = statSync(join(f.dataDir, "ssh", "pane-42")); + expect(dir.mode & 0o777).toBe(0o700); + } finally { + f.cleanup(); + } +}); + +test("close() unbinds: the socket file is gone and a fresh connect fails", async () => { + const f = await startFixture(); + const path = f.proxy.socketPath; + f.proxy.close(); + await waitUntil(() => { + try { + statSync(path); + return false; + } catch { + return true; + } + }, "socket file unlinked"); + const conn = createConnection({ path }); + let failed = false; + conn.on("error", () => { + failed = true; + }); + await waitUntil(() => failed, "connect refused after close"); + // Idempotent: a second close must not throw. + f.proxy.close(); +}); + +test("an over-cap request length prefix fails the connection without emitting a frame", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + const header = Buffer.alloc(4); + header.writeUInt32BE(SSH_RELAY_FRAME_MAX_BYTES + 1, 0); + conn.write(header); + await waitUntil(() => spyC.closed(), "over-cap request drops the connection"); + expect(f.frames.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the registry seam the daemon routes into (Task 7 reuses this one) */ +/* ------------------------------------------------------------------ */ + +test("RelaySessions dispatches by ref to the owning role: registered, unknown, duplicate, throwing", () => { + const relay = new RelaySessions(); + const seenA: RelayFrame[] = []; + const seenB: RelayFrame[] = []; + let closedWith = ""; + const frame = (ref: string): RelayFrame => ({ type: "relay", ref, seq: 0, direction: "A2B", blob: "AAAA" }); + + expect( + relay.register("r-1", { + onRelayFrame: (f) => seenA.push(f), + close: (reason) => { + closedWith = reason; + }, + }), + ).toBe(true); + expect(relay.register("r-1", { onRelayFrame: (f) => seenB.push(f), close: () => {} })).toBe(false); // ref already owned + expect( + relay.register("r-2", { + onRelayFrame: () => { + throw new Error("handler boom"); + }, + close: () => {}, + }), + ).toBe(true); + + relay.onInboundRelayFrame(frame("r-1")); + expect(seenA.length).toBe(1); + expect(seenB.length).toBe(0); + + relay.onInboundRelayFrame(frame("unknown-ref")); // never throws into the frame chain + relay.onInboundRelayFrame(frame("r-2")); // a throwing owner is contained, not rethrown + + relay.closeAll("grant-revoked"); + expect(closedWith).toBe("grant-revoked"); + expect(relay.size).toBe(0); + relay.onInboundRelayFrame(frame("r-1")); // after closeAll nothing routes, nothing throws + expect(seenA.length).toBe(1); +}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts new file mode 100644 index 000000000..0bee80776 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -0,0 +1,229 @@ +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "../identity.js"; +import { log } from "../log.js"; +import { MachinePinStore } from "../machine-pin-store.js"; +import { startAgentProxy } from "../relay-proxy.js"; + +/** + * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B + * branch of the brokered pairing. + * + * The daemon's inbound route hands EVERY `relay` link frame to this module's + * single dispatcher ({@link RelaySessions.onInboundRelayFrame}); the JWS + * command path never sees one. Which local role owns a `ref` is the only + * question routing asks: today the B-side agent proxy registers itself + * ({@link openBRelaySession}); Task 7's A-side responder registers into the + * SAME registry for the other role, and Task 8's `ssh_relay_open` executor + * decides which branch a command drives by the command's own `role` field. + * That is the whole seam - one dispatch, one map, any number of roles. + * + * §5.1's pairing doctrine needs the pairing itself: the plane's + * `ssh_relay_open` command carries the relay-session id, the routing ref, both + * node ids with roles, and the peer's registered signing + encryption keys. + * The B branch enforces §4.4's byte-equality pin (first pairing writes the + * pin; a pin that moved is a hard block naming the peer and §4.5's recovery, + * never a silent overwrite) and only then binds the pane's agent socket. + * + * WHAT THE WIRE DOES NOT CARRY YET (handed to Task 8): the frozen + * `ssh_relay_open` grammar names no pane. §5.2 puts the socket in the PANE's + * ssh dir (`/ssh//agent.sock`), so {@link openBRelaySession} + * takes the `paneId` as an explicit executor input - whoever delivers the + * command must supply it (a grammar addition or a launch-side correlation; + * the proxy refuses an id outside the path-composition shape either way, so + * guessing is structurally impossible). No executor arm is registered on this + * pass: `ssh_relay_open`/`ssh_relay_close` keep answering `unsupported` from + * the dispatch switch until Task 8's delivery wires them. + */ + +/** One local owner of a relay session's inbound frames (a B proxy, or Task 7's A responder). */ +export interface RelaySessionHandler { + /** Deliver one grammar-parsed inbound frame routed to this session's ref. */ + onRelayFrame(frame: RelayFrame): void; + /** End the session with §5.6's named reason (the `ssh_relay_close` word, or a local cut). */ + close(reason: string): void; +} + +/** + * The per-daemon map from routing ref to its local owner. ONE instance lives + * for the daemon's life (relay sessions outlive a link reconnect - the plane + * re-pumps frames on the fresh socket), and {@link + * RelaySessions.onInboundRelayFrame} is THE inbound relay dispatch every role + * shares (Task 7). Frames for an unknown ref are dropped with a log line: a + * stale ref (session already closed around the plane's own cut) is routine, + * and the refusal the plane deserves is Task 8's broker's, not this map's. + */ +export class RelaySessions { + #byRef = new Map(); + #say: (line: string) => void; + + constructor(logLine?: (line: string) => void) { + this.#say = logLine ?? ((line: string): void => log(line)); + } + + /** Sessions currently owned (census/test surface). */ + get size(): number { + return this.#byRef.size; + } + + /** + * Take ownership of a ref. False when the ref is ALREADY owned - two live + * sessions on one routing ref is a broker bug, and the second opener's + * resources belong to ITSELF to release (its caller sees the false). + */ + register(ref: string, handler: RelaySessionHandler): boolean { + if (this.#byRef.has(ref)) return false; + this.#byRef.set(ref, handler); + return true; + } + + /** Release ownership WITHOUT closing (the caller owns its own teardown). */ + unregister(ref: string): void { + this.#byRef.delete(ref); + } + + /** Whether this machine currently owns `ref`. */ + has(ref: string): boolean { + return this.#byRef.has(ref); + } + + /** + * THE single inbound relay dispatch (the Task-7-reused seam): route one + * parsed frame to whichever local role owns its ref. A throwing owner is + * contained - one poisoned session cannot take the frame chain down. + */ + onInboundRelayFrame(frame: RelayFrame): void { + const handler = this.#byRef.get(frame.ref); + if (!handler) { + // Refs are opaque ids, not secrets and not paths: naming one is how the + // operator can correlate this line with the plane's broker log. + this.#say(`relay frame for unknown session ref ${frame.ref}: dropped`); + return; + } + try { + handler.onRelayFrame(frame); + } catch (err) { + this.#say(`relay session ${frame.ref}: inbound handling threw: ${String(err)}`); + } + } + + /** + * Close the session named by `ref` (what Task 8's `ssh_relay_close` + * executor will call). True when a session was owned and closed, false for + * an unknown ref. + */ + close(ref: string, reason: string): boolean { + const handler = this.#byRef.get(ref); + if (!handler) return false; + this.#byRef.delete(ref); + try { + handler.close(reason); + } catch (err) { + this.#say(`relay session ${ref}: close threw: ${String(err)}`); + } + return true; + } + + /** Close everything (daemon shutdown / a link that will never return). */ + closeAll(reason: string): void { + for (const ref of [...this.#byRef.keys()]) this.close(ref, reason); + } +} + +/** Everything {@link openBRelaySession} needs: the daemon's seams + the command's pairing + the pane. */ +export interface BRelaySessionArgs { + /** The daemon's registry this session joins. */ + relay: RelaySessions; + /** The node's data dir (config.dataDir). */ + dataDir: string; + /** This machine's node id (config.nodeId) - the open-as principal. */ + selfNodeId: string; + /** The pane whose ssh connects through the socket (see the module header's Task-8 note). */ + paneId: string; + /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ + cmd: SshRelayOpenCommand; + /** The link pump: one sealed B2A frame onto the current socket (drops are the daemon's). */ + sendRelayFrame(frame: RelayFrame): void; + /** Line sink (defaults to the agent logger); never sees keys or agent bytes. */ + log?: (line: string) => void; +} + +/** + * Decode the command's base64 spelling of the peer's registered ECDH-ES public + * key into the raw public JWK string the pin store holds (the §4.2 registration + * spelling; the grammar's `BASE64_RE` gate already proved the outer layer). + */ +function decodePeerEncryptionJwk(b64: string): string { + const jwk = Buffer.from(b64, "base64").toString("utf8"); + // The pin's encryption half must be a PUBLIC P-256 JWK - the same deep + // refusal `bytesOfJwk` gives the signing half (private material, foreign + // curve, junk all throw; the grammar's shallow `d` gate stops at top level). + bytesOfJwk(jwk); + return jwk; +} + +/** + * Open the B side of a brokered relay pairing: enforce §4.4's machine pin on + * A (first pairing pins both halves, a moved pin is a hard block), then bind + * the pane's agent proxy socket and register its ref. Throws a named refusal + * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), or a + * ref already owned. Resolves with the composed socket path - the value the + * launch's scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * + * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH + * arm's: this function is the pairing's mechanics, not the policy. + */ +export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ socketPath: string }> { + const { cmd } = args; + if (cmd.role !== "B") throw new Error("relay open refused: this machine is not the B side of the pairing"); + + // §4.4: both halves, byte-equal, in the node-side store (its own file, never + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). + const candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + const pins = new MachinePinStore(args.dataDir); + const existing = pins.get(cmd.aNodeId); + if (existing === null) { + pins.pin(cmd.aNodeId, candidate); // first pairing: the plane-delivered keys become the pin (§4.4/§4.6's accepted window) + } else if (pins.check(cmd.aNodeId, candidate) !== "ok") { + // Hard block naming the peer and pointing at recovery - §4.5: re-pair is + // an operator act; nothing here may "trust anyway". + throw new Error( + `relay open refused: machine pin for ${cmd.aNodeId} has MOVED (re-pair per §4.5 after out-of-band verification)`, + ); + } + + const identity = await loadOrCreateIdentity(args.dataDir); + const proxy = await startAgentProxy({ + dataDir: args.dataDir, + paneId: args.paneId, + relayId: cmd.relayId, + ref: cmd.ref, + peerNodeId: cmd.aNodeId, + selfNodeId: args.selfNodeId, + peerSigningJwk: candidate.signing, + peerEncryptionJwk: candidate.encryption, + ownEncryptionPublicJwk: identity.publicJwk, + ownEncryptionPrivateJwk: identity.privateJwk, + ownSigningPrivateJwk: identity.signingPrivateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: args.sendRelayFrame, + ...(args.log === undefined ? {} : { log: args.log }), + }); + + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), + close: () => proxy.close(), + }) + ) { + proxy.close(); + throw new Error("relay open refused: routing ref already owned by a live session"); + } + return { socketPath: proxy.socketPath }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a2e9fc255..02aa1cf7b 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -11,6 +11,8 @@ import { type NodeMaintenanceWire, type NodeRuntimeReport, type NodeSshEnabledWire, + parseRelayFrame, + relayFrameRefIfOverCap, SeqTracker, verifyCommand, } from "@internal/subshell-protocol"; @@ -24,6 +26,7 @@ import { seedMaintenanceMemo, seedSshEnabledMemo, } from "./commands/report.js"; +import { RelaySessions } from "./commands/ssh-relay.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -205,6 +208,13 @@ export interface DaemonDeps { * "plaintext mode": spec §6 forbids a v14 socket ever running unsealed. */ link?: (args: LinkNegotiatorArgs) => LinkNegotiator; + /** + * The relay-session registry the inbound `relay` frames route into (spec + * 2026-10-08 §5.1/§5.2; default: a fresh {@link RelaySessions}). + * @internal test seam: the daemon test injects a capturing registry to pin + * the routing decision; production runs on the one this call constructs. + */ + relaySessions?: RelaySessions; } interface WsClose { @@ -451,6 +461,13 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // a jti that has run once NEVER runs twice. const idempotent = new Map(); + // The relay-session registry (spec 2026-10-08 §5.1/§5.2): PER-DAEMON, like + // the idempotence map: a relay session belongs to the pairing, not to the + // socket it first rode, and the plane re-pumps frames on the fresh link. + // Inbound `relay` frames route through it (see `onFrame`); B proxies (and + // from Task 7, the A responder) register into it by routing ref. + const relaySessions = deps.relaySessions ?? new RelaySessions(); + // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — // executors hold `ctx` across reconnects. The serial chain does NOT keep @@ -826,14 +843,34 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom return null; }; - /** One ADMITTED frame: jws extraction → verify → execute (spec §4). */ + /** One ADMITTED frame: relay routing (spec 2026-10-08 §5.1) OR jws → verify → execute (§4). */ const onFrame = async (ws: WsLike, data: string): Promise => { - let jws: unknown; + let parsed: unknown; try { - jws = (JSON.parse(data) as { jws?: unknown }).jws; + parsed = JSON.parse(data); } catch { - jws = undefined; + parsed = undefined; + } + // The `relay` link frame is NOT a command (§5.1: no per-message signing, + // the link authenticates the machine, origin rides the inner ES256 + // signature): it must never draw the command grammar's `verify: malformed` + // answer, which the plane reads as a command anomaly. Recognized by its + // `type` BEFORE the JWS path; a relay-shaped-but-malformed candidate is + // relay noise (dropped with a line; over-cap ones name their ref, the + // session's named close itself is the broker's), and every non-relay frame + // falls through to the untouched command path below. + if (typeof parsed === "object" && parsed !== null && (parsed as { type?: unknown }).type === "relay") { + const frame = parseRelayFrame(parsed as object); + if (frame !== null) relaySessions.onInboundRelayFrame(frame); + else { + const overCapRef = relayFrameRefIfOverCap(parsed); + if (overCapRef !== null) + log(`relay frame over the size cap for ref ${overCapRef}: refused (§5.1; the named close is the broker's)`); + else log("malformed relay frame dropped"); + } + return; } + const jws = (parsed as { jws?: unknown } | undefined)?.jws; if (typeof jws !== "string") { send(ws, { type: "error", code: "verify", message: "malformed" }); return; diff --git a/apps/node/agent/src/relay-proxy.ts b/apps/node/agent/src/relay-proxy.ts new file mode 100644 index 000000000..4df897d8d --- /dev/null +++ b/apps/node/agent/src/relay-proxy.ts @@ -0,0 +1,455 @@ +import { chmodSync, mkdirSync, unlinkSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { dirname, join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { + base64UrlNoPad, + bytesOfJwk, + newNonce, + openRelayEnvelope, + type RelayFrame, + type RelayOpenFn, + type RelaySealFn, + SeqGate, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { log } from "./log.js"; + +/** + * B-side ssh-agent proxy socket (spec 2026-10-08 §5.2): the pane's `ssh` + * connects here through its scoped `SSH_AUTH_SOCK`, and every agent request it + * writes is shipped to A as ONE sealed relay envelope - framed on the local + * wire (4-byte big-endian length + payload, the ssh-agent protocol), sealed by + * the Task-5 codec to A's PINNED encryption key, signed with B's own §4.1 + * signing key, and handed to the link pump as a `relay` frame. A's replies + * (inbound A2B frames) are opened, run through the full Task-5 caller + * checklist - verify against A's pinned signing key, cross-check the recorded + * session id and both nonces, then the per-session {@link SeqGate} - and the + * returned agent bytes are written back with the same framing. Nothing that + * fails verification reaches the socket: the pane would rather hang than + * consume a forged reply. + * + * The socket lives at `/ssh//agent.sock`, mode 0600, in the + * per-session ssh dir M1 established for the rendered config - the path is + * derived through {@link buildSshConfigPath} itself, so the two machines' + * byte-equal derivation and the exit sweep keep finding one tree. The proxy + * exists only for the handshake window (§5.6); `close()` unbinds it and the + * caller (relay close / pane teardown) drives the timing. + * + * Correlation: a relay frame carries no request id (the plane routes blind by + * ref), so the proxy keeps AT MOST ONE outstanding request - later requests + * queue locally, unsealed, until the reply lands. ssh itself serializes + * agent requests per connection; the queue makes that true across the relay + * too, which is what makes replies safely FIFO. + * + * Key handling mirrors the caller checklist in `task-5-report` verbatim: the + * wrapper is opened with B's own encryption key and the FRAME's ref/direction + * (`expect` required, M-1); the signature is verified against A's pinned + * signing key with `relaySessionId`, `nB`, and (once recorded) `nA` all bound + * into `expect` (I-1); the signed `seq` passes through the session's gate. + * seal/open are INJECTED (the protocol package cannot import mcp-core; the + * agent imports it directly and passes `seal`/`open` through). + */ + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Inputs to {@link startAgentProxy}: session facts, pinned keys, injected crypto, the pump. */ +export interface AgentProxyArgs { + /** The node's data dir; the pane's ssh dir hangs under `/ssh//`. */ + dataDir: string; + /** The pane whose `ssh` connects here; the per-session ssh dir is named by it. */ + paneId: string; + /** The relay-session id the plane minted (§5.6: signed into every envelope). */ + relayId: string; + /** The opaque routing ref this session's frames ride (also the registry key). */ + ref: string; + /** A's node id: the seal-to principal `node:` and the pinned-key holder. */ + peerNodeId: string; + /** This machine's node id: the open-as principal `node:` (Task-5 convention). */ + selfNodeId: string; + /** A's PINNED ES256 signing public JWK (JSON string) - the origin key. */ + peerSigningJwk: string; + /** A's PINNED ECDH-ES encryption public JWK (JSON string) - the seal-to key. */ + peerEncryptionJwk: string; + /** B's own encryption keypair (both JWK JSON strings) - opens replies addressed to B. */ + ownEncryptionPublicJwk: string; + ownEncryptionPrivateJwk: string; + /** B's own ES256 signing PRIVATE JWK (JSON string) - signs every B2A envelope. */ + ownSigningPrivateJwk: string; + /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ + seal: RelaySealFn; + /** The opening primitive - mcp-core's `open`. */ + open: RelayOpenFn; + /** Hand one sealed B2A frame to the link pump (the daemon's `send`, via the ctx seam). */ + sendRelayFrame(frame: RelayFrame): void; + /** Line sink; defaults to the agent logger. Never receives keys, nonces, or agent bytes. */ + log?: (line: string) => void; +} + +/** The running proxy: the composed socket path plus the two lifecycle verbs. */ +export interface AgentProxyHandle { + /** Absolute path of the listening socket (0600, inside the pane's ssh dir). */ + readonly socketPath: string; + /** + * Feed one INBOUND relay frame parsed off the link to this session. Only + * A2B frames for this session's ref are honored; anything else is dropped + * with a log line (defense in depth - the registry already routed by ref). + * Never throws: a refused reply is a refusal, not a crash. + */ + deliverInboundRelayFrame(frame: RelayFrame): void; + /** Tear down: fail every live connection, stop listening, UNLINK the socket. Idempotent. */ + close(): void; +} + +/** + * The agent proxy socket path for a pane: the directory {@link + * buildSshConfigPath} already owns for that pane's rendered config, plus + * `agent.sock`. The helper's own guards run (absolute dataDir, an id inside + * the path-composition shape) - it THROWS on anything else, so a caller can + * never compose a socket outside `/ssh//`. + */ +export function buildAgentSocketPath(dataDir: string, paneId: string): string { + return join(dirname(buildSshConfigPath(dataDir, paneId)), "agent.sock"); +} + +/** One framed agent message: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** A live client connection plus its receive buffer and (while outstanding) its relay slot. */ +interface ConnState { + socket: Socket; + buffer: Buffer; +} + +/** + * Start the B-side proxy: bind the socket, then speak the ssh-agent wire until + * `close()`. Rejects (without leaving a bound socket behind) when the pinned + * keys are not usable PUBLIC EC P-256 JWKs - the deep private-material and + * key-validity refusal §4.4 assigns to this side (`bytesOfJwk`). + */ +export async function startAgentProxy(args: AgentProxyArgs): Promise { + const say = args.log ?? ((line: string): void => log(line)); + const socketPath = buildAgentSocketPath(args.dataDir, args.paneId); + + // Pin sanity BEFORE binding: both peer halves must be public P-256 JWKs + // (bytesOfJwk throws on a `d` member, a foreign curve, or junk). Parsing + // once keeps the per-frame verify free of JSON work. + const peerSigning = JSON.parse(args.peerSigningJwk) as JsonWebKey; + try { + bytesOfJwk(args.peerSigningJwk); + bytesOfJwk(args.peerEncryptionJwk); + } catch (err) { + throw new Error(`relay proxy: pinned peer key rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const ownSigningPrivate = JSON.parse(args.ownSigningPrivateJwk) as JsonWebKey; + const ownEncryption = { + principalId: `node:${args.selfNodeId}`, + publicJwk: args.ownEncryptionPublicJwk, + privateJwk: args.ownEncryptionPrivateJwk, + }; + const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; + + // The pane's ssh dir, the same 0700 posture the launch write enforces; the + // dir may already hold the rendered config (it will, once Task 8 sequences + // open-and-launch) and holds agent.sock beside it. + const dir = dirname(socketPath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + // A stale socket file from a crashed session would make `listen` fail with + // EADDRINUSE; the pane's ssh dir is this machine's alone, so the unlinked + // name can only have been a previous proxy for the same pane. + try { + unlinkSync(socketPath); + } catch { + /* the clean case: no stale name */ + } + + // Session state (§5.6): B's own nonce is minted once; A's is recorded on the + // first verified reply and signed onward after that. The gate is FRESH per + // relay-open, and the two seqs (signed anti-replay, frame transport) start + // at 0 and advance together on B's leg. + const nB = newNonce(); + let nA: string | undefined; + const gate = new SeqGate(); + let signedSeq = 0; + let transportSeq = 0; + + const conns = new Set(); + /** The ONE outstanding request (the FIFO head at the relay), if any. */ + let outstanding: { conn: ConnState } | null = null; + /** True while a seal is in flight: no slot has been taken, and no other request starts. */ + let sealedInFlight = false; + const queue: { conn: ConnState; payload: Buffer }[] = []; + let closed = false; + + /** Destroy a connection's socket; its close handler does the bookkeeping. */ + const failConn = (conn: ConnState): void => { + conns.delete(conn); + try { + conn.socket.destroy(); + } catch { + /* already gone */ + } + }; + + /** Nothing outstanding anymore: let the next queued request go (if any). */ + const releaseOutstanding = (): void => { + outstanding = null; + void pumpQueue(); + }; + + /** A refused-forge frame fails every request that could be waiting on it. */ + const poisonSession = (why: string): void => { + say(`relay session ${args.ref}: ${why}; refusing the frame and failing pending agent connections`); + const waiting = outstanding?.conn; + if (waiting) failConn(waiting); + for (const q of queue.splice(0)) failConn(q.conn); + // A seal still in flight resolves into a dead connection: sendRequest's + // liveness re-check drops it and drains the (now empty) queue itself. + releaseOutstanding(); + }; + + /** Seal one agent request and hand the frame to the pump; then open the slot. */ + const sendRequest = async (conn: ConnState, payload: Buffer): Promise => { + const agentBytesB64 = base64UrlNoPad(new Uint8Array(payload)); + const message = { + relaySessionId: args.relayId, + routingRef: args.ref, + direction: "B2A" as const, + seq: signedSeq, + nB, + ...(nA === undefined ? {} : { nA }), + agentBytesB64, + }; + let blob: string; + try { + blob = await sealRelayEnvelope({ message, privateJwk: ownSigningPrivate, recipient, seal: args.seal }); + } catch (err) { + // Malformed key material or an over-cap payload: the request is dead. + // Cost it its connection, never the session (no seq was consumed). + say( + `relay session ${args.ref}: could not seal an agent request: ${err instanceof Error ? err.message : String(err)}`, + ); + sealedInFlight = false; + failConn(conn); + void pumpQueue(); + return; + } + if (closed) return; + if (!conns.has(conn)) { + // The connection died mid-seal: its reply would have nowhere to go, so + // the slot is not taken and the queue drains straight away. + sealedInFlight = false; + void pumpQueue(); + return; + } + signedSeq += 1; + try { + args.sendRelayFrame({ + type: "relay", + ref: args.ref, + seq: transportSeq, + direction: "B2A", + blob, + }); + } catch (err) { + say(`relay session ${args.ref}: the link pump refused a request: ${String(err)}`); + } + transportSeq += 1; + sealedInFlight = false; + // The slot HOLDS the frame's reply: further requests queue until it lands + // (or the connection dies and its close handler frees the slot). + outstanding = { conn }; + void pumpQueue(); + }; + + /** Start the next queued request if the relay slot is free. */ + const pumpQueue = async (): Promise => { + if (closed || sealedInFlight || outstanding) return; + const next = queue.shift(); + if (!next) return; + sealedInFlight = true; + void sendRequest(next.conn, next.payload); + }; + + /** One complete inbound agent message from one connection. */ + const handleAgentRequest = (conn: ConnState, payload: Buffer): void => { + if (closed) return; + if (!outstanding && !sealedInFlight) { + sealedInFlight = true; + void sendRequest(conn, payload); + return; + } + queue.push({ conn, payload }); // serializes behind the outstanding request (§5.6 FIFO) + }; + + const onConnection = (socket: Socket): void => { + const conn: ConnState = { socket, buffer: Buffer.alloc(0) }; + conns.add(conn); + socket.on("data", (chunk: Buffer) => { + conn.buffer = conn.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([conn.buffer, chunk]); + for (;;) { + if (conn.buffer.length < 4) return; + const length = conn.buffer.readUInt32BE(0); + if (length > MAX_AGENT_MESSAGE_BYTES) { + // A length prefix past the wire cap is noise or an attack: the + // payload is never buffered, and the connection is over. + say(`relay session ${args.ref}: agent message over the cap (${length} bytes); connection failed`); + failConn(conn); + return; + } + if (conn.buffer.length < 4 + length) return; + const payload = conn.buffer.subarray(4, 4 + length); + conn.buffer = conn.buffer.subarray(4 + length); + handleAgentRequest(conn, Buffer.from(payload)); + if (!conns.has(conn)) return; // the handler failed this connection mid-loop + } + }); + const forget = (): void => { + if (!conns.delete(conn)) return; + for (let i = queue.length - 1; i >= 0; i -= 1) { + if (queue[i].conn === conn) queue.splice(i, 1); + } + // A mid-seal death needs no bookkeeping here: sendRequest re-checks + // liveness before taking the slot, and drops the sealed frame if the + // connection is gone. + if (outstanding?.conn === conn && !sealedInFlight) releaseOutstanding(); + }; + socket.on("close", forget); + socket.on("error", () => { + /* close follows; never throw out of the socket layer */ + }); + }; + + const server: Server = createServer(onConnection); + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + server.removeListener("listening", onListening); + reject(err); + }; + const onListening = (): void => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(socketPath); + }); + // The socket is the pane user's door onto the relay: 0600, enforced the way + // every 0600 file in this agent is - umask cannot be reasoned with, chmod + // after the fact can. + chmodSync(socketPath, 0o600); + + /** Open + verify one inbound reply, then write it back. Runs on the chain. */ + const deliverReply = async (frame: RelayFrame): Promise => { + if (closed) return; + let opened; + try { + // The frame's OWN routing facts bind the wrapper (M-1): a relayed blob + // addressed through another ref or direction never reaches a key. + opened = await openRelayEnvelope({ + blob: frame.blob, + own: ownEncryption, + open: args.open, + expect: { ref: frame.ref, direction: frame.direction }, + }); + } catch { + // Unopenable noise from the routing plane (which holds B's public key + // and can craft seals all by itself, §5.6). Cost it a log line; the + // pane's pending request stays alive. + say(`relay session ${args.ref}: refused an inbound frame (envelope did not open)`); + return; + } + let message; + try { + // The Task-5 caller checklist, every binding the endpoint holds (I-1): + // origin against A's pinned signing key, this session's relayId, the + // recorded nonces, and the frame-agreed routing triple. An openable + // wrapper with a foreign signature or another session's facts is a + // FORGERY against the pin, not noise: refuse it and fail the + // connections that could consume anything built on this exchange. + message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: peerSigning, + expect: { + routingRef: args.ref, + direction: "A2B", + seq: opened.seq, + relaySessionId: args.relayId, + nB, + ...(nA === undefined ? {} : { nA }), + }, + }); + } catch { + poisonSession("reply failed origin/binding verification"); + return; + } + if (!gate.accept("A2B", message.seq)) { + // The plane routes - and can resend - everything (§5.6). A resend is + // refused, not punished: no bytes to the socket, no harm to the stream. + say(`relay session ${args.ref}: refused a replayed/rewound reply (seq ${message.seq})`); + return; + } + if (nA === undefined) nA = message.nA; // record A's nonce on the first verified reply + const payload = Buffer.from(message.agentBytesB64, "base64url"); + const waiting = outstanding; + if (!waiting) { + say(`relay session ${args.ref}: a reply with no outstanding request was dropped`); + return; + } + outstanding = null; + try { + waiting.conn.socket.write(framing(payload)); + } catch { + /* the close handler already forgot it */ + } + void pumpQueue(); + }; + + // Replies are processed in ARRIVAL order, one chain deep: the SeqGate is + // per-session and must never race two opens out of order. + let deliverChain: Promise = Promise.resolve(); + + return { + socketPath, + deliverInboundRelayFrame(frame: RelayFrame): void { + if (closed) return; + if (frame.ref !== args.ref || frame.direction !== "A2B") { + // Misrouting is a registry bug, not a threat; a B2A frame is B's own + // echo. Either way it never reaches the reply path. + say(`relay session ${args.ref}: dropped a non-A2B or foreign-ref inbound frame`); + return; + } + deliverChain = deliverChain + .then(() => deliverReply(frame)) + .catch((err: unknown) => { + say(`relay session ${args.ref}: inbound handling failed: ${String(err)}`); + }); + }, + close(): void { + if (closed) return; + closed = true; + for (const conn of [...conns]) failConn(conn); + for (const q of queue.splice(0)) failConn(q.conn); + try { + server.close(); + } catch { + /* already closed */ + } + // The listen name goes with the session (§5.2: unbinds at teardown); an + // unlink failure leaves a stale name the NEXT proxy start unlinks. + try { + unlinkSync(socketPath); + } catch { + /* never bound, or already gone */ + } + }, + }; +} From 7d8b2b2861e08f88022c5060fabdf0ffac8922c3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:52:39 -0700 Subject: [PATCH 094/170] docs(ssh-relay): plan Task 7 agent codes corrected to 13/15 (real agent-proto.h values) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 67fb185b9..f5501bbda 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (11) by numeric type (confirm against the target OpenSSH's `agent-proto.h`), refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread); refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 083ad869b93b06f0d63cbb1cbb7effe0eaa41996 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:01:34 -0700 Subject: [PATCH 095/170] fix(ssh-relay): Task 6 review - burn slot on conn death, deliver-or-fail send, validate before parse, correct agent-code comment --- .../agent/src/__tests__/relay-proxy.test.ts | 179 +++++++++++++++++- apps/node/agent/src/commands/ssh-relay.ts | 8 +- apps/node/agent/src/relay-proxy.ts | 100 ++++++++-- 3 files changed, 259 insertions(+), 28 deletions(-) diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index f0355872e..3c4121a30 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -26,10 +26,18 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. * consumes is the shipped wire shape end to end. */ -/** The ssh-agent wire type bytes (OpenSSH `agent-proto.h`). */ -const SSH2_AGENTC_REQUEST_IDENTITIES = 17; -const SSH2_AGENTC_SIGN_REQUEST = 13; -const SSH2_AGENT_IDENTITIES = 18; +/** + * The ssh-agent wire type bytes as OpenSSH `agent-proto.h` defines them: + * SSH2_AGENTC_REQUEST_IDENTITIES is 13 and SSH2_AGENTC_SIGN_REQUEST is 15 + * (NOT the RFC 9987 renumbering 11/13; 17/18 are ADD/REMOVE IDENTITY, the + * common misread the first draft of this file carried). These are opaque + * fixture payloads only: the B proxy forwards agent bytes byte-agnostically + * and never parses a type byte - the numeric type gate is A's responder's + * default-deny (Task 7). + */ +const SSH2_AGENTC_REQUEST_IDENTITIES = 13; +const SSH2_AGENTC_SIGN_REQUEST = 15; +const SSH2_AGENT_IDENTITIES_ANSWER = 12; interface JwkPair { publicJwk: string; @@ -112,7 +120,11 @@ interface ProxyFixture { cleanup(): void; } -async function startFixture(paneId = "pane-1"): Promise { +async function startFixture( + paneId = "pane-1", + /** Overrides the pump entirely (a throwing pump tests deliver-or-throw); default collects. */ + send?: (frame: RelayFrame) => void, +): Promise { const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); const frames: RelayFrame[] = []; @@ -131,6 +143,10 @@ async function startFixture(paneId = "pane-1"): Promise { seal: mcpSeal, open: mcpOpen, sendRelayFrame: (frame) => { + if (send) { + send(frame); + return; + } frames.push(frame); }, }); @@ -256,7 +272,12 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () const first = await openAndVerifyB2A(f, f.frames[0]); const nA = newNonce(); - const reply = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [SSH2_AGENT_IDENTITIES, 0, 0, 0, 0] }); + const reply = await sealReply(f, { + seq: 0, + nB: first.nB, + nA, + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 0], + }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); @@ -287,7 +308,7 @@ test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); - const replyBytes = [SSH2_AGENT_IDENTITIES, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const replyBytes = [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 2, 0, 0, 0, 1, 65]; const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); @@ -316,7 +337,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure }); f.proxy.deliverInboundRelayFrame(forged); @@ -342,7 +363,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], relaySessionId: "relay-OTHER", }); f.proxy.deliverInboundRelayFrame(wrongSession); @@ -359,7 +380,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: newNonce(), nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], }); f.proxy.deliverInboundRelayFrame(wrongNonce); await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); @@ -433,6 +454,108 @@ test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient } }); +/* ------------------------------------------------------------------ */ +/* FIFO integrity: a reply to a dead connection burns the slot */ +/* ------------------------------------------------------------------ */ + +test("a reply to a dead connection is absorbed: never written to the next connection's socket", async () => { + const f = await startFixture(); + try { + const x = connectSocket(f.proxy.socketPath); + const spyX = spy(x); + await new Promise((res) => x.on("connect", () => res())); + x.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "X's request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Y queues behind X's outstanding slot. + const y = connectSocket(f.proxy.socketPath); + const spyY = spy(y); + await new Promise((res) => y.on("connect", () => res())); + y.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + await sleep(30); + expect(f.frames.length).toBe(1); // queued unsealed: the slot holds X's request + + // X dies with its reply still owed. The slot BURNS: it keeps holding the + // dead request, so the queue must NOT promote yet (an immediate release + // would put Y's request on the wire while A's answer to X is still in + // flight, and that answer would then land on Y's socket). + x.destroy(); + await waitUntil(() => spyX.closed(), "X's socket closed"); + await sleep(30); + expect(f.frames.length).toBe(1); // burned slot: no early promote + + // A's genuine reply to X arrives: fresh, gate-passing seq 0, correctly + // signed. It must be verified and DISCARDED, never delivered to Y. + const nA = newNonce(); + const replyToX = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [99, 98, 97] }); + f.proxy.deliverInboundRelayFrame(replyToX); + await waitUntil(() => f.frames.length === 2, "the absorb releases the slot and Y's queued request goes out"); + expect(spyY.chunks.length).toBe(0); // NEVER X's agent answer inside Y's stream + + // Y's request left as seq 1 (X lawfully consumed 0), carrying the recorded nA. + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); + expect(second.seq).toBe(1); + expect(second.nA).toBe(nA); + expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + + // A's reply to Y (seq 1) arrives: THAT is what Y receives, in order. + const replyToY = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(replyToY); + await waitUntil(() => spyY.chunks.length === 1, "Y's own reply written to Y"); + expect(spyY.chunks[0].subarray(4)).toEqual(Buffer.from([1, 2, 3])); + expect(spyY.closed()).toBe(false); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* deliver-or-throw: a refused send fails the request, never hangs it */ +/* ------------------------------------------------------------------ */ + +test("a sendRelayFrame that throws fails the connection and drains the queue; no phantom slot, no consumed seq", async () => { + const sent: RelayFrame[] = []; + let attempts = 0; + const f = await startFixture("pane-throw", (frame) => { + attempts += 1; + if (attempts === 1) throw new Error("link down: the pump dropped the send"); + sent.push(frame); + }); + try { + const a = connectSocket(f.proxy.socketPath); + const spyA = spy(a); + await new Promise((res) => a.on("connect", () => res())); + // req1 takes the slot straight away; req2 queues behind the in-flight + // seal (arrives in the same read while sealedInFlight holds). + a.write(Buffer.concat([agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES]), agentFrame([SSH2_AGENTC_SIGN_REQUEST, 9])])); + await waitUntil(() => spyA.closed(), "the connection fails on the refused send instead of hanging"); + expect(attempts).toBe(1); // only req1 reached the pump; req2 died at the liveness gate + expect(sent.length).toBe(0); // nothing ever reached A + + // The throw is NON-DELIVERY: no slot was claimed, so the session is free + // for the next request, and the failed send consumed no seq (A never saw + // the request; the retry lawfully re-uses 0). + const b = connectSocket(f.proxy.socketPath); + const spyB = spy(b); + await new Promise((res) => b.on("connect", () => res())); + b.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + await waitUntil(() => sent.length === 1, "the next request goes out after the failed send"); + expect(sent[0].seq).toBe(0); + const first = await openAndVerifyB2A(f, sent[0]); + expect(first.seq).toBe(0); + expect(first.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7, 7] }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyB.chunks.length === 1, "the live connection's reply is written"); + expect(spyB.chunks[0].subarray(4)).toEqual(Buffer.from([7, 7])); + } finally { + f.cleanup(); + } +}); + /* ------------------------------------------------------------------ */ /* the socket file and its directory */ /* ------------------------------------------------------------------ */ @@ -477,6 +600,42 @@ test("close() unbinds: the socket file is gone and a fresh connect fails", async f.proxy.close(); }); +test("a malformed pinned peer JWK surfaces the named refusal, not a raw SyntaxError", async () => { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const base = { + dataDir, + paneId: "pane-bad", + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + peerSigningJwk: aKeys.signing.publicJwk, + peerEncryptionJwk: aKeys.encryption.publicJwk, + ownEncryptionPublicJwk: bKeys.encryption.publicJwk, + ownEncryptionPrivateJwk: bKeys.encryption.privateJwk, + ownSigningPrivateJwk: bKeys.signing.privateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: (): void => {}, + }; + // Not JSON at all: bytesOfJwk's guard runs FIRST and turns the parse + // failure into the documented refusal (the parse now happens only after + // the guard, so a raw SyntaxError can never escape startAgentProxy). + await expect(startAgentProxy({ ...base, peerSigningJwk: "{not json" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + await expect(startAgentProxy({ ...base, peerEncryptionJwk: "!!!" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // Deep private material in a pin half keeps the same named refusal. + await expect(startAgentProxy({ ...base, peerSigningJwk: aKeys.signing.privateJwk })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // The refusal precedes any binding: no socket name exists. + expect(() => statSync(buildAgentSocketPath(dataDir, "pane-bad"))).toThrow(); +}); + test("an over-cap request length prefix fails the connection without emitting a frame", async () => { const f = await startFixture(); try { diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 0bee80776..0e54b1d35 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -142,7 +142,13 @@ export interface BRelaySessionArgs { paneId: string; /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ cmd: SshRelayOpenCommand; - /** The link pump: one sealed B2A frame onto the current socket (drops are the daemon's). */ + /** + * The link pump: one sealed B2A frame onto the current socket. Forwarded to + * the proxy unchanged, so it carries the proxy's DELIVER-OR-THROW contract: + * a dropped send must THROW here (Task-8 glue obligation), never log and + * return - the proxy fails the request's connection on a throw rather than + * parking a phantom reply that never comes. + */ sendRelayFrame(frame: RelayFrame): void; /** Line sink (defaults to the agent logger); never sees keys or agent bytes. */ log?: (line: string) => void; diff --git a/apps/node/agent/src/relay-proxy.ts b/apps/node/agent/src/relay-proxy.ts index 4df897d8d..5e0fe15d9 100644 --- a/apps/node/agent/src/relay-proxy.ts +++ b/apps/node/agent/src/relay-proxy.ts @@ -44,6 +44,14 @@ import { log } from "./log.js"; * agent requests per connection; the queue makes that true across the relay * too, which is what makes replies safely FIFO. * + * When a connection dies with its request ALREADY delivered, the slot BURNS + * rather than freeing: it keeps holding the dead request's seq until that + * request's reply arrives, and that reply - verified through the same + * checklist as any - is decoded and DISCARDED without touching a socket. + * Only after the absorb does the slot release and the queue promote, so a + * dead request's honest answer can never be written into a live connection's + * ssh stream. + * * Key handling mirrors the caller checklist in `task-5-report` verbatim: the * wrapper is opened with B's own encryption key and the FRAME's ref/direction * (`expect` required, M-1); the signature is verified against A's pinned @@ -83,7 +91,15 @@ export interface AgentProxyArgs { seal: RelaySealFn; /** The opening primitive - mcp-core's `open`. */ open: RelayOpenFn; - /** Hand one sealed B2A frame to the link pump (the daemon's `send`, via the ctx seam). */ + /** + * Hand one sealed B2A frame to the link pump (the daemon's `send`, via the + * ctx seam). DELIVER-OR-THROW: the proxy treats a THROW from this callback + * as NON-DELIVERY - the request's connection is failed, no slot is taken, + * and the queue advances (the seal-failure posture). The Task-8 glue MUST + * therefore throw on a dropped send: a pump that logs-and-returns while no + * socket lives would otherwise leave a phantom request whose reply never + * comes, hanging the session until an external teardown. + */ sendRelayFrame(frame: RelayFrame): void; /** Line sink; defaults to the agent logger. Never receives keys, nonces, or agent bytes. */ log?: (line: string) => void; @@ -122,12 +138,28 @@ function framing(payload: Buffer): Buffer { return Buffer.concat([header, payload]); } -/** A live client connection plus its receive buffer and (while outstanding) its relay slot. */ +/** A live client connection plus its receive buffer. */ interface ConnState { socket: Socket; buffer: Buffer; } +/** + * The ONE outstanding request (the FIFO head at the relay) plus its burn + * state. While `awaitingAbsorb` is false, `conn` owns the reply when it + * lands. Once that connection has DIED after its frame was delivered, the + * slot burns: the next gate-passed A2B reply belongs to the dead request, is + * verified through the full checklist and then discarded without touching any + * socket, and only after that does the slot release and the queue promote. + */ +interface OutstandingSlot { + conn: ConnState; + /** The signed seq the delivered request carried (burn bookkeeping, log line). */ + signedSeq: number; + /** True once the slot's connection died with the reply still owed to it. */ + awaitingAbsorb: boolean; +} + /** * Start the B-side proxy: bind the socket, then speak the ssh-agent wire until * `close()`. Rejects (without leaving a bound socket behind) when the pinned @@ -139,15 +171,17 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise(); /** The ONE outstanding request (the FIFO head at the relay), if any. */ - let outstanding: { conn: ConnState } | null = null; + let outstanding: OutstandingSlot | null = null; /** True while a seal is in flight: no slot has been taken, and no other request starts. */ let sealedInFlight = false; const queue: { conn: ConnState; payload: Buffer }[] = []; @@ -219,11 +253,12 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise => { const agentBytesB64 = base64UrlNoPad(new Uint8Array(payload)); + const reqSeq = signedSeq; // the anti-replay seq this request signs into its envelope const message = { relaySessionId: args.relayId, routingRef: args.ref, direction: "B2A" as const, - seq: signedSeq, + seq: reqSeq, nB, ...(nA === undefined ? {} : { nA }), agentBytesB64, @@ -250,7 +285,6 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise { @@ -347,7 +400,8 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise => { if (closed) return; let opened; @@ -399,14 +453,26 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise Date: Thu, 8 Oct 2026 13:02:12 -0700 Subject: [PATCH 096/170] docs(ssh-relay): plan T7 - OpenSSH agent-proto.h numbering (13/15), not RFC 9987 (11/13); e2e is arbiter --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index f5501bbda..05e43a6f4 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread); refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread). Use OpenSSH's numbering (identities 13, sign 15); RFC 9987 renumbers these to 11/13 - do NOT use RFC 9987 here, the wire and the agent are OpenSSH; the real-agent e2e (Task 16) is the arbiter. Refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 6beb4f6663ea78dcc147a764c78c341ec53d21bd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:09:24 -0700 Subject: [PATCH 097/170] feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set --- .../src/__tests__/relay-responder.test.ts | 864 ++++++++++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 147 ++- apps/node/agent/src/relay-responder.ts | 599 ++++++++++++ 3 files changed, 1599 insertions(+), 11 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-responder.test.ts create mode 100644 apps/node/agent/src/relay-responder.ts diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts new file mode 100644 index 000000000..49ac52ddb --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -0,0 +1,864 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + type SshRelayOpenCommand, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; +import { loadOrCreateIdentity } from "../identity.js"; +import { + filterIdentitiesAnswer, + fingerprintAgentBlob, + liveAgentSocketPath, + SSH2_AGENT_FAILURE, + SSH2_AGENT_IDENTITIES_ANSWER, + SSH2_AGENT_SIGN_RESPONSE, + SSH2_AGENTC_REQUEST_IDENTITIES, + SSH2_AGENTC_SIGN_REQUEST, + startRelayResponder, +} from "../relay-responder.js"; + +/** + * The A-side responder (spec 2026-10-08 §5.4/§5.6): inbound B2A frames are + * opened, origin-verified against B's PINNED signing key with the full Task-5 + * caller checklist, default-denied by agent method, scoped to the grant's + * fingerprint set against A's live agent (a stub Unix socket here), and the + * reply is sealed+signed back as an A2B frame. seal/open are the REAL + * mcp-core pair, the keys are generated exactly as the protocol and proxy + * tests do, and the test side computes fingerprints independently (node:crypto + * over the wire blob) so the scheme is pinned, not echoed. + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire (OpenSSH `agent-proto.h` values) */ +/* ------------------------------------------------------------------ */ + +/** + * Non-allow-listed request types used as refusals witnesses, named by their + * OpenSSH `agent-proto.h` values: 17 ADD_IDENTITY, 18 REMOVE_IDENTITY, + * 19 REMOVE_ALL_IDENTITIES, 22 LOCK, 23 UNLOCK, 27 EXTENSION, and the + * SSH1-era range starting at 1. (The two FORWARDED types are the production + * constants imported above.) + */ +const SSH2_AGENTC_ADD_IDENTITY = 17; +const SSH2_AGENTC_REMOVE_IDENTITY = 18; +const SSH2_AGENTC_REMOVE_ALL_IDENTITIES = 19; +const SSH2_AGENTC_LOCK = 22; +const SSH2_AGENTC_UNLOCK = 23; +const SSH2_AGENTC_EXTENSION = 27; +const SSH1_AGENTC_REQUEST_RSA_IDENTITIES = 1; + +/** An independent fingerprint oracle for the tests: SHA-256, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +function buildIdentitiesAnswer(entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +function buildSignRequest(keyBlob: Buffer, flags = 0): Buffer { + return Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(keyBlob), be32(flags)]); +} + +/** Decode an IDENTITIES_ANSWER the test way (independent of the production parser). */ +function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/* ------------------------------------------------------------------ */ +/* the stub live agent: a Unix socket that answers the agent wire */ +/* ------------------------------------------------------------------ */ + +interface StubAgent { + path: string; + /** Every request payload the stub RECEIVED: the "never forwarded" witness. */ + received: Buffer[]; + close(): Promise; +} + +async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (type === SSH2_AGENTC_REQUEST_IDENTITIES) { + conn.write(framed(buildIdentitiesAnswer(roster))); + } else if (type === SSH2_AGENTC_SIGN_REQUEST) { + // The stub signs ANYTHING it is asked: "the stub never received it" + // is the witness that the responder refused before forwarding. + conn.write(framed(Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]))); + } else { + conn.write(framed(Buffer.from([SSH2_AGENT_FAILURE]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/* ------------------------------------------------------------------ */ +/* machine identities */ +/* ------------------------------------------------------------------ */ + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function machineKeys(): Promise<{ signing: JwkPair; encryption: JwkPair }> { + const [signing, encryption] = await Promise.all([es256Pair(), ecdhPair()]); + return { signing, encryption }; +} + +// B = the connecting machine (test-generated keys); A = this machine, whose +// identity the responder loads from the data dir through identity.ts itself. +const bKeysReady = machineKeys(); +/** An ES256 pair nobody pinned: requests signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/* ------------------------------------------------------------------ */ +/* the fixture: a brokered A session over RelaySessions + the stub */ +/* ------------------------------------------------------------------ */ + +const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); +const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); +const ROSTER = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +interface Fixture { + relay: RelaySessions; + frames: RelayFrame[]; + stub: StubAgent; + dataDir: string; + aPublic: { signing: string; encryption: string }; + aPrivate: { signing: string; encryption: string }; + bKeys: { signing: JwkPair; encryption: JwkPair }; + cleanup(): Promise; +} + +/** Open the A side with the shipped command shape through the real registration path. */ +async function openFixture( + overrides: Partial = {}, + opts: { resolveAgentSocket?: () => string | null; send?: (frame: RelayFrame) => void } = {}, +): Promise { + const bKeys = await bKeysReady; + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // The responder loads A's identity from the data dir; mint it first so the + // test side knows A's keys (loadOrCreateIdentity is idempotent per dir). + const aIdentity = await loadOrCreateIdentity(dataDir); + const stub = await startStubAgent(ROSTER); + const relay = new RelaySessions(); + const frames: RelayFrame[] = []; + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + ...overrides, + }; + await openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: + opts.send ?? + ((frame: RelayFrame): void => { + frames.push(frame); + }), + resolveAgentSocket: opts.resolveAgentSocket ?? ((): string => stub.path), + }); + return { + relay, + frames, + stub, + dataDir, + aPublic: { signing: aIdentity.signingPublicJwk, encryption: aIdentity.publicJwk }, + aPrivate: { signing: aIdentity.signingPrivateJwk, encryption: aIdentity.privateJwk }, + bKeys, + cleanup: async () => { + relay.closeAll("lifetime-expiry"); + await stub.close(); + }, + }; +} + +/** Seal one B2A request exactly as B's proxy does (Task 6 wire posture). */ +async function sealRequest( + f: Fixture, + opts: { + agentBytes: Buffer; + seq: number; + nB: string; + nA?: string; + signerPrivateJwk?: string; + relaySessionId?: string; + ref?: string; + direction?: "B2A" | "A2B"; + }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? "relay-1", + routingRef: opts.ref ?? "r-1", + direction: opts.direction ?? "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? f.bKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: f.aPublic.encryption }, + seal: mcpSeal, + }); + const direction = opts.direction ?? "B2A"; + return { type: "relay", ref: opts.ref ?? "r-1", seq: opts.seq, direction, blob }; +} + +/** Open + verify one A2B frame AS B: the mirror of A's inbound checklist. */ +async function decodeA2B( + f: Fixture, + frame: RelayFrame, + expect: { nB: string; nA?: string }, +): Promise<{ agentBytes: Buffer; nA?: string; seq: number }> { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:b-node", + publicJwk: f.bKeys.encryption.publicJwk, + privateJwk: f.bKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(f.aPublic.signing) as JsonWebKey, + expect: { + routingRef: frame.ref, + direction: "A2B", + seq: opened.seq, + relaySessionId: "relay-1", + nB: expect.nB, + ...(expect.nA === undefined ? {} : { nA: expect.nA }), + }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nA: message.nA, + seq: opened.seq, + }; +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +/* ------------------------------------------------------------------ */ +/* REQUEST_IDENTITIES: forwarded, then filtered to the grant set */ +/* ------------------------------------------------------------------ */ + +test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered to the grant fingerprints, and the session's second request rides the recorded nA", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req1 = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + expect(parseRelayFrame(JSON.stringify(req1))).not.toBeNull(); + f.relay.onInboundRelayFrame(req1); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); + + const frame1 = f.frames[0]; + expect(frame1.type).toBe("relay"); + expect(frame1.ref).toBe("r-1"); + expect(frame1.direction).toBe("A2B"); + expect(frame1.seq).toBe(0); + const reply1 = await decodeA2B(f, frame1, { nB }); + expect(reply1.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); // A minted its endpoint nonce (§5.6) + expect(reply1.seq).toBe(0); // the signed seq of the first reply + + const answer = parseAnswer(reply1.agentBytes); + expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.count).toBe(1); // the stub roster had 2; only the granted fingerprint survives + expect(answer.comments).toEqual(["granted key"]); + expect(f.stub.received.length).toBe(1); // the agent saw exactly one request + expect(f.stub.received[0]).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + + // The second request carries A's recorded nonce; A's expect binds it onward. + const req2 = await sealRequest(f, { agentBytes: buildSignRequest(KEY_IN), seq: 1, nB, nA: reply1.nA }); + f.relay.onInboundRelayFrame(req2); + await waitUntil(() => f.frames.length === 2, "the second A2B reply frame"); + const reply2 = await decodeA2B(f, f.frames[1], { nB, nA: reply1.nA }); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(reply2.seq).toBe(1); // signed anti-replay seq advanced + expect(reply2.nA).toBe(reply1.nA); // every A2B carries the SAME nA (Task-6 handoff) + expect(reply2.agentBytes).toEqual( + Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]), + ); + expect(f.stub.received.length).toBe(2); + expect(f.stub.received[1]).toEqual(buildSignRequest(KEY_IN)); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* SIGN_REQUEST: in-set forwarded, out-of-set refused before the agent */ +/* ------------------------------------------------------------------ */ + +test("a SIGN_REQUEST (15) for an OUT-of-set blob is refused with SSH2_AGENT_FAILURE and never forwarded to A's agent", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequest(KEY_OUT), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + await sleep(30); + expect(f.stub.received.length).toBe(0); // the stub agent never saw it (§5.4: refused at the responder) + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* method allow-list: default-deny on everything else */ +/* ------------------------------------------------------------------ */ + +test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOCK 23, EXTENSION 27, SSH1 1) are refused and never forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const disallowed = [ + SSH2_AGENTC_ADD_IDENTITY, + SSH2_AGENTC_REMOVE_IDENTITY, + SSH2_AGENTC_REMOVE_ALL_IDENTITIES, + SSH2_AGENTC_LOCK, + SSH2_AGENTC_UNLOCK, + SSH2_AGENTC_EXTENSION, + SSH1_AGENTC_REQUEST_RSA_IDENTITIES, + ]; + let nA: string | undefined; + for (const [i, type] of disallowed.entries()) { + const req = await sealRequest(f, { + agentBytes: Buffer.from([type, 1, 2, 3]), + seq: i, + nB, + ...(nA === undefined ? {} : { nA }), + }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for disallowed type ${type}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect(f.frames[i].seq).toBe(i); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + await sleep(30); + expect(f.stub.received.length).toBe(0); // nothing reached A's agent + } finally { + await f.cleanup(); + } +}); + +test("a malformed SIGN_REQUEST (truncated wire) is refused and not forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const truncated = Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(KEY_IN).subarray(0, 2)]); + const req = await sealRequest(f, { agentBytes: truncated, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + await sleep(30); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the empty grant: serve nothing, never the full list */ +/* ------------------------------------------------------------------ */ + +test("a grant naming no fingerprint yields an empty (count 0) IDENTITIES_ANSWER, never the roster", async () => { + const f = await openFixture({ fingerprints: [] }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + const answer = parseAnswer(reply.agentBytes); + expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.count).toBe(0); + expect(reply.agentBytes.length).toBe(5); // type + zero count, nothing else + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* origin and replay: a refusal sends nothing and forwards nothing */ +/* ------------------------------------------------------------------ */ + +test("a request signed by a key that is not B's pin is refused: no reply frame, nothing forwarded", async () => { + const f = await openFixture(); + const evil = await evilKeysReady; + try { + const req = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + f.relay.onInboundRelayFrame(req); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("a request bound to a foreign relay-session id or a foreign nB nonce is refused", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + relaySessionId: "relay-EVIL", + }); + f.relay.onInboundRelayFrame(wrongSession); + await sleep(50); + expect(f.frames.length).toBe(0); + + // Land the genuine first request so nB is recorded... + const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // ...then a second-seq request carrying a nonce this session never saw. + const foreignNonce = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 1, + nB: newNonce(), + nA: first.nA, + }); + f.relay.onInboundRelayFrame(foreignNonce); + await sleep(50); + expect(f.frames.length).toBe(1); // no reply to the foreign nonce + expect(f.stub.received.length).toBe(1); // and nothing further reached the agent + } finally { + await f.cleanup(); + } +}); + +test("a replayed or non-increasing-seq request is refused: no reply frame, nothing forwarded; the session continues", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the first genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // Byte-identical resend of the first frame (the plane can resend anything). + f.relay.onInboundRelayFrame(good); + // A freshly signed request that rewinds the seq (the signature binds seq). + const rewind = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + nA: first.nA, + }); + f.relay.onInboundRelayFrame(rewind); + await sleep(50); + expect(f.frames.length).toBe(1); + expect(f.stub.received.length).toBe(1); + + // The session still works after refusals: seq 1 advances. + const next = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 1, + nB, + nA: first.nA, + }); + f.relay.onInboundRelayFrame(next); + await waitUntil(() => f.frames.length === 2, "the next-seq reply still lands"); + } finally { + await f.cleanup(); + } +}); + +test("a frame on another ref or with the A2B direction never reaches the responder path", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Another ref: the registry itself drops it (the unbrokered-session case). + const foreignRef = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + ref: "r-EVIL", + }); + f.relay.onInboundRelayFrame(foreignRef); + // The wrong direction for A's side (B's own echo posture). + const echo = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + direction: "A2B", + }); + f.relay.onInboundRelayFrame(echo); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* registration: openARelaySession's guards + the relay-open carriage */ +/* ------------------------------------------------------------------ */ + +type OpenCmdBase = Omit; + +test("openARelaySession refuses a command that does not name this machine in its role, and the B branch guards its own", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + const encB64 = Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }; + // role B: the A branch is not for this command. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + role: "B", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the A side/); + // role A but aNodeId names someone else: not OUR brokered session. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + aNodeId: "someone-else", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the key home/); + // The mirrored guard on the B branch: bNodeId must name the receiver too. + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-guard", + cmd: { + ...base, + role: "B", + bNodeId: "someone-else", + ref: "r-8", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the connecting side/); + expect(relay.size).toBe(0); +}); + +test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicate-ref rule", async () => { + const f = await openFixture(); // first pairing pinned b-node with bKeys + try { + const otherB = await machineKeys(); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-2", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }; + // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + ref: "r-2", + peerSigningPublicKey: otherB.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(otherB.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/pin for b-node has MOVED/); + // Same pinned pairing, already-owned ref: refused, registry untouched. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + relayId: "relay-3", + ref: "r-1", + peerSigningPublicKey: f.bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(f.bKeys.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/routing ref already owned/); + expect(f.relay.size).toBe(1); + } finally { + await f.cleanup(); + } +}); + +test("a pinned peer key that is not a usable public P-256 JWK rejects the session before anything binds", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // A PRIVATE signing JWK as the pin: bytesOfJwk's deep refusal, not a raw parse error. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.privateJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/relay open refused/); + expect(relay.size).toBe(0); +}); + +test("relay.close tears the responder down: later frames produce nothing", async () => { + const f = await openFixture(); + try { + expect(f.relay.has("r-1")).toBe(true); + expect(f.relay.close("r-1", "grant-revoked")).toBe(true); + expect(f.relay.has("r-1")).toBe(false); + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); // the registry: unknown ref now, dropped + await sleep(30); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the live agent socket seam */ +/* ------------------------------------------------------------------ */ + +test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); + expect(liveAgentSocketPath({})).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); +}); + +test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and touches nothing", async () => { + const f = await openFixture({}, { resolveAgentSocket: () => null }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the no-agent refusal reply"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect(f.stub.received.length).toBe(0); // the stub socket was never connected + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the filtering/parser helpers, pinned directly */ +/* ------------------------------------------------------------------ */ + +test("startRelayResponder rejects a pinned peer key that is not public P-256 material (the deep bytesOfJwk gate)", () => { + expect(() => + startRelayResponder({ + relayId: "relay-1", + ref: "r-1", + selfNodeId: "a-node", + peerNodeId: "b-node", + peerSigningJwk: "not even json", + peerEncryptionJwk: "not even json", + ownEncryptionPublicJwk: "{}", + ownEncryptionPrivateJwk: "{}", + ownSigningPrivateJwk: "{}", + fingerprints: [], + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: () => {}, + }), + ).toThrow(/relay responder: pinned peer key rejected/); +}); + +test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { + const s = fingerprintAgentBlob(Buffer.from("hello")); + expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); + expect(s).toBe(fp(Buffer.from("hello"))); +}); + +test("filterIdentitiesAnswer keeps only granted entries (count fixed, bytes preserved) and refuses malformed answers", () => { + const allowed = new Set([fp(KEY_OUT)]); // grant the SECOND entry: order must not matter + const filtered = filterIdentitiesAnswer(buildIdentitiesAnswer(ROSTER), allowed); + const answer = parseAnswer(filtered); + expect(answer.count).toBe(1); + expect(answer.comments).toEqual(["ungranted key"]); + // The kept entry's wire bytes survive byte-identical: type + count + str(blob) + str(comment). + expect(filtered).toEqual( + Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), be32(1), sshStr(KEY_OUT), sshStr("ungranted key")]), + ); + expect(filterIdentitiesAnswer(buildIdentitiesAnswer([]), new Set()).length).toBe(5); + // Malformed: declared entries the buffer does not carry, and a wrong type byte. + const truncated = buildIdentitiesAnswer(ROSTER).subarray(0, 20); + expect(() => filterIdentitiesAnswer(truncated, new Set())).toThrow(); + expect(() => filterIdentitiesAnswer(Buffer.from([SSH2_AGENT_SIGN_RESPONSE, 0, 0, 0, 0]), new Set())).toThrow(); +}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 0e54b1d35..eb2841966 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -4,6 +4,7 @@ import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; import { startAgentProxy } from "../relay-proxy.js"; +import { liveAgentSocketPath, startRelayResponder } from "../relay-responder.js"; /** * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B @@ -12,11 +13,11 @@ import { startAgentProxy } from "../relay-proxy.js"; * The daemon's inbound route hands EVERY `relay` link frame to this module's * single dispatcher ({@link RelaySessions.onInboundRelayFrame}); the JWS * command path never sees one. Which local role owns a `ref` is the only - * question routing asks: today the B-side agent proxy registers itself - * ({@link openBRelaySession}); Task 7's A-side responder registers into the - * SAME registry for the other role, and Task 8's `ssh_relay_open` executor - * decides which branch a command drives by the command's own `role` field. - * That is the whole seam - one dispatch, one map, any number of roles. + * question routing asks: the B-side agent proxy registers itself + * ({@link openBRelaySession}) and the A-side responder registers into the + * SAME registry ({@link openARelaySession}); Task 8's `ssh_relay_open` + * executor decides which branch a command drives by the command's own `role` + * field. That is the whole seam - one dispatch, one map, both roles. * * §5.1's pairing doctrine needs the pairing itself: the plane's * `ssh_relay_open` command carries the relay-session id, the routing ref, both @@ -183,14 +184,27 @@ function decodePeerEncryptionJwk(b64: string): string { export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ socketPath: string }> { const { cmd } = args; if (cmd.role !== "B") throw new Error("relay open refused: this machine is not the B side of the pairing"); + // The role says B; the pairing must ALSO name THIS machine as B (§5.4's + // "a session naming this machine" reads both branches alike - a command + // about someone else's pairing is refused before any pin is consulted). + if (cmd.bNodeId !== args.selfNodeId) { + throw new Error("relay open refused: this machine is not the connecting side named by the pairing"); + } // §4.4: both halves, byte-equal, in the node-side store (its own file, never - // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). - const candidate = { - signing: cmd.peerSigningPublicKey, - encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), - }; - bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). Every key + // gate lands in the SAME named refusal (the raw bytesOfJwk text would reach + // the dispatch answer otherwise): decode, deep-check, reject before any pin. + let candidate: { signing: string; encryption: string }; + try { + candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + } catch (err) { + throw new Error(`relay open refused: peer pin rejected: ${err instanceof Error ? err.message : String(err)}`); + } const pins = new MachinePinStore(args.dataDir); const existing = pins.get(cmd.aNodeId); if (existing === null) { @@ -233,3 +247,114 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock } return { socketPath: proxy.socketPath }; } + +/** Everything {@link openARelaySession} needs: the daemon's seams + the command's pairing. */ +export interface ARelaySessionArgs { + /** The daemon's registry this session joins. */ + relay: RelaySessions; + /** The node's data dir (config.dataDir): holds A's identity and B's pin. */ + dataDir: string; + /** This machine's node id (config.nodeId) - must equal `cmd.aNodeId`, the open-as principal. */ + selfNodeId: string; + /** The verified `ssh_relay_open` body; its `role` MUST be "A" and its `aNodeId` MUST be this machine. */ + cmd: SshRelayOpenCommand; + /** + * The link pump: one sealed A2B frame onto the current socket. The + * responder forwards it unchanged, so it carries the DELIVER-OR-THROW + * contract (the Task-8 glue obligation): a dropped send must THROW, and A + * consumes no seq for a reply that never left. + */ + sendRelayFrame(frame: RelayFrame): void; + /** + * Resolve A's live agent socket per request; defaults to + * `liveAgentSocketPath` (the connecting account's absolute `SSH_AUTH_SOCK`, + * the ssh-resolve rule). The test seam for a stub agent. + */ + resolveAgentSocket?: () => string | null; + /** Line sink (defaults to the agent logger); never sees keys, fingerprints, or agent bytes. */ + log?: (line: string) => void; +} + +/** + * Open the A side of a brokered relay pairing: enforce §4.4's machine pin on + * B (first pairing writes both halves, a moved pin is a hard block naming the + * peer and §4.5's re-pair), then start {@link startRelayResponder} for the + * command's grant (its selected fingerprint set is carried by the command + * itself, §5.1 - the responder enforces it without a REST call it cannot + * make) and register the ref. Throws a named refusal (the dispatch wrapper + * turns it into `ok:false`) on: role other than A, a pairing that names a + * different machine as A, an unusable peer key, a MOVED pin, or a ref already + * owned. Resolves with the session id - nothing about the machine is in the + * answer, and no path exists to leak. + * + * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH + * arm's: this function is the pairing's mechanics, not the policy. §5.4's + * roster command (`ssh_agent_identities`, Task 11) is a separate signed + * command, not this session: the responder serves ONLY brokered traffic. + */ +export async function openARelaySession(args: ARelaySessionArgs): Promise<{ relayId: string }> { + const { cmd } = args; + if (cmd.role !== "A") throw new Error("relay open refused: this machine is not the A side of the pairing"); + // §5.4: "a session naming this A" - the role alone is not enough when the + // plane brokers many machines' pairings through one daemon; the pairing + // must name THIS machine as the key home before any pin is consulted. + if (cmd.aNodeId !== args.selfNodeId) { + throw new Error(`relay open refused: this machine is not the key home ${cmd.aNodeId} named by the pairing`); + } + + // §4.4: both halves, byte-equal, in the node-side store (its own file, never + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). The peer + // here is B: A pins whoever the command names as the connecting machine. + // Same named-refusal wrapper as the B branch. + let candidate: { signing: string; encryption: string }; + try { + candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + } catch (err) { + throw new Error(`relay open refused: peer pin rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const pins = new MachinePinStore(args.dataDir); + const existing = pins.get(cmd.bNodeId); + if (existing === null) { + pins.pin(cmd.bNodeId, candidate); // first pairing: the plane-delivered keys become the pin (§4.4/§4.6's accepted window) + } else if (pins.check(cmd.bNodeId, candidate) !== "ok") { + // Hard block naming the peer and pointing at recovery - §4.5: re-pair is + // an operator act; nothing here may "trust anyway". + throw new Error( + `relay open refused: machine pin for ${cmd.bNodeId} has MOVED (re-pair per §4.5 after out-of-band verification)`, + ); + } + + const identity = await loadOrCreateIdentity(args.dataDir); + const responder = startRelayResponder({ + relayId: cmd.relayId, + ref: cmd.ref, + selfNodeId: args.selfNodeId, + peerNodeId: cmd.bNodeId, + peerSigningJwk: candidate.signing, + peerEncryptionJwk: candidate.encryption, + ownEncryptionPublicJwk: identity.publicJwk, + ownEncryptionPrivateJwk: identity.privateJwk, + ownSigningPrivateJwk: identity.signingPrivateJwk, + fingerprints: cmd.fingerprints, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: args.sendRelayFrame, + resolveAgentSocket: args.resolveAgentSocket ?? liveAgentSocketPath, + ...(args.log === undefined ? {} : { log: args.log }), + }); + + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), + close: (reason) => responder.close(reason), + }) + ) { + responder.close(); + throw new Error("relay open refused: routing ref already owned by a live session"); + } + return { relayId: cmd.relayId }; +} diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts new file mode 100644 index 000000000..b41f31a60 --- /dev/null +++ b/apps/node/agent/src/relay-responder.ts @@ -0,0 +1,599 @@ +import { createHash } from "node:crypto"; +import { createConnection, type Socket } from "node:net"; +import { + base64UrlNoPad, + bytesOfJwk, + newNonce, + openRelayEnvelope, + type RelayFrame, + type RelayOpenFn, + type RelaySealFn, + SeqGate, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { log } from "./log.js"; + +/** + * A-side responder of the sealed agent relay (spec 2026-10-08 §5.4/§5.6): the + * key home's gate. One inbound B2A relay frame carries one ssh-agent request + * sealed from B; this module opens it with A's own encryption key, verifies + * origin against B's PINNED signing key with the full Task-5 caller checklist + * (frame-bound open, structurally bound relaySessionId / nB / nA, per-session + * {@link SeqGate}), default-denies the agent method, enforces the grant's + * fingerprint set against A's LIVE agent socket, and seals+signs the (possibly + * filtered or refused) agent answer back as an A2B frame. + * + * The two rules of §5.4, in the order they run: + * + * 1. **Method allow-list, default-deny.** Exactly the two request types SSH + * publickey auth uses are forwarded, matched by numeric type byte on the + * wire; every other type (identity mutations, LOCK/UNLOCK, EXTENSION, the + * SSH1-era range, anything unknown) is refused WITHOUT touching A's agent. + * A granted window therefore cannot mutate or lock A's agent against its + * other panes, by default-deny rather than a name list that could miss a + * code. + * 2. **Identity scoping.** The IDENTITIES_ANSWER is rebuilt with only the + * entries whose `SHA256:` fingerprint is in the grant's selected set, and a + * SIGN_REQUEST whose key blob hashes outside the set is refused before it + * is ever forwarded. A grant naming no fingerprint serves nothing. + * + * **Agent-only (§3 item 4):** the only path to a key is A's running agent + * socket (`SSH_AUTH_SOCK`, the connecting account's own, the same + * absolute-path rule `ssh-resolve.ts` established for the launch side). No + * private key file is opened here, and nothing about key material is ever + * logged. + * + * Refusal semantics mirror Task 6's proxy: an unopenable envelope is + * plane-crafted noise and costs a log line (§5.6 first line: the plane holds + * A's public key and can seal at will); an envelope that opens but fails + * verification against the pin is a FORGERY and is refused with NO reply sent + * (answering a forgery would hand the attacker a signature oracle and spend a + * real seq against B's stream); a SeqGate refusal is refused silently-ish, + * never punished, because the plane routes and can resend anything. A refused + * REQUEST still gets the session's normal answer only in the agent layer: + * disallowed or out-of-scope agent requests are answered with + * `SSH2_AGENT_FAILURE` (B's ssh sees a clean "agent refused", never a hang), + * while refused FRAMES (origin/replay) never produce any A2B at all. + * + * Nonce protocol (§5.6, A's side of the mirror): A mints `nA` once at session + * start and carries it in every A2B alongside the recorded `nB`. B carries + * `nB` in every request, so `nB` is recorded from the first verified request + * and bound thereafter; B cannot carry `nA` until A's first reply reaches it, + * and B's proxy never seals a second request before that reply lands + * (Task 6's single-outstanding FIFO), so `nA` is unbound only for the session's + * first verified request and hard-bound from the second onward. + * + * seal/open are INJECTED exactly as at the B side: the protocol package cannot + * import mcp-core; the caller (commands/ssh-relay.ts, production glue) passes + * mcp-core's pair straight through. + */ + +/* ------------------------------------------------------------------ */ +/* the ssh-agent wire (OpenSSH `agent-proto.h` values) */ +/* ------------------------------------------------------------------ */ + +/** + * `SSH2_AGENTC_REQUEST_IDENTITIES` - ask for the public-key list. The value + * is from OpenSSH's `agent-proto.h` (NOT the RFC 9987 renumbering, where this + * is 11: the wire and the real agent are OpenSSH, and the real-agent e2e is + * the arbiter). One of the TWO forwarded request types. + */ +export const SSH2_AGENTC_REQUEST_IDENTITIES = 13; + +/** + * `SSH2_AGENTC_SIGN_REQUEST` - sign with a named blob. The value is from + * OpenSSH's `agent-proto.h` (RFC 9987 calls this 13 - not this wire). One of + * the TWO forwarded request types. + */ +export const SSH2_AGENTC_SIGN_REQUEST = 15; + +/** `SSH2_AGENT_IDENTITIES_ANSWER` (agent-proto.h): the roster reply this module filters. */ +export const SSH2_AGENT_IDENTITIES_ANSWER = 2; + +/** `SSH2_AGENT_SIGN_RESPONSE` (agent-proto.h): the signature reply, forwarded verbatim (it is opaque). */ +export const SSH2_AGENT_SIGN_RESPONSE = 14; + +/** `SSH2_AGENT_FAILURE` (agent-proto.h): the uniform answer to any refused request. */ +export const SSH2_AGENT_FAILURE = 5; + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Local I/O budget for one round trip to A's agent (a local Unix socket call; ssh's own is unbounded, ours is not). */ +const AGENT_REQUEST_TIMEOUT_MS = 10_000; + +/** The single-byte refusal every refused request is answered with. */ +const FAILURE = Buffer.from([SSH2_AGENT_FAILURE]); + +/* ------------------------------------------------------------------ */ +/* agent-wire framing + parsing (4-byte BE length + payload, shared */ +/* shape with the B proxy; the TYPE parsing is A-only per §5.4) */ +/* ------------------------------------------------------------------ */ + +/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ +class ByteReader { + #buf: Buffer; + #off = 0; + + constructor(buf: Buffer) { + this.#buf = buf; + } + + u32(): number { + if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); + const v = this.#buf.readUInt32BE(this.#off); + this.#off += 4; + return v; + } + + bytes(n: number): Buffer { + if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); + const v = this.#buf.subarray(this.#off, this.#off + n); + this.#off += n; + return v; + } + + /** An SSH string: uint32 length + bytes. */ + string(): Buffer { + return this.bytes(this.u32()); + } + + done(): boolean { + return this.#off === this.#buf.length; + } +} + +/** Serialize an SSH string for the rebuilt answer: uint32 length + bytes. */ +function sshString(bytes: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(bytes.length, 0); + return Buffer.concat([header, bytes]); +} + +/** + * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the + * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + + * base64url without padding - the same scheme `fingerprintJwk` established + * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also + * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. + */ +export function fingerprintAgentBlob(blob: Uint8Array): string { + return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; +} + +/** + * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob + * fingerprints into `allowed`; fixes the count; kept entries ride byte- + * identically. Throws on a malformed answer (type byte, declared entries the + * buffer does not carry, trailing bytes) - an unparseable answer is refused + * as a failure, NEVER forwarded unfiltered, because a partial parse of the + * roster is exactly what §5.4 exists to prevent. + */ +export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet): Buffer { + if (answer.length < 5 || answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { + throw new Error("relay responder: expected an SSH2_AGENT_IDENTITIES_ANSWER"); + } + const reader = new ByteReader(answer.subarray(1)); + const count = reader.u32(); + const kept: Buffer[] = []; // one item per KEPT ENTRY (both fields pre-concatenated, so the rebuilt count is the entry count) + for (let i = 0; i < count; i += 1) { + const blob = reader.string(); + const comment = reader.string(); + if (allowed.has(fingerprintAgentBlob(blob))) kept.push(Buffer.concat([sshString(blob), sshString(comment)])); + } + if (!reader.done()) throw new Error("relay responder: IDENTITIES_ANSWER carries trailing bytes"); + return Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), sshStringCount(kept.length), ...kept]); +} + +/** The count field of a rebuilt answer (an unframed uint32, unlike an SSH string). */ +function sshStringCount(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** + * Split a SIGN_REQUEST into its key blob (the parse throws on anything short + * of the full grammar: string blob + uint32 flags + nothing else). The flags + * ride through to the agent unexamined: §5.4 scopes by BLOB, not by flag. + */ +export function parseSignRequest(payload: Buffer): { keyBlob: Buffer; flags: number } { + const reader = new ByteReader(payload.subarray(1)); + const keyBlob = reader.string(); + const flags = reader.u32(); + if (keyBlob.length === 0) throw new Error("relay responder: SIGN_REQUEST names an empty key blob"); + if (!reader.done()) throw new Error("relay responder: SIGN_REQUEST carries trailing bytes"); + return { keyBlob, flags }; +} + +/* ------------------------------------------------------------------ */ +/* A's live agent socket */ +/* ------------------------------------------------------------------ */ + +/** + * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, + * else null - the same trust rule `ssh-resolve.ts` runs for the launch side + * ("the env sock is trusted because it is the connecting account's own + * setup"). A null answer is the honest no-agent case: the responder refuses + * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). + */ +export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { + const sock = env.SSH_AUTH_SOCK; + return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; +} + +/** + * One request/response round trip to A's agent over the Unix socket: + * connect, write one framed message, read one framed answer, close. Each + * request rides its own connection (ssh's agent protocol permits it; no + * session state lives on the socket). Rejects on connect failure, timeout, + * an empty or over-cap answer, or a close before a complete answer. + */ +function requestLiveAgent(socketPath: string, payload: Buffer): Promise { + return new Promise((resolve, reject) => { + let conn: Socket; + try { + conn = createConnection({ path: socketPath }); + } catch (err) { + reject(new Error(`relay responder: cannot reach the agent socket: ${String(err)}`)); + return; + } + let buffer = Buffer.alloc(0); + let settled = false; + const fail = (why: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + conn.destroy(); + } catch { + /* already gone */ + } + reject(new Error(`relay responder: ${why}`)); + }; + const timer = setTimeout(() => fail("agent request timed out"), AGENT_REQUEST_TIMEOUT_MS); + conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); + conn.on("connect", () => { + conn.write(framing(payload)); + }); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { + fail(`agent answer length ${len} is empty or over the cap`); + return; + } + if (buffer.length < 4 + len) return; + const answer = Buffer.from(buffer.subarray(4, 4 + len)); + settled = true; + clearTimeout(timer); + try { + conn.end(); + } catch { + /* the destroy below handles it */ + } + conn.destroy(); + resolve(answer); + }); + conn.on("close", () => fail("the agent socket closed before a complete answer")); + }); +} + +/* ------------------------------------------------------------------ */ +/* the responder */ +/* ------------------------------------------------------------------ */ + +/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the pump, the seams. */ +export interface RelayResponderArgs { + /** The relay-session id the plane minted (§5.6: signed into every envelope). */ + relayId: string; + /** The opaque routing ref this session's frames ride (also the registry key). */ + ref: string; + /** This machine's node id: the open-as principal `node:`. */ + selfNodeId: string; + /** B's node id: the seal-to principal `node:` and the pinned-key holder. */ + peerNodeId: string; + /** B's PINNED ES256 signing public JWK (JSON string) - the origin key. */ + peerSigningJwk: string; + /** B's PINNED ECDH-ES encryption public JWK (JSON string) - the seal-to key. */ + peerEncryptionJwk: string; + /** A's own encryption keypair (both JWK JSON strings) - opens B's requests. */ + ownEncryptionPublicJwk: string; + ownEncryptionPrivateJwk: string; + /** A's own ES256 signing PRIVATE JWK - signs every A2B reply. */ + ownSigningPrivateJwk: string; + /** The grant's selected fingerprints (§5.4); empty names nothing, serves nothing. */ + fingerprints: readonly string[]; + /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ + seal: RelaySealFn; + /** The opening primitive - mcp-core's `open`. */ + open: RelayOpenFn; + /** + * Hand one sealed A2B frame to the link pump (the daemon's `send`, via the + * ctx seam). DELIVER-OR-THROW (the Task-6 contract, restated for A): a + * dropped send must THROW here. A throw is NON-DELIVERY: no seq is + * consumed, and B's proxy (still holding its outstanding slot) stalls until + * the relay's external teardown - Task 8's broker closes the session on + * its timers. + */ + sendRelayFrame(frame: RelayFrame): void; + /** + * Resolve A's live agent socket per request (default {@link + * liveAgentSocketPath}). A test seam and the operator override land here; + * returning null is the honest no-agent case. + */ + resolveAgentSocket?: () => string | null; + /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ + log?: (line: string) => void; +} + +/** The running responder: the two lifecycle verbs the registry adapter uses. */ +export interface RelayResponderHandle { + /** + * Feed one INBOUND relay frame parsed off the link to this session. Only + * B2A frames for this session's ref are honored; anything else is dropped + * with a log line (defense in depth - the registry already routed by ref). + * Never throws: a refused frame is a refusal, not a crash. + */ + deliverInboundRelayFrame(frame: RelayFrame): void; + /** End the session (§5.6's named close word from the registry, or a local cut). Idempotent. */ + close(reason?: string): void; +} + +/** + * Start the A-side responder for ONE brokered session: validate B's pins + * (deep public-only P-256 via {@link bytesOfJwk}, the same gate the B proxy + * runs), mint A's session nonce, and return the handler that + * `commands/ssh-relay.ts` registers into {@link RelaySessions}. Throws a + * named refusal when either pinned half is not usable public material. + */ +export function startRelayResponder(args: RelayResponderArgs): RelayResponderHandle { + const say = args.log ?? ((line: string): void => log(line)); + + // Pin sanity BEFORE anything binds (the B side's MINOR-3 order): bytesOfJwk + // parses the string itself, so junk, private material, and a foreign curve + // all land in the guard and surface as the named refusal. Parsing once + // AFTER the guard keeps the per-frame verify free of JSON work. + try { + bytesOfJwk(args.peerSigningJwk); + bytesOfJwk(args.peerEncryptionJwk); + } catch (err) { + throw new Error(`relay responder: pinned peer key rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const peerSigning = JSON.parse(args.peerSigningJwk) as JsonWebKey; + const ownSigningPrivate = JSON.parse(args.ownSigningPrivateJwk) as JsonWebKey; + const ownEncryption = { + principalId: `node:${args.selfNodeId}`, + publicJwk: args.ownEncryptionPublicJwk, + privateJwk: args.ownEncryptionPrivateJwk, + }; + const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; + const allowed = new Set(args.fingerprints); + const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + + // Session state (§5.6): A's nonce is minted once and every A2B carries it; + // B's is recorded from the first verified request. `nASeenBinding` marks + // that the first verified request has passed, from which point every + // inbound message MUST carry A's nonce (B learns it from A's first reply, + // and B's single-outstanding FIFO means B's next request was sealed after + // that reply landed - a request without it is a replay or a forgery). + const nA = newNonce(); + let nB: string | undefined; + let bindNA = false; + const gate = new SeqGate(); + let signedSeq = 0; + let transportSeq = 0; + let closed = false; + + /** The agent-layer decision for one decoded request: the payload to answer B with. */ + const respondToAgent = async (request: Buffer): Promise => { + if (request.length === 0) return FAILURE; // no type byte: nothing allow-listed, nothing forwarded + const type = request[0]; + const agentPath = resolveAgent(); + if (type !== SSH2_AGENTC_REQUEST_IDENTITIES && type !== SSH2_AGENTC_SIGN_REQUEST) { + // §5.4's default-deny: by the REFUSED set's behavior, not a lookup. Log the + // numeric type (not a secret) so the operator can see what B asked for. + say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + return FAILURE; + } + if (type === SSH2_AGENTC_SIGN_REQUEST) { + let keyBlob: Buffer; + try { + ({ keyBlob } = parseSignRequest(request)); + } catch { + say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST; not forwarded`); + return FAILURE; + } + // §5.4's signing gate: checked HERE, before anything touches the agent. + if (!allowed.has(fingerprintAgentBlob(keyBlob))) { + say(`relay session ${args.ref}: refused a SIGN_REQUEST outside the grant set; not forwarded`); + return FAILURE; + } + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let response: Buffer; + try { + response = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + // The signature is opaque and the blob was already scoped, but the + // response TYPE still gates: only SIGN_RESPONSE and the agent's own + // FAILURE ride onward; anything unexpected becomes a failure. + if (response.length < 1 || (response[0] !== SSH2_AGENT_SIGN_RESPONSE && response[0] !== SSH2_AGENT_FAILURE)) { + say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); + return FAILURE; + } + return response; + } + // SSH2_AGENTC_REQUEST_IDENTITIES: the roster goes to the agent, comes + // back, and is FILTERED here - the grant set is enforced at A, the only + // machine that can see A's whole roster. + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let answer: Buffer; + try { + answer = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + if (answer.length < 1) return FAILURE; + if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal + if (answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { + say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); + return FAILURE; + } + try { + return filterIdentitiesAnswer(answer, allowed); + } catch { + // An unparseable roster NEVER rides on: partial parsing is how an + // ungranted identity could leak. The pane sees a clean failure. + say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); + return FAILURE; + } + }; + + /** The full inbound pipeline for one frame: open, verify, gate, answer. */ + const handleFrame = async (frame: RelayFrame): Promise => { + if (closed) return; + let opened; + try { + // Checklist step 1: the FRAME's own routing facts bind the wrapper + // (M-1) - a relayed blob addressed through another ref or direction + // never reaches a key. + opened = await openRelayEnvelope({ + blob: frame.blob, + own: ownEncryption, + open: args.open, + expect: { ref: frame.ref, direction: frame.direction }, + }); + } catch { + // Unopenable noise from the routing plane (which holds A's public key + // and can craft seals all by itself, §5.6). Cost it a log line. + say(`relay session ${args.ref}: refused an inbound frame (envelope did not open)`); + return; + } + let message; + try { + // Checklist steps 2+3+4 in one call (I-1): origin against B's PINNED + // signing key, this session's relayId, and the nonces this endpoint + // holds. An openable wrapper with a foreign signature or another + // session's facts is a FORGERY, not noise: it is refused and NOTHING + // is sent back - answering a forgery would sign real A2B envelopes + // against attacker-chosen state. + message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: peerSigning, + expect: { + routingRef: args.ref, + direction: "B2A", + seq: opened.seq, + relaySessionId: args.relayId, + ...(nB === undefined ? {} : { nB }), + ...(bindNA ? { nA } : {}), + }, + }); + } catch { + say(`relay session ${args.ref}: refused a forgery (origin/binding verification failed); nothing sent`); + return; + } + // §5.6's nonce discipline, A's side: B carries nB in EVERY request, and + // carries nA from the first request sealed after A's first reply landed. + // A request before the flip that already carries nA cannot be genuine + // (A's nA has not been delivered), and one after the flip without it is + // refused by the binding itself. + if (message.nB === undefined || (!bindNA && message.nA !== undefined)) { + say(`relay session ${args.ref}: refused a request with a malformed nonce pair; nothing sent`); + return; + } + if (!gate.accept("B2A", message.seq)) { + // The plane routes - and can resend - everything (§5.6). A resend is + // refused, not punished: no agent traffic, no reply, seq unconsumed. + say(`relay session ${args.ref}: refused a replayed/rewound request (seq ${message.seq})`); + return; + } + if (nB === undefined) nB = message.nB; // record B's nonce from the first verified request + bindNA = true; // every later request must carry A's nonce back (see above) + + const request = Buffer.from(message.agentBytesB64, "base64url"); + const reply = await respondToAgent(request); + if (closed) return; + const replySeq = signedSeq; + let blob: string; + try { + blob = await sealRelayEnvelope({ + message: { + relaySessionId: args.relayId, + routingRef: args.ref, + direction: "A2B", + seq: replySeq, + nB: message.nB, // the SAME value the request carried, signed onward + nA, + agentBytesB64: base64UrlNoPad(new Uint8Array(reply)), + }, + privateJwk: ownSigningPrivate, + recipient, + seal: args.seal, + }); + } catch (err) { + // Malformed key material or an over-cap reply: nothing was sent, so no + // seq is consumed. B's proxy stalls on its outstanding slot until the + // relay's external teardown (Task 8's broker closes on its timers). + say(`relay session ${args.ref}: could not seal a reply: ${err instanceof Error ? err.message : String(err)}`); + return; + } + try { + args.sendRelayFrame({ type: "relay", ref: args.ref, seq: transportSeq, direction: "A2B", blob }); + } catch (err) { + // DELIVER-OR-THROW: a thrown pump means B never received the answer. + // No seq is consumed (B's next request lawfully re-uses the numbers); + // the session stalls to external teardown, the same posture as a drop. + say(`relay session ${args.ref}: the link pump refused a reply: ${String(err)}`); + return; + } + signedSeq += 1; + transportSeq += 1; + }; + + // Frames are processed in ARRIVAL order, one chain deep: the SeqGate is + // per-session and must never race two opens out of order (Task 6's rule). + let deliverChain: Promise = Promise.resolve(); + + return { + deliverInboundRelayFrame(frame: RelayFrame): void { + if (closed) return; + if (frame.ref !== args.ref || frame.direction !== "B2A") { + // Misrouting is a registry bug, not a threat; an A2B frame is A's own + // echo. Either way it never reaches the request path. + say(`relay session ${args.ref}: dropped a non-B2A or foreign-ref inbound frame`); + return; + } + deliverChain = deliverChain + .then(() => handleFrame(frame)) + .catch((err: unknown) => { + say(`relay session ${args.ref}: inbound handling failed: ${String(err)}`); + }); + }, + close(reason?: string): void { + if (closed) return; + closed = true; + say(`relay session ${args.ref}: A-side responder closed${reason ? ` (${reason})` : ""}`); + }, + }; +} From 757a895a46d53c5b90b0944933708e81a021bafb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:20:24 -0700 Subject: [PATCH 098/170] docs(ssh-relay): agent numbering is probed, not baked (measured OpenSSH 10.2 = 11/13, collides with classic 13/15) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 6 +++++- docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 05e43a6f4..89d456702 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,11 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread). Use OpenSSH's numbering (identities 13, sign 15); RFC 9987 renumbers these to 11/13 - do NOT use RFC 9987 here, the wire and the agent are OpenSSH; the real-agent e2e (Task 16) is the arbiter. Refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request. + + **Agent numbering is probed, not hard-coded (operator ruling 2026-10-08).** The agent request/response codepoints are NOT a single fixed set the responder may bake in: OpenSSH's classic numbering (identities request 13 / answer 14 / sign request 15 / sign response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / sign response 14) COLLIDE - codepoint 13 is *identities* under classic but *sign* under RFC, and 14 is *answer* under classic but *sign response* under RFC. Measured on this fleet's `OpenSSH_10.2p1`: request 11 answers 12; request 13 and 15 both answer FAILURE(5). Therefore the responder MUST, once per relay-session open, **probe A's live agent** to learn which numbering it speaks, then classify and scope every request in that discovered scheme. Probe by sending a well-formed identities request in one scheme and reading the response code: a valid IDENTITIES_ANSWER (12 or 14) names the scheme; a FAILURE(5) to the candidate that would be *sign* under the other scheme confirms the choice. Cache the resolved scheme on the A-side session; never re-interpret a byte without it. + + Forward ONLY the two read methods the scheme names: the identities request and the sign request. Do NOT trust any remembered numeric value - derive the forward-decision from the probed scheme, and default-deny ANY request type the scheme does not name as identities or sign (ADD_IDENTITY, REMOVE_IDENTITY, REMOVE_ALL_IDENTITIES, EXPLICIT_* / EXTENSION, and any unrecognized byte). **Scoping runs BEFORE any forward, for whichever codepoint the probed scheme treats as sign:** on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, resolve the target key by its blob/agent reference and refuse (null) when that fingerprint is outside the grant set - never forward a sign request whose key is ungranted. Accept the RFC 9987 extended sign grammar (a sign request carries a trailing `string algorithms` list after the blob) as well as the classic (blob-only) form; parse both, refuse a malformed body, and refuse rather than forward an ambiguous byte-13 that the scheme has not confirmed as sign. Talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). The real-agent e2e (Task 16) is the arbiter that the probe picks the right scheme for a live `OpenSSH_10.x` agent. - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 6f2936b75..9ef4d77ef 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -93,6 +93,8 @@ A's responder accepts an **agent-protocol** request only when the plane brokered **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. +**The numeric scheme is probed, not baked (ruling 2026-10-08).** "By symbolic type, matched on the wire" cannot mean a fixed codepoint: OpenSSH's classic agent numbering (identities 13 / answer 14 / sign 15 / response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / response 14) collide at 13 and 14, so byte 13 is *identities* on one agent and *sign* on the other. The responder probes A's live agent once per relay-session open - a well-formed identities request in one scheme, whose answer code (a valid IDENTITIES_ANSWER, or FAILURE to the candidate that the other scheme reads as sign) names the scheme - caches that scheme on the session, and classifies and scopes every later request in it. This is load-bearing for the scoping rule below it: the fingerprint check must run against whichever codepoint A's agent actually treats as `SIGN_REQUEST`, so an ungranted sign never reaches the agent. A request the resolved scheme does not name as identities or sign is refused, never forwarded, and a byte whose scheme is not yet resolved is refused; the responder never forwards an ambiguous codepoint under a guessed interpretation. + **Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections); an approval selecting more than the cap is a hard red error on the grants screen, never a silent truncation. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). From a83f91fed5810f6aebfca1b5dac24dadc856f8b1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:30:09 -0700 Subject: [PATCH 099/170] feat(ssh-relay): probe A's agent numbering; scope sign before forward --- .../src/__tests__/relay-agent-scheme.test.ts | 285 ++++++++ .../src/__tests__/relay-agent-socket.test.ts | 129 ++++ .../agent/src/__tests__/relay-proxy.test.ts | 14 +- .../src/__tests__/relay-responder.test.ts | 617 +++++++++++++----- apps/node/agent/src/commands/ssh-relay.ts | 53 +- apps/node/agent/src/relay-agent-scheme.ts | 273 ++++++++ apps/node/agent/src/relay-agent-socket.ts | 99 +++ apps/node/agent/src/relay-responder.ts | 414 ++++-------- 8 files changed, 1408 insertions(+), 476 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-agent-scheme.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-agent-socket.test.ts create mode 100644 apps/node/agent/src/relay-agent-scheme.ts create mode 100644 apps/node/agent/src/relay-agent-socket.ts diff --git a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts new file mode 100644 index 000000000..acefbb416 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + type AgentScheme, + CLASSIC_SCHEME, + filterIdentitiesAnswer, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, + parseIdentitiesAnswer, + parseSignRequest, + probeAgentScheme, + SSH2_AGENT_FAILURE, +} from "../relay-agent-scheme.js"; + +/** + * Byte-literal fixtures for the two ssh-agent numbering schemes the A-side + * responder probes between (spec 2026-10-08 §5.4, ruling 2026-10-08): the + * RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / + * sign response 14, sign carrying the extended grammar) and OpenSSH's classic + * numbering (13 / 14 / 15 / 16). Every codepoint here is written as a plain + * number - the constants are asserted AGAINST the literals, never derived + * from the production module, so numeric drift cannot hide. The FAILURE(5) + * answers mirror the live measurement on this fleet's OpenSSH_10.2p1: a byte + * 13 (a truncated sign under 10.x) answers FAILURE, and a byte 11 answers 12. + */ + +/* ---------------- the agent wire, spelled by hand ---------------- */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_A = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-A"))]); +const KEY_B = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-B"))]); + +/** An IDENTITIES_ANSWER spelled in ONE scheme's answer byte, from raw numbers. */ +function answerWith(answerByte: number, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([answerByte]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/* ---------------- the scheme data: measured literals ---------------- */ + +test("the schemes carry the measured codepoints and collide exactly where the ruling says", () => { + // RFC 9987 numbering, shipped by OpenSSH 10.x (measured OpenSSH_10.2p1: + // request 11 answers 12; request 13 and 15 answer FAILURE 5). + expect([OPENSSH_10X_SCHEME.identities, OPENSSH_10X_SCHEME.answer]).toEqual([11, 12]); + expect([OPENSSH_10X_SCHEME.sign, OPENSSH_10X_SCHEME.signResponse]).toEqual([13, 14]); + expect(OPENSSH_10X_SCHEME.extendedSign).toBe(true); + // OpenSSH's classic numbering: identities 13 / answer 14 / sign 15 / response 16. + expect([CLASSIC_SCHEME.identities, CLASSIC_SCHEME.answer]).toEqual([13, 14]); + expect([CLASSIC_SCHEME.sign, CLASSIC_SCHEME.signResponse]).toEqual([15, 16]); + expect(CLASSIC_SCHEME.extendedSign).toBe(false); + // SSH2_AGENT_FAILURE is 5 in both (the measurement's third probe). + expect(SSH2_AGENT_FAILURE).toBe(5); + // The collisions the probe exists to resolve: 13 is classic identities but + // 10.x sign; 14 is classic answer but 10.x sign response. + expect(CLASSIC_SCHEME.identities).toBe(OPENSSH_10X_SCHEME.sign); + expect(CLASSIC_SCHEME.answer).toBe(OPENSSH_10X_SCHEME.signResponse); +}); + +/* ---------------- fingerprint spelling (the grant grammar) ---------------- */ + +test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { + const s = fingerprintAgentBlob(Buffer.from("hello")); + expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); + expect(s).toBe(fp(Buffer.from("hello"))); +}); + +/* ---------------- IDENTITIES_ANSWER parsing and filtering ---------------- */ + +test("parseIdentitiesAnswer accepts only the resolved scheme's answer byte", () => { + const tenX = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + const classic = answerWith(14, [{ blob: KEY_A, comment: "a" }]); + expect(parseIdentitiesAnswer(tenX, OPENSSH_10X_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + expect(parseIdentitiesAnswer(classic, CLASSIC_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + // Byte 14 is the ANSWER under classic and the SIGN RESPONSE under 10.x: + // a resolver must never accept it as a roster in the wrong scheme. + expect(() => parseIdentitiesAnswer(classic, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(tenX, CLASSIC_SCHEME)).toThrow(); +}); + +test("parseIdentitiesAnswer refuses truncated entries and trailing bytes", () => { + const full = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + expect(() => parseIdentitiesAnswer(full.subarray(0, full.length - 1), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(Buffer.concat([full, Buffer.from([0])]), OPENSSH_10X_SCHEME)).toThrow(); + // A declared count the buffer does not carry. + const overcounted = Buffer.concat([Buffer.from([12]), be32(2), sshStr(KEY_A), sshStr("a")]); + expect(() => parseIdentitiesAnswer(overcounted, OPENSSH_10X_SCHEME)).toThrow(); + // The shortest legal answer: type + count 0. + expect(parseIdentitiesAnswer(Buffer.from([12, 0, 0, 0, 0]), OPENSSH_10X_SCHEME)).toEqual([]); +}); + +test("filterIdentitiesAnswer keeps granted entries byte-identical under BOTH schemes' answer byte", () => { + const roster = [ + { blob: KEY_A, comment: "granted" }, + { blob: KEY_B, comment: "not granted" }, + ]; + const allowed = new Set([fp(KEY_B)]); // grant the SECOND entry: order must not matter + for (const [scheme, answerByte] of [ + [OPENSSH_10X_SCHEME, 12], + [CLASSIC_SCHEME, 14], + ] as [AgentScheme, number][]) { + const filtered = filterIdentitiesAnswer(answerWith(answerByte, roster), allowed, scheme); + // The rebuilt answer carries THIS scheme's answer byte - not a foreign + // one, and never the first pass's invented 2. + expect([...filtered]).toEqual([answerByte, 0, 0, 0, 1, ...sshStr(KEY_B), ...sshStr("not granted")]); + expect(parseIdentitiesAnswer(filtered, scheme)).toEqual([{ blob: KEY_B, comment: Buffer.from("not granted") }]); + } + // An empty grant serves the count-zero answer, five bytes, nothing else. + expect([...filterIdentitiesAnswer(answerWith(12, roster), new Set(), OPENSSH_10X_SCHEME)]).toEqual([12, 0, 0, 0, 0]); + // A malformed answer throws (the responder must refuse it, never forward). + expect(() => filterIdentitiesAnswer(Buffer.from([12, 0, 0]), new Set(), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => filterIdentitiesAnswer(answerWith(14, roster), new Set([fp(KEY_A)]), OPENSSH_10X_SCHEME)).toThrow(); +}); + +/* ---------------- SIGN_REQUEST grammar: classic and extended ---------------- */ + +test("parseSignRequest accepts the classic body (blob, data, flags) in the classic scheme", () => { + const body = Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0)]); + const parsed = parseSignRequest(body, CLASSIC_SCHEME); + expect(parsed.keyBlob).toEqual(KEY_A); + expect(parsed.data).toEqual(Buffer.from("DATA")); + expect(parsed.flags).toBe(0); + expect(parsed.algorithms).toBeUndefined(); +}); + +test("parseSignRequest accepts the extended 10.x body (trailing algorithms string) and the bare form", () => { + const bare = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(1)]); + expect(parseSignRequest(bare, OPENSSH_10X_SCHEME).algorithms).toBeUndefined(); + const extended = Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_A), + sshStr("DATA"), + be32(0), + sshStr("ssh-ed25519,rsa-sha2-512"), + ]); + expect(parseSignRequest(extended, OPENSSH_10X_SCHEME).algorithms).toEqual(Buffer.from("ssh-ed25519,rsa-sha2-512")); +}); + +test("parseSignRequest refuses every malformed body in both schemes", () => { + const bare = (scheme: AgentScheme, tail: Buffer): Buffer => + Buffer.concat([Buffer.from([scheme.sign]), sshStr(KEY_A), tail]); + // Blob-only (no data, no flags): a body the wire never legally carries. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, Buffer.alloc(0)), CLASSIC_SCHEME)).toThrow(); + expect(() => parseSignRequest(bare(OPENSSH_10X_SCHEME, Buffer.alloc(0)), OPENSSH_10X_SCHEME)).toThrow(); + // The first pass's invented blob+flags shape: still not the wire grammar. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, be32(0)), CLASSIC_SCHEME)).toThrow(); + // Truncated string lengths, missing flags, trailing garbage. + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), Buffer.from([0, 0, 0, 9]), KEY_A]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA")]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), + CLASSIC_SCHEME, + ), + ).toThrow(); + // A trailing algorithms string is legal only under the extended scheme, + // and it must itself be well-formed there. + const trailing = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), sshStr("algos")]); + expect(() => parseSignRequest(trailing, CLASSIC_SCHEME)).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0, 0, 99])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // An empty key blob names nothing to scope: refused. + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); +}); + +/* ---------------- the probe: resolving the live agent's numbering ---------------- */ + +/** Records every payload a fake agent received and answers per a script. */ +function scriptedAgent(answer: (payload: Buffer) => Buffer | "throw"): { + sent: Buffer[]; + requestAgent: (socketPath: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_socketPath: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + const a = answer(payload); + if (a === "throw") throw new Error("connect refused"); + return a; + }, + }; +} + +const EMPTY_ANSWER_12 = Buffer.from([12, 0, 0, 0, 0]); +const EMPTY_ANSWER_14 = Buffer.from([14, 0, 0, 0, 0]); + +test("the probe resolves openssh-10x from the 11-to-12 answer after the classic candidate answers FAILURE", async () => { + // The measured OpenSSH_10.2p1 pair: byte 13 (a truncated sign) answers 5, + // byte 11 (identities) answers a valid 12. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return "throw"; + }); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("the probe resolves classic from the 13-to-14 answer in a single probe", async () => { + const agent = scriptedAgent((p) => (p.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]))); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(CLASSIC_SCHEME); + // The classic agent answered the first candidate: no second probe is sent. + expect(agent.sent).toEqual([Buffer.from([13])]); +}); + +test("an agent that answers FAILURE to both candidates resolves to nothing, never a guessed scheme", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBeNull(); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("a probe answer must PARSE as an IDENTITIES_ANSWER: a bare type byte confirms nothing", async () => { + // Type 14 with a truncated body is not a valid classic answer (and is what + // a 10.x SIGN_RESPONSE byte would look like); the probe must move on and + // confirm 10.x positively. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([14, 0, 0, 0, 2, 0, 0, 0, 1, 65]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return Buffer.from([5]); + }); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent.length).toBe(2); +}); + +test("an unknown answer byte and a transport failure both move the probe on and resolve to nothing", async () => { + const junk = scriptedAgent(() => Buffer.from([99])); + expect(await probeAgentScheme("/fake/agent.sock", junk.requestAgent)).toBeNull(); + expect(junk.sent.length).toBe(2); + const dead = scriptedAgent(() => "throw"); + expect(await probeAgentScheme("/fake/agent.sock", dead.requestAgent)).toBeNull(); + expect(dead.sent.length).toBe(2); // every candidate was tried before giving up +}); + +test("the probe never sends anything but one-byte identities requests (a probe must never reach a signature)", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + for (const payload of agent.sent) { + expect(payload.length).toBe(1); // never a sign body: a truncated sign can only ever FAILURE + expect([11, 13]).toContain(payload[0]); // only the two candidates' identities bytes + } +}); + +test("probeAgentScheme passes the socket path it was given to every candidate", async () => { + const seen: string[] = []; + const scheme = await probeAgentScheme("/run/user/1000/agent.sock", async (path, payload) => { + seen.push(path); + return payload.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]); + }); + expect(scheme).toBe(CLASSIC_SCHEME); + expect(seen).toEqual(["/run/user/1000/agent.sock"]); +}); diff --git a/apps/node/agent/src/__tests__/relay-agent-socket.test.ts b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts new file mode 100644 index 000000000..8cdb0247d --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; + +/** + * The one-shot agent socket round trip the responder and the numbering probe + * share: connect, write ONE framed message, read ONE framed answer, close. + * These fixtures answer raw bytes, so the framing contract is pinned against + * the real socket layer, not a mock. + */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +interface EchoServer { + path: string; + received: Buffer[]; + close(): Promise; +} + +/** A Unix socket that answers every complete framed request with `answerBytes`. */ +async function startEchoAgent(onRequest: (request: Buffer, conn: Socket) => void): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + onRequest(request, conn); + }); + conn.on("error", () => { + /* the client closes per request; never throw out of the socket layer */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/* ---------------- liveAgentSocketPath (the ssh-resolve rule) ---------------- */ + +test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); + expect(liveAgentSocketPath({})).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); +}); + +/* ---------------- requestLiveAgent (the round trip) ---------------- */ + +test("requestLiveAgent writes exactly one framed request and resolves the framed answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(framed(Buffer.from([12, 0, 0, 0, 0]))); + }); + try { + const request = Buffer.from([11]); + const answer = await requestLiveAgent(server.path, request); + expect([...answer]).toEqual([12, 0, 0, 0, 0]); + expect(server.received.length).toBe(1); + expect(server.received[0]).toEqual(request); // one message, no framing residue, byte-exact + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects an answer whose length header is empty or over the relay cap", async () => { + const overCap = await startEchoAgent((_request, conn) => { + conn.write(Buffer.concat([be32(SSH_RELAY_FRAME_MAX_BYTES + 1), Buffer.from([0])])); + }); + const zeroLen = await startEchoAgent((_request, conn) => { + conn.write(be32(0)); + }); + try { + await expect(requestLiveAgent(overCap.path, Buffer.from([11]))).rejects.toThrow(/cap/); + await expect(requestLiveAgent(zeroLen.path, Buffer.from([11]))).rejects.toThrow(/cap/); + } finally { + await overCap.close(); + await zeroLen.close(); + } +}); + +test("requestLiveAgent rejects when the agent closes before a complete answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(Buffer.from([0, 0, 0, 5, 12])); // a header promising more than follows + conn.end(); + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]))).rejects.toThrow(/closed before/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects on its local timeout when the agent never answers", async () => { + const server = await startEchoAgent(() => { + /* swallow: the agent thinks about it forever */ + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]), 80)).rejects.toThrow(/timed out/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects when nothing listens on the socket", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + await expect(requestLiveAgent(join(dir, "nobody-home.sock"), Buffer.from([11]))).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index 3c4121a30..b73df9b06 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -27,13 +27,13 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. */ /** - * The ssh-agent wire type bytes as OpenSSH `agent-proto.h` defines them: - * SSH2_AGENTC_REQUEST_IDENTITIES is 13 and SSH2_AGENTC_SIGN_REQUEST is 15 - * (NOT the RFC 9987 renumbering 11/13; 17/18 are ADD/REMOVE IDENTITY, the - * common misread the first draft of this file carried). These are opaque - * fixture payloads only: the B proxy forwards agent bytes byte-agnostically - * and never parses a type byte - the numeric type gate is A's responder's - * default-deny (Task 7). + * ssh-agent wire type bytes used as FIXTURE PAYLOADS only: the B proxy + * forwards agent bytes byte-agnostically and never parses a type byte, so the + * numbers here carry no protocol meaning (the classic 13/15 pair from + * `agent-proto.h` is what a pre-10.x agent speaks; OpenSSH 10.x ships the RFC + * 9987 renumbering 11/13 - see the ruling of 2026-10-08, which is why A's + * responder PROBEs the numbering and the numeric type gate is A's responder's + * default-deny, Task 7, never this side). */ const SSH2_AGENTC_REQUEST_IDENTITIES = 13; const SSH2_AGENTC_SIGN_REQUEST = 15; diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 49ac52ddb..b712677be 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -18,48 +18,27 @@ import { import { exportJWK, generateKeyPair } from "jose"; import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; import { loadOrCreateIdentity } from "../identity.js"; -import { - filterIdentitiesAnswer, - fingerprintAgentBlob, - liveAgentSocketPath, - SSH2_AGENT_FAILURE, - SSH2_AGENT_IDENTITIES_ANSWER, - SSH2_AGENT_SIGN_RESPONSE, - SSH2_AGENTC_REQUEST_IDENTITIES, - SSH2_AGENTC_SIGN_REQUEST, - startRelayResponder, -} from "../relay-responder.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { startRelayResponder } from "../relay-responder.js"; /** - * The A-side responder (spec 2026-10-08 §5.4/§5.6): inbound B2A frames are - * opened, origin-verified against B's PINNED signing key with the full Task-5 - * caller checklist, default-denied by agent method, scoped to the grant's - * fingerprint set against A's live agent (a stub Unix socket here), and the - * reply is sealed+signed back as an A2B frame. seal/open are the REAL - * mcp-core pair, the keys are generated exactly as the protocol and proxy - * tests do, and the test side computes fingerprints independently (node:crypto - * over the wire blob) so the scheme is pinned, not echoed. + * The A-side responder (spec 2026-10-08 §5.4/§5.6) under the PROBED numbering + * ruling (ruling 2026-10-08): the session probes A's live agent once at + * relay-open to learn which codepoint scheme it speaks, caches that scheme on + * the session, then classifies and scopes every request in it. The stub + * agents below answer with real-agent byte literals for BOTH schemes - the + * fixtures are written against the measured OpenSSH_10.2p1 behavior (a byte + * 13 truncated sign answers FAILURE 5; a byte 11 answers a valid 12) and + * OpenSSH's classic behavior (13 answers 14; 15 signs with 16), never against + * constants the production module exports, so numeric drift cannot hide. + * seal/open are the REAL mcp-core pair; the test side computes fingerprints + * independently (node:crypto over the wire blob). */ /* ------------------------------------------------------------------ */ -/* the agent wire (OpenSSH `agent-proto.h` values) */ +/* the agent wire, spelled by hand */ /* ------------------------------------------------------------------ */ -/** - * Non-allow-listed request types used as refusals witnesses, named by their - * OpenSSH `agent-proto.h` values: 17 ADD_IDENTITY, 18 REMOVE_IDENTITY, - * 19 REMOVE_ALL_IDENTITIES, 22 LOCK, 23 UNLOCK, 27 EXTENSION, and the - * SSH1-era range starting at 1. (The two FORWARDED types are the production - * constants imported above.) - */ -const SSH2_AGENTC_ADD_IDENTITY = 17; -const SSH2_AGENTC_REMOVE_IDENTITY = 18; -const SSH2_AGENTC_REMOVE_ALL_IDENTITIES = 19; -const SSH2_AGENTC_LOCK = 22; -const SSH2_AGENTC_UNLOCK = 23; -const SSH2_AGENTC_EXTENSION = 27; -const SSH1_AGENTC_REQUEST_RSA_IDENTITIES = 1; - /** An independent fingerprint oracle for the tests: SHA-256, base64url no pad. */ function fp(wire: Uint8Array): string { return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; @@ -77,16 +56,50 @@ function sshStr(bytes: Buffer | string): Buffer { return Buffer.concat([be32(b.length), b]); } -function buildIdentitiesAnswer(entries: { blob: Buffer; comment: string }[]): Buffer { +function buildIdentitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { return Buffer.concat([ - Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), + Buffer.from([scheme.answer]), be32(entries.length), ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), ]); } -function buildSignRequest(keyBlob: Buffer, flags = 0): Buffer { - return Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(keyBlob), be32(flags)]); +/** A classic-scheme SIGN_REQUEST body: blob, data, flags (the classic wire grammar). */ +function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST body: blob, data, flags, optional trailing algorithms string. */ +function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** The stub's independent sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): Buffer | null => { + if (off + 4 > p.length) return null; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return null; + const v = Buffer.from(p.subarray(off, off + n)); + off += n; + return v; + }; + const blob = str(); + if (blob === null || blob.length === 0) return false; + if (str() === null) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; // trailing bytes need the extended grammar + if (str() === null) return false; // algorithms string must be whole + return off === p.length; } /** Decode an IDENTITIES_ANSWER the test way (independent of the production parser). */ @@ -110,20 +123,43 @@ function framed(payload: Buffer): Buffer { } /* ------------------------------------------------------------------ */ -/* the stub live agent: a Unix socket that answers the agent wire */ +/* the stub live agents: Unix sockets speaking one scheme's bytes */ /* ------------------------------------------------------------------ */ interface StubAgent { path: string; - /** Every request payload the stub RECEIVED: the "never forwarded" witness. */ + /** Every request payload the stub RECEIVED, in order (the probes included). */ received: Buffer[]; + /** + * Install an answer mutator AFTER the open-time probe (late binding): the + * probe must always see a clean agent or the scheme would never resolve; + * only later replies get corrupted to drive the response gates. + */ + setMutate(mutate: (kind: "identities" | "sign", answer: Buffer) => Buffer): void; close(): Promise; } -async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Promise { +interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** + * A stub that behaves like a live agent of the given scheme: a one-byte + * identities request answers the full roster in that scheme's answer byte; a + * WELL-FORMED sign body answers SIGN_RESPONSE (it signs anything it is asked: + * "the forwarded list never contains it" is the witness that the responder + * refused before forwarding); anything else - including the probe's one-byte + * classic candidate hitting a 10.x agent, where byte 13 is a truncated sign - + * answers FAILURE(5), exactly the OpenSSH_10.2p1 measurement. {@link + * StubAgent.setMutate} can later corrupt would-be-valid answers (foreign type + * bytes, truncated rosters) to drive the responder's response gates. + */ +async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); const path = join(dir, "agent.sock"); const received: Buffer[] = []; + const state: { mutate?: (kind: "identities" | "sign", answer: Buffer) => Buffer } = {}; const server: Server = createServer((conn: Socket) => { let buffer = Buffer.alloc(0); conn.on("data", (chunk: Buffer) => { @@ -136,14 +172,14 @@ async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Prom buffer = buffer.subarray(4 + len); received.push(request); const type = request[0]; - if (type === SSH2_AGENTC_REQUEST_IDENTITIES) { - conn.write(framed(buildIdentitiesAnswer(roster))); - } else if (type === SSH2_AGENTC_SIGN_REQUEST) { - // The stub signs ANYTHING it is asked: "the stub never received it" - // is the witness that the responder refused before forwarding. - conn.write(framed(Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]))); + if (request.length === 1 && type === scheme.identities) { + const answer = buildIdentitiesAnswer(scheme, roster); + conn.write(framed(state.mutate ? state.mutate("identities", answer) : answer)); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + const answer = Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]); + conn.write(framed(state.mutate ? state.mutate("sign", answer) : answer)); } else { - conn.write(framed(Buffer.from([SSH2_AGENT_FAILURE]))); + conn.write(framed(Buffer.from([5]))); } } }); @@ -156,7 +192,49 @@ async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Prom server.once("listening", () => resolve()); server.listen(path); }); - return { path, received, close: () => new Promise((r) => server.close(() => r())) }; + return { + path, + received, + setMutate: (mutate) => { + state.mutate = mutate; + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +/** A stub answering FAILURE(5) to EVERY request: it speaks neither scheme. */ +async function startUnprobeableAgent(): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + conn.write(framed(Buffer.from([5]))); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* this stub never produces a valid answer to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; } /* ------------------------------------------------------------------ */ @@ -196,12 +274,12 @@ const bKeysReady = machineKeys(); const evilKeysReady: Promise = es256Pair(); /* ------------------------------------------------------------------ */ -/* the fixture: a brokered A session over RelaySessions + the stub */ +/* the fixture: a brokered A session over RelaySessions + a stub agent */ /* ------------------------------------------------------------------ */ const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); -const ROSTER = [ +const ROSTER: RosterEntry[] = [ { blob: KEY_IN, comment: "granted key" }, { blob: KEY_OUT, comment: "ungranted key" }, ]; @@ -210,24 +288,41 @@ interface Fixture { relay: RelaySessions; frames: RelayFrame[]; stub: StubAgent; + /** Messages the stub received AFTER the open-time probe finished. */ + forwarded(): Buffer[]; dataDir: string; aPublic: { signing: string; encryption: string }; aPrivate: { signing: string; encryption: string }; bKeys: { signing: JwkPair; encryption: JwkPair }; + /** The scheme the stub agent speaks and the probe is expected to resolve. */ + scheme: AgentScheme; cleanup(): Promise; } -/** Open the A side with the shipped command shape through the real registration path. */ +/** + * Open the A side with the shipped command shape through the real registration + * path. The numbering probe runs INSIDE openARelaySession, so by the time this + * resolves the stub has already fielded the probe bytes; everything received + * after that point is what the responder forwarded for B's requests. + */ async function openFixture( overrides: Partial = {}, - opts: { resolveAgentSocket?: () => string | null; send?: (frame: RelayFrame) => void } = {}, + opts: { + resolveAgentSocket?: () => string | null; + send?: (frame: RelayFrame) => void; + scheme?: AgentScheme; + roster?: RosterEntry[]; + unprobeable?: boolean; + mutateAnswer?: (kind: "identities" | "sign", answer: Buffer) => Buffer; + } = {}, ): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; const bKeys = await bKeysReady; const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); // The responder loads A's identity from the data dir; mint it first so the // test side knows A's keys (loadOrCreateIdentity is idempotent per dir). const aIdentity = await loadOrCreateIdentity(dataDir); - const stub = await startStubAgent(ROSTER); + const stub = opts.unprobeable ? await startUnprobeableAgent() : await startStubAgent(opts.roster ?? ROSTER, scheme); const relay = new RelaySessions(); const frames: RelayFrame[] = []; const cmd: SshRelayOpenCommand = { @@ -256,14 +351,18 @@ async function openFixture( }), resolveAgentSocket: opts.resolveAgentSocket ?? ((): string => stub.path), }); + const probeCount = stub.received.length; // whatever the open-time probe asked + if (opts.mutateAnswer) stub.setMutate(opts.mutateAnswer); // corrupt only post-probe answers return { relay, frames, stub, + forwarded: (): Buffer[] => stub.received.slice(probeCount), dataDir, aPublic: { signing: aIdentity.signingPublicJwk, encryption: aIdentity.publicJwk }, aPrivate: { signing: aIdentity.signingPrivateJwk, encryption: aIdentity.privateJwk }, bKeys, + scheme, cleanup: async () => { relay.closeAll("lifetime-expiry"); await stub.close(); @@ -351,14 +450,82 @@ async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): P } /* ------------------------------------------------------------------ */ -/* REQUEST_IDENTITIES: forwarded, then filtered to the grant set */ +/* the open-time probe (the 2026-10-08 numbering ruling) */ /* ------------------------------------------------------------------ */ -test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered to the grant fingerprints, and the session's second request rides the recorded nA", async () => { +test("opening a session on a 10.x agent probes classic-then-10.x, resolves on the 11-to-12 answer, and forwards nothing", async () => { + const f = await openFixture(); // the 10.x stub is the fleet default (measured OpenSSH_10.2p1) + try { + // Byte 13 first: the 10.x stub reads it as a truncated sign and answers + // FAILURE(5), the measured behavior. Byte 11 then answers a valid + // IDENTITIES_ANSWER(12): the scheme resolves positively on the 11-to-12 pair. + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); + // The probe's roster is A's own view of the world: it never rides to B. + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("opening a session on a classic agent resolves on the 13-to-14 answer in a single probe", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + expect(f.stub.received).toEqual([Buffer.from([13])]); + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("the probe never sends anything but one-byte identities requests (it can never ask for a signature)", async () => { const f = await openFixture(); try { + for (const payload of f.stub.received) { + expect(payload.length).toBe(1); + expect([11, 13]).toContain(payload[0]); + } + } finally { + await f.cleanup(); + } +}); + +test("an agent the probe cannot resolve refuses every request and forwards nothing beyond the probes", async () => { + const f = await openFixture({}, { unprobeable: true }); // FAILURE(5) to everything + try { + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates tried const nB = newNonce(); - const req1 = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + // Every byte SOME scheme would honor must be refused while the scheme is + // unresolved: never forwarded under a guessed interpretation. + const requests = [ + Buffer.from([11]), + buildSignRequestTenX(KEY_IN), + Buffer.from([13]), + buildSignRequestClassic(KEY_IN), + ]; + let nA: string | undefined; + for (const [i, agentBytes] of requests.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `refusal ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + expect(f.forwarded()).toEqual([]); // nothing beyond the two one-byte probes + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the 10.x scheme: identities 11/answer 12, sign 13/response 14 */ +/* ------------------------------------------------------------------ */ + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) from pinned B gets an IDENTITIES_ANSWER (12) filtered to the grant, and the session's second request rides the recorded nA", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req1 = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); expect(parseRelayFrame(JSON.stringify(req1))).not.toBeNull(); f.relay.onInboundRelayFrame(req1); await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); @@ -373,67 +540,62 @@ test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered expect(reply1.seq).toBe(0); // the signed seq of the first reply const answer = parseAnswer(reply1.agentBytes); - expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.type).toBe(12); // the 10.x answer byte, never an invented 2 (C-1) expect(answer.count).toBe(1); // the stub roster had 2; only the granted fingerprint survives expect(answer.comments).toEqual(["granted key"]); - expect(f.stub.received.length).toBe(1); // the agent saw exactly one request - expect(f.stub.received[0]).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(f.forwarded()[0]).toEqual(Buffer.from([11])); // the agent saw the identities request // The second request carries A's recorded nonce; A's expect binds it onward. - const req2 = await sealRequest(f, { agentBytes: buildSignRequest(KEY_IN), seq: 1, nB, nA: reply1.nA }); + // The 10.x extended grammar: blob, data, flags, trailing algorithms string. + const signReq = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + const req2 = await sealRequest(f, { agentBytes: signReq, seq: 1, nB, nA: reply1.nA }); f.relay.onInboundRelayFrame(req2); await waitUntil(() => f.frames.length === 2, "the second A2B reply frame"); const reply2 = await decodeA2B(f, f.frames[1], { nB, nA: reply1.nA }); expect(f.frames[1].seq).toBe(1); // transport seq advanced expect(reply2.seq).toBe(1); // signed anti-replay seq advanced expect(reply2.nA).toBe(reply1.nA); // every A2B carries the SAME nA (Task-6 handoff) - expect(reply2.agentBytes).toEqual( - Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]), - ); - expect(f.stub.received.length).toBe(2); - expect(f.stub.received[1]).toEqual(buildSignRequest(KEY_IN)); + expect([...reply2.agentBytes]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 14 + expect(f.forwarded()[1]).toEqual(signReq); // byte-identical forward, extended grammar included } finally { await f.cleanup(); } }); -/* ------------------------------------------------------------------ */ -/* SIGN_REQUEST: in-set forwarded, out-of-set refused before the agent */ -/* ------------------------------------------------------------------ */ - -test("a SIGN_REQUEST (15) for an OUT-of-set blob is refused with SSH2_AGENT_FAILURE and never forwarded to A's agent", async () => { +test("C-2 regression: a crafted byte-13 SIGN_REQUEST under the 10.x scheme with an OUT-of-grant blob is refused and never forwarded", async () => { + // Byte 13 is SIGN under 10.x. Before the fix the responder treated it as + // the classic identities request and forwarded it raw, unscoped, so A's + // agent would have signed an attacker-chosen blob. Now the probe resolves + // 10.x and the fingerprint scope must reject it BEFORE any forward. const f = await openFixture(); try { const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: buildSignRequest(KEY_OUT), seq: 0, nB }); + const crafted = buildSignRequestTenX(KEY_OUT); // KEY_OUT is not in the grant + const req = await sealRequest(f, { agentBytes: crafted, seq: 0, nB }); f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); // SSH2_AGENT_FAILURE await sleep(30); - expect(f.stub.received.length).toBe(0); // the stub agent never saw it (§5.4: refused at the responder) + expect(f.forwarded()).toEqual([]); // §5.4: checked at the responder, never forwarded + // And the byte-13 traffic the stub saw is only the one-byte probe, never + // the crafted body. + for (const seen of f.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); + } } finally { await f.cleanup(); } }); -/* ------------------------------------------------------------------ */ -/* method allow-list: default-deny on everything else */ -/* ------------------------------------------------------------------ */ - -test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOCK 23, EXTENSION 27, SSH1 1) are refused and never forwarded", async () => { +test("under the 10.x scheme every codepoint the scheme does not name is refused and never forwarded (default-deny)", async () => { const f = await openFixture(); try { const nB = newNonce(); - const disallowed = [ - SSH2_AGENTC_ADD_IDENTITY, - SSH2_AGENTC_REMOVE_IDENTITY, - SSH2_AGENTC_REMOVE_ALL_IDENTITIES, - SSH2_AGENTC_LOCK, - SSH2_AGENTC_UNLOCK, - SSH2_AGENTC_EXTENSION, - SSH1_AGENTC_REQUEST_RSA_IDENTITIES, - ]; + // 15/16 are the CLASSIC sign request/response bytes; 14 is 10.x's own + // response byte aimed at us as a request; the rest are agent-proto.h + // mutations/locks/SSH1 codes. NONE is 10.x identities (11) or sign (13). + const disallowed = [15, 16, 14, 17, 18, 19, 22, 23, 27, 1, 200]; let nA: string | undefined; for (const [i, type] of disallowed.entries()) { const req = await sealRequest(f, { @@ -445,50 +607,209 @@ test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOC f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === i + 1, `a refusal for disallowed type ${type}`); const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); expect(f.frames[i].seq).toBe(i); nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) } await sleep(30); - expect(f.stub.received.length).toBe(0); // nothing reached A's agent + expect(f.forwarded()).toEqual([]); // nothing reached A's agent } finally { await f.cleanup(); } }); -test("a malformed SIGN_REQUEST (truncated wire) is refused and not forwarded", async () => { +test("under the 10.x scheme malformed sign bodies are refused and never forwarded", async () => { const f = await openFixture(); try { const nB = newNonce(); - const truncated = Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(KEY_IN).subarray(0, 2)]); - const req = await sealRequest(f, { agentBytes: truncated, seq: 0, nB }); + let nA: string | undefined; + const malformed: Buffer[] = [ + Buffer.from([13, 0, 0, 0, 2]), // truncated blob length + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN)]), // blob-only: no data, no flags + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), be32(0)]), // blob+flags: not the wire grammar + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA")]), // missing flags + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), // empty blob + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), // junk tail + Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_IN), + sshStr("DATA"), + be32(0), + Buffer.from([0, 0, 0, 99]), // algorithms string longer than the buffer + ]), + ]; + for (const [i, agentBytes] of malformed.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for malformed body ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) must be exactly one byte (extra bytes are refused, not forwarded)", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11, 0, 1, 2, 3]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); - await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + await waitUntil(() => f.frames.length === 1, "the refusal"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); await sleep(30); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a grant naming no fingerprint serves the count-zero answer (12), never the roster", async () => { + const f = await openFixture({ fingerprints: [] }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + // The full two-key roster is on the stub; the answer is five bytes: 12 + count 0. + expect([...reply.agentBytes]).toEqual([12, 0, 0, 0, 0]); } finally { await f.cleanup(); } }); /* ------------------------------------------------------------------ */ -/* the empty grant: serve nothing, never the full list */ +/* the classic scheme: identities 13/answer 14, sign 15/response 16 */ /* ------------------------------------------------------------------ */ -test("a grant naming no fingerprint yields an empty (count 0) IDENTITIES_ANSWER, never the roster", async () => { - const f = await openFixture({ fingerprints: [] }); +test("under the classic scheme a REQUEST_IDENTITIES (13) gets an IDENTITIES_ANSWER (14) filtered to the grant", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); try { const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([13]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); - await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); const reply = await decodeA2B(f, f.frames[0], { nB }); const answer = parseAnswer(reply.agentBytes); - expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); - expect(answer.count).toBe(0); - expect(reply.agentBytes.length).toBe(5); // type + zero count, nothing else + expect(answer.type).toBe(14); // the classic answer byte + expect(answer.count).toBe(1); + expect(answer.comments).toEqual(["granted key"]); + expect(f.forwarded()[0]).toEqual(Buffer.from([13])); // byte 13 IS identities here + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme a SIGN_REQUEST (15) is scoped by fingerprint and a granted one rides byte-identical", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + const outOfSet = buildSignRequestClassic(KEY_OUT); + const bad = await sealRequest(f, { agentBytes: outOfSet, seq: 0, nB }); + f.relay.onInboundRelayFrame(bad); + await waitUntil(() => f.frames.length === 1, "the out-of-set refusal"); + const refusal = await decodeA2B(f, f.frames[0], { nB }); + expect([...refusal.agentBytes]).toEqual([5]); + expect(f.forwarded()).toEqual([]); // out-of-set never reached the agent + + const inSet = buildSignRequestClassic(KEY_IN); + const good = await sealRequest(f, { agentBytes: inSet, seq: 1, nB, nA: refusal.nA }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 2, "the signature reply"); + const reply = await decodeA2B(f, f.frames[1], { nB, nA: refusal.nA }); + expect([...reply.agentBytes]).toEqual([16, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 16 + expect(f.forwarded()[0]).toEqual(inSet); + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme the 10.x bytes and non-grammatical bodies are refused and never forwarded", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + let nA: string | undefined; + const cases = [ + Buffer.from([11]), // 10.x identities: a classic agent has no such request + Buffer.from([12]), // 10.x's answer byte aimed at us as a request + Buffer.from([13, 9]), // classic identities must be exactly one byte + buildSignRequestClassic(KEY_IN).subarray(0, 5), // truncated before data + buildSignRequestTenX(KEY_IN), // byte 13 with a full body: 13 must be one byte here + Buffer.concat([buildSignRequestClassic(KEY_IN), sshStr("ssh-ed25519")]), // trailing algorithms: the extended grammar belongs to 10.x + ]; + for (const [i, agentBytes] of cases.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for case ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* agent answers that misbehave (the response gates) */ +/* ------------------------------------------------------------------ */ + +test("an agent answering identities with a FOREIGN type byte becomes a refusal, never a forward-through", async () => { + // The 10.x session asks [11]; the stub answers a well-formed roster spelled + // with the classic 14 (the collision byte). The resolved scheme's answer + // gate must refuse it: 14 is a SIGN_RESPONSE here, not a roster. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => Buffer.from([14, ...answer.subarray(1)]) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("an unparseable roster never rides on: partial parsing is exactly what §5.4 exists to prevent", async () => { + // A valid 12 header whose declared entries the buffer does not carry. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => answer.subarray(0, answer.length - 4) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("an agent answering a granted sign with a FOREIGN response byte becomes a refusal (the signature never rides)", async () => { + // The 10.x session forwards byte 13; the stub answers type 16 (the classic + // SIGN_RESPONSE) with a signature: under the resolved 10.x scheme that is + // not a legal answer, and the bytes must never reach B. + const f = await openFixture( + {}, + { mutateAnswer: (kind, answer) => (kind === "sign" ? Buffer.from([16, ...answer.subarray(1)]) : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + expect(f.forwarded().length).toBe(1); // the scoped request DID ride; its answer did not } finally { await f.cleanup(); } @@ -503,7 +824,7 @@ test("a request signed by a key that is not B's pin is refused: no reply frame, const evil = await evilKeysReady; try { const req = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB: newNonce(), signerPrivateJwk: evil.privateJwk, @@ -511,7 +832,7 @@ test("a request signed by a key that is not B's pin is refused: no reply frame, f.relay.onInboundRelayFrame(req); await sleep(50); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -523,7 +844,7 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref const nB = newNonce(); // Cross-session replay: right keys, another session's id. const wrongSession = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, relaySessionId: "relay-EVIL", @@ -533,14 +854,14 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref expect(f.frames.length).toBe(0); // Land the genuine first request so nB is recorded... - const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(good); await waitUntil(() => f.frames.length === 1, "the genuine reply"); const first = await decodeA2B(f, f.frames[0], { nB }); // ...then a second-seq request carrying a nonce this session never saw. const foreignNonce = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 1, nB: newNonce(), nA: first.nA, @@ -548,7 +869,7 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref f.relay.onInboundRelayFrame(foreignNonce); await sleep(50); expect(f.frames.length).toBe(1); // no reply to the foreign nonce - expect(f.stub.received.length).toBe(1); // and nothing further reached the agent + expect(f.forwarded().length).toBe(1); // and nothing further reached the agent } finally { await f.cleanup(); } @@ -558,7 +879,7 @@ test("a replayed or non-increasing-seq request is refused: no reply frame, nothi const f = await openFixture(); try { const nB = newNonce(); - const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(good); await waitUntil(() => f.frames.length === 1, "the first genuine reply"); const first = await decodeA2B(f, f.frames[0], { nB }); @@ -566,24 +887,14 @@ test("a replayed or non-increasing-seq request is refused: no reply frame, nothi // Byte-identical resend of the first frame (the plane can resend anything). f.relay.onInboundRelayFrame(good); // A freshly signed request that rewinds the seq (the signature binds seq). - const rewind = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), - seq: 0, - nB, - nA: first.nA, - }); + const rewind = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB, nA: first.nA }); f.relay.onInboundRelayFrame(rewind); await sleep(50); expect(f.frames.length).toBe(1); - expect(f.stub.received.length).toBe(1); + expect(f.forwarded().length).toBe(1); // The session still works after refusals: seq 1 advances. - const next = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), - seq: 1, - nB, - nA: first.nA, - }); + const next = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 1, nB, nA: first.nA }); f.relay.onInboundRelayFrame(next); await waitUntil(() => f.frames.length === 2, "the next-seq reply still lands"); } finally { @@ -597,7 +908,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond const nB = newNonce(); // Another ref: the registry itself drops it (the unbrokered-session case). const foreignRef = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, ref: "r-EVIL", @@ -605,7 +916,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond f.relay.onInboundRelayFrame(foreignRef); // The wrong direction for A's side (B's own echo posture). const echo = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, direction: "A2B", @@ -613,7 +924,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond f.relay.onInboundRelayFrame(echo); await sleep(50); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -654,6 +965,7 @@ test("openARelaySession refuses a command that does not name this machine in its peerEncryptPublicKey: encB64, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/not the A side/); // role A but aNodeId names someone else: not OUR brokered session. @@ -670,6 +982,7 @@ test("openARelaySession refuses a command that does not name this machine in its peerEncryptPublicKey: encB64, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/not the key home/); // The mirrored guard on the B branch: bNodeId must name the receiver too. @@ -720,6 +1033,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat peerEncryptPublicKey: Buffer.from(otherB.encryption.publicJwk, "utf8").toString("base64"), }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/pin for b-node has MOVED/); // Same pinned pairing, already-owned ref: refused, registry untouched. @@ -736,6 +1050,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat peerEncryptPublicKey: Buffer.from(f.bKeys.encryption.publicJwk, "utf8").toString("base64"), }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/routing ref already owned/); expect(f.relay.size).toBe(1); @@ -768,6 +1083,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio lifetimeMs: 30_000, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/relay open refused/); expect(relay.size).toBe(0); @@ -780,11 +1096,11 @@ test("relay.close tears the responder down: later frames produce nothing", async expect(f.relay.close("r-1", "grant-revoked")).toBe(true); expect(f.relay.has("r-1")).toBe(false); const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); // the registry: unknown ref now, dropped await sleep(30); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -794,22 +1110,17 @@ test("relay.close tears the responder down: later frames produce nothing", async /* the live agent socket seam */ /* ------------------------------------------------------------------ */ -test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); - expect(liveAgentSocketPath({})).toBeNull(); - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); -}); - -test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and touches nothing", async () => { +test("with no live agent socket the session opens with no scheme and answers SSH2_AGENT_FAILURE, touching nothing", async () => { const f = await openFixture({}, { resolveAgentSocket: () => null }); try { + // Nothing connected at open either: the probe only runs when a socket exists. + expect(f.stub.received.length).toBe(0); const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === 1, "the no-agent refusal reply"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); expect(f.stub.received.length).toBe(0); // the stub socket was never connected } finally { await f.cleanup(); @@ -817,7 +1128,7 @@ test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and tou }); /* ------------------------------------------------------------------ */ -/* the filtering/parser helpers, pinned directly */ +/* startRelayResponder's own gates */ /* ------------------------------------------------------------------ */ test("startRelayResponder rejects a pinned peer key that is not public P-256 material (the deep bytesOfJwk gate)", () => { @@ -833,32 +1144,10 @@ test("startRelayResponder rejects a pinned peer key that is not public P-256 mat ownEncryptionPrivateJwk: "{}", ownSigningPrivateJwk: "{}", fingerprints: [], + agentScheme: OPENSSH_10X_SCHEME, seal: mcpSeal, open: mcpOpen, sendRelayFrame: () => {}, }), ).toThrow(/relay responder: pinned peer key rejected/); }); - -test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { - const s = fingerprintAgentBlob(Buffer.from("hello")); - expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); - expect(s).toBe(fp(Buffer.from("hello"))); -}); - -test("filterIdentitiesAnswer keeps only granted entries (count fixed, bytes preserved) and refuses malformed answers", () => { - const allowed = new Set([fp(KEY_OUT)]); // grant the SECOND entry: order must not matter - const filtered = filterIdentitiesAnswer(buildIdentitiesAnswer(ROSTER), allowed); - const answer = parseAnswer(filtered); - expect(answer.count).toBe(1); - expect(answer.comments).toEqual(["ungranted key"]); - // The kept entry's wire bytes survive byte-identical: type + count + str(blob) + str(comment). - expect(filtered).toEqual( - Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), be32(1), sshStr(KEY_OUT), sshStr("ungranted key")]), - ); - expect(filterIdentitiesAnswer(buildIdentitiesAnswer([]), new Set()).length).toBe(5); - // Malformed: declared entries the buffer does not carry, and a wrong type byte. - const truncated = buildIdentitiesAnswer(ROSTER).subarray(0, 20); - expect(() => filterIdentitiesAnswer(truncated, new Set())).toThrow(); - expect(() => filterIdentitiesAnswer(Buffer.from([SSH2_AGENT_SIGN_RESPONSE, 0, 0, 0, 0]), new Set())).toThrow(); -}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index eb2841966..02273ba02 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -3,8 +3,10 @@ import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; +import { type AgentScheme, probeAgentScheme } from "../relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; import { startAgentProxy } from "../relay-proxy.js"; -import { liveAgentSocketPath, startRelayResponder } from "../relay-responder.js"; +import { startRelayResponder } from "../relay-responder.js"; /** * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B @@ -266,9 +268,10 @@ export interface ARelaySessionArgs { */ sendRelayFrame(frame: RelayFrame): void; /** - * Resolve A's live agent socket per request; defaults to - * `liveAgentSocketPath` (the connecting account's absolute `SSH_AUTH_SOCK`, - * the ssh-resolve rule). The test seam for a stub agent. + * Resolve A's live agent socket; defaults to `liveAgentSocketPath` (the + * connecting account's absolute `SSH_AUTH_SOCK`, the ssh-resolve rule). + * Used ONCE by the open-time numbering probe and again per forwarded + * request. The test seam for a stub agent. */ resolveAgentSocket?: () => string | null; /** Line sink (defaults to the agent logger); never sees keys, fingerprints, or agent bytes. */ @@ -278,14 +281,19 @@ export interface ARelaySessionArgs { /** * Open the A side of a brokered relay pairing: enforce §4.4's machine pin on * B (first pairing writes both halves, a moved pin is a hard block naming the - * peer and §4.5's re-pair), then start {@link startRelayResponder} for the - * command's grant (its selected fingerprint set is carried by the command - * itself, §5.1 - the responder enforces it without a REST call it cannot - * make) and register the ref. Throws a named refusal (the dispatch wrapper - * turns it into `ok:false`) on: role other than A, a pairing that names a - * different machine as A, an unusable peer key, a MOVED pin, or a ref already - * owned. Resolves with the session id - nothing about the machine is in the - * answer, and no path exists to leak. + * peer and §4.5's re-pair), PROBE A's live agent to resolve its numbering + * (the ruling of 2026-10-08: classic 13/15 and OpenSSH-10.x 11/13 collide at + * byte 13, so {@link probeAgentScheme} must name the scheme once, here, before + * any byte is ever classified; an unresolved probe opens a session that + * refuses everything, never one that guesses), then start + * {@link startRelayResponder} for the command's grant (its selected + * fingerprint set is carried by the command itself, §5.1 - the responder + * enforces it without a REST call it cannot make) and register the ref. + * Throws a named refusal (the dispatch wrapper turns it into `ok:false`) on: + * role other than A, a pairing that names a different machine as A, an + * unusable peer key, a MOVED pin, or a ref already owned. A probe failure is + * NOT a throw: the session opens refuse-closed. Resolves with the session id + * - nothing about the machine is in the answer, and no path exists to leak. * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. §5.4's @@ -328,8 +336,27 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela ); } + // The numbering probe (ruling 2026-10-08), at session open and ONCE: the + // responder classifies every later byte only in what this resolves. No + // socket, or an agent that answers neither candidate, is not an error to + // throw around - it is the honest unresolved case, and an unresolved + // session refuses every request rather than forward one under a guess. + const say = args.log ?? ((line: string): void => log(line)); + const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + const agentSocket = resolveAgent(); + let agentScheme: AgentScheme | null = null; + if (agentSocket !== null) { + agentScheme = await probeAgentScheme(agentSocket, requestLiveAgent); + } + if (agentScheme === null) { + say(`relay session ${cmd.ref}: agent numbering unresolved at open; every request will be refused`); + } else { + say(`relay session ${cmd.ref}: agent numbering resolved: ${agentScheme.name}`); + } + const identity = await loadOrCreateIdentity(args.dataDir); const responder = startRelayResponder({ + agentScheme, relayId: cmd.relayId, ref: cmd.ref, selfNodeId: args.selfNodeId, @@ -343,7 +370,7 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela seal: mcpSeal, open: mcpOpen, sendRelayFrame: args.sendRelayFrame, - resolveAgentSocket: args.resolveAgentSocket ?? liveAgentSocketPath, + resolveAgentSocket: resolveAgent, ...(args.log === undefined ? {} : { log: args.log }), }); diff --git a/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts new file mode 100644 index 000000000..c98a5e72a --- /dev/null +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; + +/** + * The ssh-agent numbering schemes the A-side relay responder probes between + * (spec 2026-10-08 §5.4, ruling 2026-10-08: the numbering is PROBED, not + * baked). OpenSSH's classic numbering (identities 13 / answer 14 / sign 15 / + * sign response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities + * 11 / answer 12 / sign 13 / sign response 14) COLLIDE: byte 13 is the + * identities request under classic and the sign request under 10.x, and byte + * 14 is the answer under classic and the sign response under 10.x. Measured on + * this fleet's OpenSSH_10.2p1: request 11 answers 12; requests 13 and 15 both + * answer FAILURE(5). Forwarding a codepoint under the wrong interpretation is + * how an unscoped SIGN_REQUEST reaches the agent, so the responder must know + * which scheme A's live agent speaks before it classifies ANY byte. + * + * {@link probeAgentScheme} resolves that once per relay-session open: it sends + * each candidate's well-formed identities request in turn and demands a + * VALID IDENTITIES_ANSWER in that candidate's own answer byte to name the + * scheme. A byte-13 probe against a 10.x agent is a truncated sign request - + * it can only ever answer FAILURE (a probe therefore never risks a signature) + * - and the second candidate (byte 11) then names 10.x positively. No + * resolution ever rests on a guess: an agent that positively answers neither + * candidate resolves to nothing, and the session refuses everything. + * + * The parsing and scoping helpers here are the wire grammar itself: the + * per-scheme IDENTITIES_ANSWER filter, and SIGN_REQUEST bodies in both the + * classic (blob, data, flags) and the extended 10.x (trailing `string + * algorithms`) form. Task 11 (`ssh_agent_identities`) reuses + * {@link fingerprintAgentBlob} and {@link parseIdentitiesAnswer}: the + * fingerprint spelling defined here IS the grant-matching grammar (§5.4). + */ + +/** One agent request/response numbering: the four codepoints plus the body grammar. */ +export interface AgentScheme { + /** Stable short name for log lines and tests; never user data. */ + name: "openssh-10x" | "classic"; + /** SSH2_AGENTC_REQUEST_IDENTITIES: the roster request byte (one byte, no body). */ + identities: number; + /** SSH2_AGENT_IDENTITIES_ANSWER: the roster response byte this session's answers carry. */ + answer: number; + /** SSH2_AGENTC_SIGN_REQUEST: the codepoint whose body MUST be fingerprint-scoped before any forward. */ + sign: number; + /** SSH2_AGENT_SIGN_RESPONSE: the signature reply byte, otherwise opaque. */ + signResponse: number; + /** Whether SIGN_REQUEST bodies may carry the trailing `string algorithms` (RFC 9987 extended grammar). */ + extendedSign: boolean; +} + +/** + * The RFC 9987 numbering OpenSSH 10.x ships (measured OpenSSH_10.2p1: an 11 + * answers 12; a 13 answers 5 because it is a truncated sign; a 15 is unknown + * and answers 5). Sign bodies carry the extended grammar. + */ +export const OPENSSH_10X_SCHEME: AgentScheme = { + name: "openssh-10x", + identities: 11, + answer: 12, + sign: 13, + signResponse: 14, + extendedSign: true, +}; + +/** + * OpenSSH's classic numbering from `agent-proto.h` (identities 13 / answer 14 + * / sign 15 / sign response 16), the scheme pre-10.x agents and every other + * ssh-agent implementation in the field speak. Sign bodies are blob+data+ + * flags only. + */ +export const CLASSIC_SCHEME: AgentScheme = { + name: "classic", + identities: 13, + answer: 14, + sign: 15, + signResponse: 16, + extendedSign: false, +}; + +/** `SSH2_AGENT_FAILURE`: the uniform refusal, the same 5 in BOTH schemes (measured). */ +export const SSH2_AGENT_FAILURE = 5; + +/** The probe order: the classic candidate first, so a classic agent resolves in ONE round trip. */ +const PROBE_CANDIDATES: readonly AgentScheme[] = [CLASSIC_SCHEME, OPENSSH_10X_SCHEME]; + +/** One framed round trip to a live agent socket (the `requestLiveAgent` shape). */ +export type AgentRequestFn = (socketPath: string, payload: Buffer) => Promise; + +/* ------------------------------------------------------------------ */ +/* the wire codec (shared shape with the B proxy; A-side parsing) */ +/* ------------------------------------------------------------------ */ + +/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ +class ByteReader { + #buf: Buffer; + #off = 0; + + constructor(buf: Buffer) { + this.#buf = buf; + } + + u32(): number { + if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); + const v = this.#buf.readUInt32BE(this.#off); + this.#off += 4; + return v; + } + + bytes(n: number): Buffer { + if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); + const v = this.#buf.subarray(this.#off, this.#off + n); + this.#off += n; + return v; + } + + /** An SSH string: uint32 length + bytes. */ + string(): Buffer { + return this.bytes(this.u32()); + } + + done(): boolean { + return this.#off === this.#buf.length; + } +} + +/** Serialize an SSH string: uint32 length + bytes. */ +function sshString(bytes: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(bytes.length, 0); + return Buffer.concat([header, bytes]); +} + +/* ------------------------------------------------------------------ */ +/* the fingerprint grammar (§5.4: the ONLY spelling grants match on) */ +/* ------------------------------------------------------------------ */ + +/** + * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the + * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + + * base64url without padding - the same scheme `fingerprintJwk` established + * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also + * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. + */ +export function fingerprintAgentBlob(blob: Uint8Array): string { + return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; +} + +/* ------------------------------------------------------------------ */ +/* IDENTITIES_ANSWER: parse, then scope */ +/* ------------------------------------------------------------------ */ + +/** One roster entry as the agent answer carries it. */ +export interface AgentIdentity { + /** The public key blob, byte-exact from the wire (its fingerprint scopes it). */ + blob: Buffer; + /** The operator's label; rides through, never parsed. */ + comment: Buffer; +} + +/** + * Parse an IDENTITIES_ANSWER in the RESOLVED scheme: the type byte must be + * THAT scheme's answer codepoint (byte 14 answers an agent that speaks + * classic, and is a foreign SIGN_RESPONSE to one that speaks 10.x), and the + * declared entries must be all there and nothing more. Throws on anything + * else - an unparseable roster is refused by the caller, NEVER forwarded, + * because a partial parse of the roster is exactly what §5.4 exists to + * prevent. + */ +export function parseIdentitiesAnswer(answer: Buffer, scheme: AgentScheme): AgentIdentity[] { + if (answer.length < 5 || answer[0] !== scheme.answer) { + throw new Error(`relay agent wire: expected a ${scheme.name} IDENTITIES_ANSWER (${scheme.answer})`); + } + const reader = new ByteReader(answer.subarray(1)); + const count = reader.u32(); + const entries: AgentIdentity[] = []; + for (let i = 0; i < count; i += 1) { + const blob = reader.string(); + const comment = reader.string(); + entries.push({ blob, comment }); + } + if (!reader.done()) throw new Error("relay agent wire: IDENTITIES_ANSWER carries trailing bytes"); + return entries; +} + +/** + * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob + * fingerprints into `allowed`; fixes the count; kept entries ride byte- + * identically and are re-headed with the RESOLVED scheme's answer byte. + * Throws on a malformed answer (see {@link parseIdentitiesAnswer}). + */ +export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet, scheme: AgentScheme): Buffer { + const kept = parseIdentitiesAnswer(answer, scheme) + .filter((entry) => allowed.has(fingerprintAgentBlob(entry.blob))) + .map((entry) => Buffer.concat([sshString(entry.blob), sshString(entry.comment)])); + const count = Buffer.alloc(4); + count.writeUInt32BE(kept.length, 0); + return Buffer.concat([Buffer.from([scheme.answer]), count, ...kept]); +} + +/* ------------------------------------------------------------------ */ +/* SIGN_REQUEST: the body the fingerprint scope reads */ +/* ------------------------------------------------------------------ */ + +/** The parsed fields of a well-formed SIGN_REQUEST body (only `keyBlob` scopes; the rest ride through). */ +export interface SignRequestBody { + /** The target key blob: its {@link fingerprintAgentBlob} decides the forward (it MUST be checked before any). */ + keyBlob: Buffer; + /** The bytes to be signed; unexamined, forwarded inside the untouched request bytes. */ + data: Buffer; + /** Request flags; unexamined (§5.4 scopes by BLOB, not by flag). */ + flags: number; + /** The RFC 9987 extended grammar's trailing `string algorithms`; present only under the extended scheme. */ + algorithms?: Buffer; +} + +/** + * Split a SIGN_REQUEST (full payload, type byte included) into its fields. + * The wire grammar is blob, data, flags - in BOTH schemes - and the extended + * 10.x scheme additionally allows one well-formed trailing `string + * algorithms`. Anything else (missing fields, truncated strings, an empty + * blob, a classic body with trailing bytes, junk after the algorithms string) + * throws: a malformed body is refused, never forwarded. + */ +export function parseSignRequest(payload: Buffer, scheme: AgentScheme): SignRequestBody { + const reader = new ByteReader(payload.subarray(1)); + const keyBlob = reader.string(); + const data = reader.string(); + const flags = reader.u32(); + if (keyBlob.length === 0) throw new Error("relay agent wire: SIGN_REQUEST names an empty key blob"); + if (reader.done()) return { keyBlob, data, flags }; + if (!scheme.extendedSign) throw new Error("relay agent wire: classic SIGN_REQUEST carries trailing bytes"); + const algorithms = reader.string(); + if (!reader.done()) throw new Error("relay agent wire: SIGN_REQUEST carries bytes after the algorithms string"); + return { keyBlob, data, flags, algorithms }; +} + +/* ------------------------------------------------------------------ */ +/* the probe (ruling 2026-10-08: the numbering is learned, never assumed) */ +/* ------------------------------------------------------------------ */ + +/** Whether `answer` is a VALID IDENTITIES_ANSWER in `scheme` (positive confirmation, nothing weaker). */ +function isIdentitiesAnswer(answer: Buffer, scheme: AgentScheme): boolean { + if (answer.length === 0 || answer[0] !== scheme.answer) return false; + try { + parseIdentitiesAnswer(answer, scheme); + return true; + } catch { + return false; + } +} + +/** + * Learn which numbering the live agent speaks: send each candidate's + * one-byte identities request in turn (classic's 13 first, so a classic agent + * resolves in a single round trip) and demand a VALID IDENTITIES_ANSWER in + * that candidate's own answer byte to name it. A 10.x agent reads the byte-13 + * candidate as a truncated sign request and answers FAILURE(5) - the probe + * sends only one-byte requests, so it can never ask for a signature - then + * the byte-11 candidate answers a valid 12 and names the scheme positively. + * An agent that answers neither resolves to NULL: the caller must refuse + * every request, because a byte under a GUESSED scheme is the bug this + * ruling exists to kill. + */ +export async function probeAgentScheme(socketPath: string, requestAgent: AgentRequestFn): Promise { + for (const candidate of PROBE_CANDIDATES) { + let answer: Buffer; + try { + answer = await requestAgent(socketPath, Buffer.from([candidate.identities])); + } catch { + continue; // this candidate got no answer: try the next before giving up + } + if (isIdentitiesAnswer(answer, candidate)) return candidate; + } + return null; +} diff --git a/apps/node/agent/src/relay-agent-socket.ts b/apps/node/agent/src/relay-agent-socket.ts new file mode 100644 index 000000000..24ebd4da8 --- /dev/null +++ b/apps/node/agent/src/relay-agent-socket.ts @@ -0,0 +1,99 @@ +import { createConnection, type Socket } from "node:net"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; + +/** + * A's live agent socket, spoken to one framed message at a time. This is the + * ONLY path from the relay to a key (§3 item 4 / §5.4, "agent-only"): the + * responder and the numbering probe connect to the running agent through + * `SSH_AUTH_SOCK` and nothing else - no private key file is ever opened here, + * and nothing about key material reaches a log line. + * + * The trust rule is the one {@link liveAgentSocketPath} shares with the launch + * side (`ssh-resolve.ts`): the env sock is trusted because it is the + * connecting account's own setup, and only an absolute path qualifies. Each + * request rides its own one-shot connection (ssh's agent protocol permits it; + * no session state lives on the socket), under the relay frame cap and a + * local timeout - ssh's own agent budget is unbounded, ours may not be. + */ + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Local I/O budget for one round trip to A's agent (a local Unix socket call). */ +const AGENT_REQUEST_TIMEOUT_MS = 10_000; + +/** + * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, + * else null. A null answer is the honest no-agent case: the relay refuses + * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). + */ +export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { + const sock = env.SSH_AUTH_SOCK; + return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; +} + +/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** + * One request/response round trip to A's agent over the Unix socket: + * connect, write one framed message, read one framed answer, close. Rejects + * on connect failure, timeout, an empty or over-cap answer, or a close before + * a complete answer. `timeoutMs` is exposed for tests; production always uses + * the module default. The answer's TYPE is not examined here - classifying + * it is the caller's job in the RESOLVED scheme (relay-agent-scheme.ts). + */ +export function requestLiveAgent(socketPath: string, payload: Buffer, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + let conn: Socket; + try { + conn = createConnection({ path: socketPath }); + } catch (err) { + reject(new Error(`relay agent socket: cannot reach the agent socket: ${String(err)}`)); + return; + } + let buffer = Buffer.alloc(0); + let settled = false; + const fail = (why: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + conn.destroy(); + } catch { + /* already gone */ + } + reject(new Error(`relay agent socket: ${why}`)); + }; + const timer = setTimeout(() => fail("agent request timed out"), timeoutMs ?? AGENT_REQUEST_TIMEOUT_MS); + conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); + conn.on("connect", () => { + conn.write(framing(payload)); + }); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { + fail(`agent answer length ${len} is empty or over the cap`); + return; + } + if (buffer.length < 4 + len) return; + const answer = Buffer.from(buffer.subarray(4, 4 + len)); + settled = true; + clearTimeout(timer); + try { + conn.end(); + } catch { + /* the destroy below handles it */ + } + conn.destroy(); + resolve(answer); + }); + conn.on("close", () => fail("the agent socket closed before a complete answer")); + }); +} diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts index b41f31a60..ca578b0fe 100644 --- a/apps/node/agent/src/relay-responder.ts +++ b/apps/node/agent/src/relay-responder.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; -import { createConnection, type Socket } from "node:net"; import { base64UrlNoPad, bytesOfJwk, @@ -9,11 +7,18 @@ import { type RelayOpenFn, type RelaySealFn, SeqGate, - SSH_RELAY_FRAME_MAX_BYTES, sealRelayEnvelope, verifyRelayEnvelope, } from "@internal/subshell-protocol"; import { log } from "./log.js"; +import { + type AgentScheme, + filterIdentitiesAnswer, + fingerprintAgentBlob, + parseSignRequest, + SSH2_AGENT_FAILURE, +} from "./relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "./relay-agent-socket.js"; /** * A-side responder of the sealed agent relay (spec 2026-10-08 §5.4/§5.6): the @@ -21,29 +26,38 @@ import { log } from "./log.js"; * sealed from B; this module opens it with A's own encryption key, verifies * origin against B's PINNED signing key with the full Task-5 caller checklist * (frame-bound open, structurally bound relaySessionId / nB / nA, per-session - * {@link SeqGate}), default-denies the agent method, enforces the grant's - * fingerprint set against A's LIVE agent socket, and seals+signs the (possibly - * filtered or refused) agent answer back as an A2B frame. + * {@link SeqGate}), default-denies the agent method **in the probed scheme**, + * enforces the grant's fingerprint set against A's LIVE agent socket, and + * seals+signs the (possibly filtered or refused) agent answer back as an A2B + * frame. The agent wire itself (numbering, parsing, scoping helpers) lives in + * `relay-agent-scheme.ts`; the socket round trip in `relay-agent-socket.ts`. * - * The two rules of §5.4, in the order they run: + * The three rules of §5.4, in the order they run: * - * 1. **Method allow-list, default-deny.** Exactly the two request types SSH - * publickey auth uses are forwarded, matched by numeric type byte on the - * wire; every other type (identity mutations, LOCK/UNLOCK, EXTENSION, the - * SSH1-era range, anything unknown) is refused WITHOUT touching A's agent. - * A granted window therefore cannot mutate or lock A's agent against its - * other panes, by default-deny rather than a name list that could miss a - * code. - * 2. **Identity scoping.** The IDENTITIES_ANSWER is rebuilt with only the - * entries whose `SHA256:` fingerprint is in the grant's selected set, and a - * SIGN_REQUEST whose key blob hashes outside the set is refused before it - * is ever forwarded. A grant naming no fingerprint serves nothing. + * 1. **The scheme is probed, never assumed (ruling 2026-10-08).** Classic + * (identities 13 / sign 15) and RFC 9987 / OpenSSH-10.x (identities 11 / sign + * 13) numbering COLLIDE at byte 13: forwarding it raw as identities against a + * 10.x agent forwards a SIGN_REQUEST unscoped, and the key home signs an + * attacker-chosen blob. The session is therefore opened with a resolved + * `AgentScheme` (the probe lives in `relay-agent-scheme.ts`, run by + * `openARelaySession`); a session whose probe resolved NOTHING classifies + * nothing and forwards nothing - every request is refused. + * 2. **Method allow-list, default-deny in the resolved scheme.** Exactly the + * two request types SSH publickey auth uses - the identities and sign + * codepoints THAT scheme names - are ever forwarded; every other byte + * (identity mutations, LOCK/UNLOCK, EXTENSION, the SSH1-era range, the OTHER + * scheme's codes, anything unknown) dies here without touching A's agent. + * 3. **Identity scoping BEFORE any forward.** The identities answer is + * rebuilt with only the entries whose `SHA256:` fingerprint is in the grant; + * a sign request is parsed, its key blob fingerprinted, and a blob outside + * the set is refused at the responder - the bytes NEVER reach A's agent, + * whichever codepoint the scheme treats as sign (§5.4's load-bearing rule). + * A granted window therefore signs and sees only the keys the operator picked. * * **Agent-only (§3 item 4):** the only path to a key is A's running agent - * socket (`SSH_AUTH_SOCK`, the connecting account's own, the same - * absolute-path rule `ssh-resolve.ts` established for the launch side). No - * private key file is opened here, and nothing about key material is ever - * logged. + * socket (`SSH_AUTH_SOCK`, the connecting account's own, the absolute-path + * rule `relay-agent-socket.ts` shares with `ssh-resolve.ts`). No private key + * file is opened anywhere, and nothing about key material is ever logged. * * Refusal semantics mirror Task 6's proxy: an unopenable envelope is * plane-crafted noise and costs a log line (§5.6 first line: the plane holds @@ -54,7 +68,7 @@ import { log } from "./log.js"; * never punished, because the plane routes and can resend anything. A refused * REQUEST still gets the session's normal answer only in the agent layer: * disallowed or out-of-scope agent requests are answered with - * `SSH2_AGENT_FAILURE` (B's ssh sees a clean "agent refused", never a hang), + * SSH2_AGENT_FAILURE (B's ssh sees a clean "agent refused", never a hang), * while refused FRAMES (origin/replay) never produce any A2B at all. * * Nonce protocol (§5.6, A's side of the mirror): A mints `nA` once at session @@ -70,230 +84,14 @@ import { log } from "./log.js"; * mcp-core's pair straight through. */ -/* ------------------------------------------------------------------ */ -/* the ssh-agent wire (OpenSSH `agent-proto.h` values) */ -/* ------------------------------------------------------------------ */ - -/** - * `SSH2_AGENTC_REQUEST_IDENTITIES` - ask for the public-key list. The value - * is from OpenSSH's `agent-proto.h` (NOT the RFC 9987 renumbering, where this - * is 11: the wire and the real agent are OpenSSH, and the real-agent e2e is - * the arbiter). One of the TWO forwarded request types. - */ -export const SSH2_AGENTC_REQUEST_IDENTITIES = 13; - -/** - * `SSH2_AGENTC_SIGN_REQUEST` - sign with a named blob. The value is from - * OpenSSH's `agent-proto.h` (RFC 9987 calls this 13 - not this wire). One of - * the TWO forwarded request types. - */ -export const SSH2_AGENTC_SIGN_REQUEST = 15; - -/** `SSH2_AGENT_IDENTITIES_ANSWER` (agent-proto.h): the roster reply this module filters. */ -export const SSH2_AGENT_IDENTITIES_ANSWER = 2; - -/** `SSH2_AGENT_SIGN_RESPONSE` (agent-proto.h): the signature reply, forwarded verbatim (it is opaque). */ -export const SSH2_AGENT_SIGN_RESPONSE = 14; - -/** `SSH2_AGENT_FAILURE` (agent-proto.h): the uniform answer to any refused request. */ -export const SSH2_AGENT_FAILURE = 5; - -/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ -const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; - -/** Local I/O budget for one round trip to A's agent (a local Unix socket call; ssh's own is unbounded, ours is not). */ -const AGENT_REQUEST_TIMEOUT_MS = 10_000; - -/** The single-byte refusal every refused request is answered with. */ +/** The single-byte refusal every refused request is answered with (5 in both schemes). */ const FAILURE = Buffer.from([SSH2_AGENT_FAILURE]); -/* ------------------------------------------------------------------ */ -/* agent-wire framing + parsing (4-byte BE length + payload, shared */ -/* shape with the B proxy; the TYPE parsing is A-only per §5.4) */ -/* ------------------------------------------------------------------ */ - -/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ -function framing(payload: Buffer): Buffer { - const header = Buffer.alloc(4); - header.writeUInt32BE(payload.length, 0); - return Buffer.concat([header, payload]); -} - -/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ -class ByteReader { - #buf: Buffer; - #off = 0; - - constructor(buf: Buffer) { - this.#buf = buf; - } - - u32(): number { - if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); - const v = this.#buf.readUInt32BE(this.#off); - this.#off += 4; - return v; - } - - bytes(n: number): Buffer { - if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); - const v = this.#buf.subarray(this.#off, this.#off + n); - this.#off += n; - return v; - } - - /** An SSH string: uint32 length + bytes. */ - string(): Buffer { - return this.bytes(this.u32()); - } - - done(): boolean { - return this.#off === this.#buf.length; - } -} - -/** Serialize an SSH string for the rebuilt answer: uint32 length + bytes. */ -function sshString(bytes: Buffer): Buffer { - const header = Buffer.alloc(4); - header.writeUInt32BE(bytes.length, 0); - return Buffer.concat([header, bytes]); -} - -/** - * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the - * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + - * base64url without padding - the same scheme `fingerprintJwk` established - * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also - * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. - */ -export function fingerprintAgentBlob(blob: Uint8Array): string { - return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; -} - -/** - * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob - * fingerprints into `allowed`; fixes the count; kept entries ride byte- - * identically. Throws on a malformed answer (type byte, declared entries the - * buffer does not carry, trailing bytes) - an unparseable answer is refused - * as a failure, NEVER forwarded unfiltered, because a partial parse of the - * roster is exactly what §5.4 exists to prevent. - */ -export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet): Buffer { - if (answer.length < 5 || answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { - throw new Error("relay responder: expected an SSH2_AGENT_IDENTITIES_ANSWER"); - } - const reader = new ByteReader(answer.subarray(1)); - const count = reader.u32(); - const kept: Buffer[] = []; // one item per KEPT ENTRY (both fields pre-concatenated, so the rebuilt count is the entry count) - for (let i = 0; i < count; i += 1) { - const blob = reader.string(); - const comment = reader.string(); - if (allowed.has(fingerprintAgentBlob(blob))) kept.push(Buffer.concat([sshString(blob), sshString(comment)])); - } - if (!reader.done()) throw new Error("relay responder: IDENTITIES_ANSWER carries trailing bytes"); - return Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), sshStringCount(kept.length), ...kept]); -} - -/** The count field of a rebuilt answer (an unframed uint32, unlike an SSH string). */ -function sshStringCount(n: number): Buffer { - const b = Buffer.alloc(4); - b.writeUInt32BE(n, 0); - return b; -} - -/** - * Split a SIGN_REQUEST into its key blob (the parse throws on anything short - * of the full grammar: string blob + uint32 flags + nothing else). The flags - * ride through to the agent unexamined: §5.4 scopes by BLOB, not by flag. - */ -export function parseSignRequest(payload: Buffer): { keyBlob: Buffer; flags: number } { - const reader = new ByteReader(payload.subarray(1)); - const keyBlob = reader.string(); - const flags = reader.u32(); - if (keyBlob.length === 0) throw new Error("relay responder: SIGN_REQUEST names an empty key blob"); - if (!reader.done()) throw new Error("relay responder: SIGN_REQUEST carries trailing bytes"); - return { keyBlob, flags }; -} - -/* ------------------------------------------------------------------ */ -/* A's live agent socket */ -/* ------------------------------------------------------------------ */ - -/** - * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, - * else null - the same trust rule `ssh-resolve.ts` runs for the launch side - * ("the env sock is trusted because it is the connecting account's own - * setup"). A null answer is the honest no-agent case: the responder refuses - * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). - */ -export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { - const sock = env.SSH_AUTH_SOCK; - return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; -} - -/** - * One request/response round trip to A's agent over the Unix socket: - * connect, write one framed message, read one framed answer, close. Each - * request rides its own connection (ssh's agent protocol permits it; no - * session state lives on the socket). Rejects on connect failure, timeout, - * an empty or over-cap answer, or a close before a complete answer. - */ -function requestLiveAgent(socketPath: string, payload: Buffer): Promise { - return new Promise((resolve, reject) => { - let conn: Socket; - try { - conn = createConnection({ path: socketPath }); - } catch (err) { - reject(new Error(`relay responder: cannot reach the agent socket: ${String(err)}`)); - return; - } - let buffer = Buffer.alloc(0); - let settled = false; - const fail = (why: string): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - try { - conn.destroy(); - } catch { - /* already gone */ - } - reject(new Error(`relay responder: ${why}`)); - }; - const timer = setTimeout(() => fail("agent request timed out"), AGENT_REQUEST_TIMEOUT_MS); - conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); - conn.on("connect", () => { - conn.write(framing(payload)); - }); - conn.on("data", (chunk: Buffer) => { - buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); - if (buffer.length < 4) return; - const len = buffer.readUInt32BE(0); - if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { - fail(`agent answer length ${len} is empty or over the cap`); - return; - } - if (buffer.length < 4 + len) return; - const answer = Buffer.from(buffer.subarray(4, 4 + len)); - settled = true; - clearTimeout(timer); - try { - conn.end(); - } catch { - /* the destroy below handles it */ - } - conn.destroy(); - resolve(answer); - }); - conn.on("close", () => fail("the agent socket closed before a complete answer")); - }); -} - /* ------------------------------------------------------------------ */ /* the responder */ /* ------------------------------------------------------------------ */ -/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the pump, the seams. */ +/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the probed scheme, the seams. */ export interface RelayResponderArgs { /** The relay-session id the plane minted (§5.6: signed into every envelope). */ relayId: string; @@ -314,6 +112,14 @@ export interface RelayResponderArgs { ownSigningPrivateJwk: string; /** The grant's selected fingerprints (§5.4); empty names nothing, serves nothing. */ fingerprints: readonly string[]; + /** + * The numbering probe resolved against A's live agent at session open + * ({@link probeAgentScheme} in relay-agent-scheme.ts, run by + * openARelaySession). Null means unresolved: this session classifies no + * byte and forwards nothing, because a byte under a GUESSED scheme is the + * CRITICAL the 2026-10-08 ruling exists to prevent. + */ + agentScheme: AgentScheme | null; /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ seal: RelaySealFn; /** The opening primitive - mcp-core's `open`. */ @@ -328,9 +134,11 @@ export interface RelayResponderArgs { */ sendRelayFrame(frame: RelayFrame): void; /** - * Resolve A's live agent socket per request (default {@link - * liveAgentSocketPath}). A test seam and the operator override land here; - * returning null is the honest no-agent case. + * Resolve A's live agent socket per request (default `liveAgentSocketPath` + * from relay-agent-socket.ts). A test seam and the operator override land + * here; returning null is the honest no-agent case. The scheme is cached + * once at open; a later agent restart under a DIFFERENT numbering fails the + * response-type gate, never a silent re-interpretation. */ resolveAgentSocket?: () => string | null; /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ @@ -354,8 +162,8 @@ export interface RelayResponderHandle { * Start the A-side responder for ONE brokered session: validate B's pins * (deep public-only P-256 via {@link bytesOfJwk}, the same gate the B proxy * runs), mint A's session nonce, and return the handler that - * `commands/ssh-relay.ts` registers into {@link RelaySessions}. Throws a - * named refusal when either pinned half is not usable public material. + * `commands/ssh-relay.ts` registers into `RelaySessions`. Throws a named + * refusal when either pinned half is not usable public material. */ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHandle { const say = args.log ?? ((line: string): void => log(line)); @@ -380,13 +188,14 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; const allowed = new Set(args.fingerprints); const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + const scheme = args.agentScheme; // cached at session open; NEVER re-derived per byte // Session state (§5.6): A's nonce is minted once and every A2B carries it; - // B's is recorded from the first verified request. `nASeenBinding` marks - // that the first verified request has passed, from which point every - // inbound message MUST carry A's nonce (B learns it from A's first reply, - // and B's single-outstanding FIFO means B's next request was sealed after - // that reply landed - a request without it is a replay or a forgery). + // B's is recorded from the first verified request. `bindNA` marks that the + // first verified request has passed, from which point every inbound message + // MUST carry A's nonce (B learns it from A's first reply, and B's + // single-outstanding FIFO means B's next request was sealed after that + // reply landed - a request without it is a replay or a forgery). const nA = newNonce(); let nB: string | undefined; let bindNA = false; @@ -397,28 +206,36 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan /** The agent-layer decision for one decoded request: the payload to answer B with. */ const respondToAgent = async (request: Buffer): Promise => { - if (request.length === 0) return FAILURE; // no type byte: nothing allow-listed, nothing forwarded - const type = request[0]; - const agentPath = resolveAgent(); - if (type !== SSH2_AGENTC_REQUEST_IDENTITIES && type !== SSH2_AGENTC_SIGN_REQUEST) { - // §5.4's default-deny: by the REFUSED set's behavior, not a lookup. Log the - // numeric type (not a secret) so the operator can see what B asked for. - say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + if (request.length === 0) { + say(`relay session ${args.ref}: refused an agent request with no type byte; not forwarded`); return FAILURE; } - if (type === SSH2_AGENTC_SIGN_REQUEST) { + if (scheme === null) { + // The numbering probe resolved nothing at open: there is no safe + // reading of ANY byte, and forwarding under a guess is exactly the + // C-2 bug this ruling killed. Refuse, always. + say(`relay session ${args.ref}: refused a request with no resolved agent scheme; not forwarded`); + return FAILURE; + } + const type = request[0]; + + if (type === scheme.sign) { let keyBlob: Buffer; try { - ({ keyBlob } = parseSignRequest(request)); + ({ keyBlob } = parseSignRequest(request, scheme)); } catch { - say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST; not forwarded`); + say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST body; not forwarded`); return FAILURE; } - // §5.4's signing gate: checked HERE, before anything touches the agent. + // §5.4's signing gate, BEFORE anything else touches the agent: a sign + // whose blob is outside the grant dies here whichever codepoint the + // probed scheme treats as sign (classic 15 or 10.x 13 - the very byte + // the first pass forwarded raw). if (!allowed.has(fingerprintAgentBlob(keyBlob))) { say(`relay session ${args.ref}: refused a SIGN_REQUEST outside the grant set; not forwarded`); return FAILURE; } + const agentPath = resolveAgent(); if (agentPath === null) { say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); return FAILURE; @@ -431,42 +248,55 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan return FAILURE; } // The signature is opaque and the blob was already scoped, but the - // response TYPE still gates: only SIGN_RESPONSE and the agent's own - // FAILURE ride onward; anything unexpected becomes a failure. - if (response.length < 1 || (response[0] !== SSH2_AGENT_SIGN_RESPONSE && response[0] !== SSH2_AGENT_FAILURE)) { + // response TYPE still gates in the resolved scheme: only that scheme's + // SIGN_RESPONSE and the agent's own FAILURE ride onward; anything + // else (a foreign scheme's bytes included) becomes a failure. + if (response.length < 1 || (response[0] !== scheme.signResponse && response[0] !== SSH2_AGENT_FAILURE)) { say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); return FAILURE; } return response; } - // SSH2_AGENTC_REQUEST_IDENTITIES: the roster goes to the agent, comes - // back, and is FILTERED here - the grant set is enforced at A, the only - // machine that can see A's whole roster. - if (agentPath === null) { - say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); - return FAILURE; - } - let answer: Buffer; - try { - answer = await requestLiveAgent(agentPath, request); - } catch (err) { - say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); - return FAILURE; - } - if (answer.length < 1) return FAILURE; - if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal - if (answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { - say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); - return FAILURE; - } - try { - return filterIdentitiesAnswer(answer, allowed); - } catch { - // An unparseable roster NEVER rides on: partial parsing is how an - // ungranted identity could leak. The pane sees a clean failure. - say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); - return FAILURE; + + if (type === scheme.identities) { + if (request.length !== 1) { + say(`relay session ${args.ref}: refused a malformed REQUEST_IDENTITIES; not forwarded`); + return FAILURE; + } + const agentPath = resolveAgent(); + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let answer: Buffer; + try { + answer = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + if (answer.length < 1) return FAILURE; + if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal + if (answer[0] !== scheme.answer) { + say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); + return FAILURE; + } + try { + return filterIdentitiesAnswer(answer, allowed, scheme); + } catch { + // An unparseable roster NEVER rides on: partial parsing is how an + // ungranted identity could leak. The pane sees a clean failure. + say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); + return FAILURE; + } } + + // §5.4's default-deny in the resolved scheme: the OTHER scheme's codes, + // the answer/response bytes aimed at us as requests, identity mutations, + // LOCK/UNLOCK, EXTENSION, the SSH1-era range, anything unknown. Log the + // numeric type (not a secret) so the operator can see what B asked for. + say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + return FAILURE; }; /** The full inbound pipeline for one frame: open, verify, gate, answer. */ From 27ee9c98448ba09e44c3b3b17af14eb758ee41fc Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:36:00 -0700 Subject: [PATCH 100/170] fix(ssh-relay): strictly parse SIGN_RESPONSE body; drop chimeric proxy fixture names --- .../src/__tests__/relay-agent-scheme.test.ts | 40 +++++++++ .../agent/src/__tests__/relay-proxy.test.ts | 64 +++++++-------- .../src/__tests__/relay-responder.test.ts | 82 +++++++++++++++++++ apps/node/agent/src/relay-agent-scheme.ts | 22 +++++ apps/node/agent/src/relay-responder.ts | 23 ++++-- 5 files changed, 190 insertions(+), 41 deletions(-) diff --git a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts index acefbb416..d8b97a988 100644 --- a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -8,6 +8,7 @@ import { OPENSSH_10X_SCHEME, parseIdentitiesAnswer, parseSignRequest, + parseSignResponse, probeAgentScheme, SSH2_AGENT_FAILURE, } from "../relay-agent-scheme.js"; @@ -194,6 +195,45 @@ test("parseSignRequest refuses every malformed body in both schemes", () => { ).toThrow(); }); +/* ---------------- SIGN_RESPONSE: the body the relay gate accepts ---------------- */ + +test("parseSignResponse accepts exactly the type byte plus one string in each scheme", () => { + // The collision this strictness exists for, pinned: classic answer 14 == + // 10.x signResponse 14. The type byte alone cannot tell a signature from a + // foreign scheme's roster; only the body parse can. + expect(CLASSIC_SCHEME.answer).toBe(14); + expect(OPENSSH_10X_SCHEME.signResponse).toBe(14); + expect(parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG"))]), OPENSSH_10X_SCHEME)).toEqual( + Buffer.from("SIG"), + ); + expect(parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr(Buffer.from("SIG"))]), CLASSIC_SCHEME)).toEqual( + Buffer.from("SIG"), + ); +}); + +test("parseSignResponse refuses a roster-shaped body, extra bytes, truncation, and a foreign type byte", () => { + // A multi-entry roster spelled under the SIGN_RESPONSE byte (the exact + // shape a mid-session classic swap answers a forwarded 13 with): refused. + const roster = Buffer.concat([Buffer.from([14]), be32(2), sshStr(KEY_A), sshStr("a"), sshStr(KEY_B), sshStr("b")]); + expect(() => parseSignResponse(roster, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => + parseSignResponse(Buffer.concat([Buffer.from([16]), be32(1), sshStr(KEY_A), sshStr("a")]), CLASSIC_SCHEME), + ).toThrow(); + // Bytes after the one signature string: not the wire shape. + expect(() => + parseSignResponse( + Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG")), Buffer.from([0])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // Empty body and a truncated length prefix. + expect(() => parseSignResponse(Buffer.from([14]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.from([14, 0, 0, 0]), OPENSSH_10X_SCHEME)).toThrow(); + // A foreign scheme's response byte is refused in the resolved scheme. + expect(() => parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr("S")]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr("S")]), CLASSIC_SCHEME)).toThrow(); +}); + /* ---------------- the probe: resolving the live agent's numbering ---------------- */ /** Records every payload a fake agent received and answers per a script. */ diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index b73df9b06..8262d93c7 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -27,17 +27,15 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. */ /** - * ssh-agent wire type bytes used as FIXTURE PAYLOADS only: the B proxy - * forwards agent bytes byte-agnostically and never parses a type byte, so the - * numbers here carry no protocol meaning (the classic 13/15 pair from - * `agent-proto.h` is what a pre-10.x agent speaks; OpenSSH 10.x ships the RFC - * 9987 renumbering 11/13 - see the ruling of 2026-10-08, which is why A's - * responder PROBEs the numbering and the numeric type gate is A's responder's - * default-deny, Task 7, never this side). + * Opaque FIXTURE PAYLOADS only: the B proxy forwards agent bytes + * byte-agnostically and never parses a type byte, so these numbers carry no + * protocol meaning at this side. These tests exercise transport framing (the + * FIFO, seq, nonces, socket lifecycle); what a real agent makes of a byte is + * A's responder's business, and its scheme semantics are pinned there. */ -const SSH2_AGENTC_REQUEST_IDENTITIES = 13; -const SSH2_AGENTC_SIGN_REQUEST = 15; -const SSH2_AGENT_IDENTITIES_ANSWER = 12; +const SAMPLE_AGENT_REQUEST_A = 13; +const SAMPLE_AGENT_REQUEST_B = 15; +const SAMPLE_AGENT_ANSWER_A = 12; interface JwkPair { publicJwk: string; @@ -237,7 +235,7 @@ test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq conn.on("error", rej); }); void w; - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "one outbound relay frame"); await sleep(30); expect(f.frames.length).toBe(1); // exactly one @@ -251,7 +249,7 @@ test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq const opened = await openAndVerifyB2A(f, frame); expect(opened.seq).toBe(0); // the SIGNED anti-replay seq agrees for the first request - expect(opened.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(opened.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A])); expect(opened.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // a 128-bit base64url endpoint nonce expect(opened.nA).toBeUndefined(); // B speaks first; nA has not been minted yet conn.destroy(); @@ -267,7 +265,7 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "first request frame"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -276,19 +274,19 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () seq: 0, nB: first.nB, nA, - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 0], + agentBytes: [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 0], }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); - conn.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); await waitUntil(() => f.frames.length === 2, "second request frame"); const second = await openAndVerifyB2A(f, f.frames[1]); expect(f.frames[1].seq).toBe(1); // transport seq advanced expect(second.seq).toBe(1); // signed anti-replay seq advanced expect(second.nB).toBe(first.nB); // B keeps its own nonce expect(second.nA).toBe(nA); // and signs A's onward (§5.6: recorded peer value) - expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); } finally { f.cleanup(); } @@ -304,11 +302,11 @@ test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); - const replyBytes = [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const replyBytes = [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 2, 0, 0, 0, 1, 65]; const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); @@ -329,7 +327,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -337,7 +335,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure }); f.proxy.deliverInboundRelayFrame(forged); @@ -354,7 +352,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -363,7 +361,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], relaySessionId: "relay-OTHER", }); f.proxy.deliverInboundRelayFrame(wrongSession); @@ -374,13 +372,13 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not const conn2 = connectSocket(f.proxy.socketPath); const spy2 = spy(conn2); await new Promise((res) => conn2.on("connect", () => res())); - conn2.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn2.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 2, "second request frame out"); const wrongNonce = await sealReply(f, { seq: 0, nB: newNonce(), nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], }); f.proxy.deliverInboundRelayFrame(wrongNonce); await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); @@ -396,7 +394,7 @@ test("a repeated seq is refused by the SeqGate and not written; a valid next-seq const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -418,7 +416,7 @@ test("a repeated seq is refused by the SeqGate and not written; a valid next-seq expect(spyC.closed()).toBe(false); // a replay refusal is not a connection failure // The stream continues: seq 1 over seq 0 is accepted. - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 2, "second request frame out"); const next = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [4, 5, 6] }); f.proxy.deliverInboundRelayFrame(next); @@ -435,7 +433,7 @@ test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); // Not even valid base64-of-a-JWE: the open fails before any trust question. @@ -464,7 +462,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec const x = connectSocket(f.proxy.socketPath); const spyX = spy(x); await new Promise((res) => x.on("connect", () => res())); - x.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + x.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "X's request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -472,7 +470,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec const y = connectSocket(f.proxy.socketPath); const spyY = spy(y); await new Promise((res) => y.on("connect", () => res())); - y.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + y.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); await sleep(30); expect(f.frames.length).toBe(1); // queued unsealed: the slot holds X's request @@ -498,7 +496,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec expect(f.frames[1].seq).toBe(1); expect(second.seq).toBe(1); expect(second.nA).toBe(nA); - expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); // A's reply to Y (seq 1) arrives: THAT is what Y receives, in order. const replyToY = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [1, 2, 3] }); @@ -529,7 +527,7 @@ test("a sendRelayFrame that throws fails the connection and drains the queue; no await new Promise((res) => a.on("connect", () => res())); // req1 takes the slot straight away; req2 queues behind the in-flight // seal (arrives in the same read while sealedInFlight holds). - a.write(Buffer.concat([agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES]), agentFrame([SSH2_AGENTC_SIGN_REQUEST, 9])])); + a.write(Buffer.concat([agentFrame([SAMPLE_AGENT_REQUEST_A]), agentFrame([SAMPLE_AGENT_REQUEST_B, 9])])); await waitUntil(() => spyA.closed(), "the connection fails on the refused send instead of hanging"); expect(attempts).toBe(1); // only req1 reached the pump; req2 died at the liveness gate expect(sent.length).toBe(0); // nothing ever reached A @@ -540,12 +538,12 @@ test("a sendRelayFrame that throws fails the connection and drains the queue; no const b = connectSocket(f.proxy.socketPath); const spyB = spy(b); await new Promise((res) => b.on("connect", () => res())); - b.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + b.write(agentFrame([SAMPLE_AGENT_REQUEST_A, 4, 5])); await waitUntil(() => sent.length === 1, "the next request goes out after the failed send"); expect(sent[0].seq).toBe(0); const first = await openAndVerifyB2A(f, sent[0]); expect(first.seq).toBe(0); - expect(first.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + expect(first.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A, 4, 5])); const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7, 7] }); f.proxy.deliverInboundRelayFrame(reply); diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index b712677be..8419edca9 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -794,6 +794,88 @@ test("an unparseable roster never rides on: partial parsing is exactly what §5. } }); +test("regression: a SIGN_RESPONSE of exactly the type byte plus one string rides on in BOTH schemes", async () => { + // The strict body parse accepts the honest shape in each scheme and forwards + // it byte-identical: 10.x's [14, string] and classic's [16, string]. + for (const [scheme, responseByte, signReq] of [ + [OPENSSH_10X_SCHEME, 14, buildSignRequestTenX(KEY_IN)], + [CLASSIC_SCHEME, 16, buildSignRequestClassic(KEY_IN)], + ] as [AgentScheme, number, Buffer][]) { + const f = await openFixture({}, { scheme }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: signReq, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, `the ${scheme.name} signature reply`); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([responseByte, 0, 0, 0, 3, ...Buffer.from("SIG")]); + } finally { + await f.cleanup(); + } + } +}); + +test("collision regression: a classic roster answering under byte 14 is refused by a 10.x session's sign gate", async () => { + // classic answer 14 == 10.x signResponse 14. A mid-session swap of A's agent + // to a classic build reads the forwarded byte 13 (10.x sign) as classic + // REQUEST_IDENTITIES and answers its FULL UNFILTERED roster under type 14 - + // exactly the byte a type-only gate would wave through to B as a sign + // response, leaking public blobs plus comments. The strictly parsed body + // must refuse the roster shape; B sees only the clean FAILURE. + const rosterUnder14 = Buffer.concat([ + Buffer.from([14]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: OPENSSH_10X_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder14 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); // the scoped sign request DID reach the agent + } finally { + await f.cleanup(); + } +}); + +test("regression: a roster-shaped body under classic's SIGN_RESPONSE byte 16 is refused too", async () => { + // Not a real-world collision (classic's roster answer is 14): the point is + // the gate parses the BODY, so a multi-string roster under the response + // byte is refused whichever agent spelled it. + const rosterUnder16 = Buffer.concat([ + Buffer.from([16]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: CLASSIC_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder16 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestClassic(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); + } finally { + await f.cleanup(); + } +}); + test("an agent answering a granted sign with a FOREIGN response byte becomes a refusal (the signature never rides)", async () => { // The 10.x session forwards byte 13; the stub answers type 16 (the classic // SIGN_RESPONSE) with a signature: under the resolved 10.x scheme that is diff --git a/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts index c98a5e72a..68f25706c 100644 --- a/apps/node/agent/src/relay-agent-scheme.ts +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -232,6 +232,28 @@ export function parseSignRequest(payload: Buffer, scheme: AgentScheme): SignRequ return { keyBlob, data, flags, algorithms }; } +/* ------------------------------------------------------------------ */ +/* SIGN_RESPONSE: the answer body the relay gate accepts */ +/* ------------------------------------------------------------------ */ + +/** + * Strictly parse a SIGN_RESPONSE (full payload, type byte included): the type + * byte must be THAT scheme's sign-response codepoint and the body must be + * exactly one `string` (the signature blob) with nothing after it. Throws + * otherwise. The type byte ALONE is not enough: the codepoints collide across + * schemes (classic's roster answer is 14, which is 10.x's sign response), so + * only the body tells a signature from a foreign scheme's roster. + */ +export function parseSignResponse(response: Buffer, scheme: AgentScheme): Buffer { + if (response.length < 5 || response[0] !== scheme.signResponse) { + throw new Error(`relay agent wire: expected a ${scheme.name} SIGN_RESPONSE (${scheme.signResponse})`); + } + const reader = new ByteReader(response.subarray(1)); + const signature = reader.string(); + if (!reader.done()) throw new Error("relay agent wire: SIGN_RESPONSE carries bytes after the signature string"); + return signature; +} + /* ------------------------------------------------------------------ */ /* the probe (ruling 2026-10-08: the numbering is learned, never assumed) */ /* ------------------------------------------------------------------ */ diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts index ca578b0fe..8cbb4d751 100644 --- a/apps/node/agent/src/relay-responder.ts +++ b/apps/node/agent/src/relay-responder.ts @@ -16,6 +16,7 @@ import { filterIdentitiesAnswer, fingerprintAgentBlob, parseSignRequest, + parseSignResponse, SSH2_AGENT_FAILURE, } from "./relay-agent-scheme.js"; import { liveAgentSocketPath, requestLiveAgent } from "./relay-agent-socket.js"; @@ -136,9 +137,9 @@ export interface RelayResponderArgs { /** * Resolve A's live agent socket per request (default `liveAgentSocketPath` * from relay-agent-socket.ts). A test seam and the operator override land - * here; returning null is the honest no-agent case. The scheme is cached - * once at open; a later agent restart under a DIFFERENT numbering fails the - * response-type gate, never a silent re-interpretation. + * here; returning null is the honest no-agent case. The cached scheme is + * not re-probed mid-session: the strictly-parsed response body, not merely + * the type byte, is what rejects a foreign-scheme answer. */ resolveAgentSocket?: () => string | null; /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ @@ -248,11 +249,17 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan return FAILURE; } // The signature is opaque and the blob was already scoped, but the - // response TYPE still gates in the resolved scheme: only that scheme's - // SIGN_RESPONSE and the agent's own FAILURE ride onward; anything - // else (a foreign scheme's bytes included) becomes a failure. - if (response.length < 1 || (response[0] !== scheme.signResponse && response[0] !== SSH2_AGENT_FAILURE)) { - say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); + // answer is still gated in the resolved scheme, by TYPE AND BODY: + // the type bytes collide across schemes (classic's roster answer is + // 14, which is 10.x's sign response), so only the strictly parsed + // body - exactly one signature string - tells a signature from a + // foreign scheme's roster. That scheme's well-formed SIGN_RESPONSE + // and the agent's own FAILURE ride onward; anything else fails. + if (response.length >= 1 && response[0] === SSH2_AGENT_FAILURE) return response; + try { + parseSignResponse(response, scheme); + } catch { + say(`relay session ${args.ref}: the agent's sign answer was not a well-formed SIGN_RESPONSE; refusing`); return FAILURE; } return response; From 8d2f18266b19415e78d68e25b1db9b8f552fdbb6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:36:14 -0700 Subject: [PATCH 101/170] docs(ssh-relay): record the shipped Task 7 seam shape in the plan --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 89d456702..daef5f16b 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -176,7 +176,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - Test: `apps/node/agent/src/__tests__/relay-responder.test.ts` **Interfaces:** -- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces: `handleRelayRequest(frame, session): Promise` that verifies origin, default-denies agent methods, filters identities, and forwards to A's real agent socket. +- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces (the shipped seam): `startRelayResponder(...)` + `openARelaySession(...)` + `deliverInboundRelayFrame(frame)` returning `{ onRelayFrame, close }` registered into `RelaySessions`. It probes A's agent scheme at open, verifies origin against the machine pin, `SeqGate`s, default-denies agent methods, and filters identity/sign to the grant's fingerprint set (scope-before-forward in the resolved scheme). Two refusal tiers: an agent-layer FAILURE reply (a refused method or an ungranted key) vs frame-level silence (an origin/seq/shape failure drops the frame with no reply). `relay-responder.ts` is split with `relay-agent-scheme.ts` (schemes/probe/grammar) and `relay-agent-socket.ts` (transport). - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** From af7adf101c4f0309187a7aa427540a0d7f77b981 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:45:50 -0700 Subject: [PATCH 102/170] feat(ssh-relay): paneId in the relay-open grammar, shared agent-socket path, canonical peer-key spelling Task 8 (b): the brokered open now names the pane its pairing serves, validated with the same ^[a-zA-Z0-9_-]{1,64}$ guard the node's socket bind runs, so a paneId that could only fail the path composition is refused before signing. The encryption-half spelling is fixed as base64 of the UTF-8 JSON public JWK (h), and buildAgentSocketPath moves to pane-runtime beside buildSshConfigPath so the node bind and the plane's byte-check derive one path. --- packages/pane-runtime/src/index.ts | 1 + .../src/ssh/__tests__/ssh-render.test.ts | 31 ++++++++- packages/pane-runtime/src/ssh/ssh-render.ts | 19 ++++++ .../src/__tests__/node-frames.test.ts | 53 ++++++++++++++- packages/subshell-protocol/src/index.ts | 2 + packages/subshell-protocol/src/ssh-frames.ts | 68 +++++++++++++++---- 6 files changed, 158 insertions(+), 16 deletions(-) diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 59034617f..bbbeaaf48 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -150,6 +150,7 @@ export { walkSshConfig, } from "./ssh/ssh-discover.js"; export { + buildAgentSocketPath, buildSshConfigPath, renderSshConfigContents, sshDestinationToken, diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index be8634104..01adceec4 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { buildSshConfigPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens } from "../ssh-render.js"; +import { dirname, join } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "../ssh-render.js"; import { makeSnapshot } from "./helpers.js"; /** @@ -235,6 +241,27 @@ describe("buildSshConfigPath", () => { }); }); +describe("buildAgentSocketPath", () => { + // Spec 2026-10-08 §5.2: the relay proxy socket lives in the SAME per-pane + // ssh dir the rendered config owns, so the pane's dir holds `config` and + // `agent.sock` side by side. It lives here because BOTH ends derive it - + // the node binds this exact path and the plane byte-checks the path the + // B open answers with - and one derivation is the only thing keeping the + // byte-check meaningful. + it("composes the socket beside the config, one dir", () => { + expect(buildAgentSocketPath("/data", "s1")).toBe("/data/ssh/s1/agent.sock"); + expect(dirname(buildAgentSocketPath("/data", "s1"))).toBe(dirname(buildSshConfigPath("/data", "s1"))); + }); + + it("runs the same guards as the config path", () => { + expect(() => buildAgentSocketPath("data", "s1")).toThrow(/absolute/); + expect(() => buildAgentSocketPath("/d", "")).toThrow(/paneId/); + expect(() => buildAgentSocketPath("/d", "../escape")).toThrow(/paneId/); + expect(() => buildAgentSocketPath("/d", "a".repeat(65))).toThrow(/paneId/); + expect(buildAgentSocketPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/agent.sock`); + }); +}); + describe("Metro purity", () => { it("ssh-render.ts imports no node: builtin (the pane-runtime barrel rule)", () => { const source = readFileSync(join(import.meta.dir, "..", "ssh-render.ts"), "utf8"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index f0087d197..99bf2fa9f 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -215,3 +215,22 @@ export function buildSshConfigPath(dataDir: string, subshellId: string): string if (!/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) throw new Error("ssh config path: invalid subshellId"); return `${dataDir}/ssh/${subshellId}/config`; } + +/** + * The relay agent-proxy socket path for a pane (spec 2026-10-08 §5.2): the + * SAME per-pane ssh dir the rendered config owns, with `agent.sock` beside + * `config`. It lives beside {@link buildSshConfigPath} for the same reason + * the config path does: BOTH ends derive it from (dataDir, paneId) and the + * byte-equality is the check - the node's B executor binds exactly this path, + * answers the open with it, and the plane's broker re-derives it (its own + * dataDir record for the node + the command's paneId) and refuses a single- + * byte mismatch before exporting anything as the pane's scoped + * `SSH_AUTH_SOCK`. A machine answer naming any other path therefore points + * the pane's ssh at nothing the plane agreed to. Same guards, same refusals + * (throws) as the config path. + */ +export function buildAgentSocketPath(dataDir: string, paneId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh agent socket path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh agent socket path: invalid paneId"); + return `${dataDir}/ssh/${paneId}/agent.sock`; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 1081f734f..7a1feb1bc 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,7 +24,13 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { + isSshGrantFingerprints, + isSshPaneId, + SSH_COMMAND_TYPES, + SSH_RELAY_CLOSE_REASONS, + type SshRelayOpenCommand, +} from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -62,6 +68,7 @@ const relayOpenCmd: SshRelayOpenCommand = { grantId: "grant-77", fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], lifetimeMs: 30_000, + paneId: "11111111-2222-4333-8444-555555555555", }; describe("parseNodeCommandBody", () => { @@ -1028,6 +1035,7 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => "grantId", "fingerprints", "lifetimeMs", + "paneId", ] as const) { const partial = structuredClone(relayOpenCmd) as unknown as Record; delete partial[drop]; @@ -1108,6 +1116,49 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"a-_0".repeat(32)}`] })).not.toBeNull(); // alphabet, at bound }); + it("validates the paneId against the path-composition shape (Task 8 (b))", () => { + // The B-side proxy socket is `/ssh//agent.sock` and + // pane-runtime's own guard refuses anything outside + // `^[a-zA-Z0-9_-]{1,64}$`; the grammar names the SAME shape so a command + // that could only fail the node's guard fails here first, before the + // plane signs it. A uuid (the normal subshell id) is in the class. + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "pane-42" })).toMatchObject({ paneId: "pane-42" }); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "A9_z-x" })).toMatchObject({ paneId: "A9_z-x" }); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "x".repeat(64) })).not.toBeNull(); // at bound + // Every refusal below is a shape the socket-path guard could not take: + // traversal, absolute paths, empty, over-bound, and whitespace/control. + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "x".repeat(65) })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "../escape" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a/b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "/abs" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a.b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a\nb" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: 42 })).toBeNull(); + // The exported predicate is the ONE spelling of the rule; the parse arm + // and the plane's openRelay consult it, so a drift here is a drift there. + expect(isSshPaneId("pane-42")).toBe(true); + expect(isSshPaneId("x".repeat(64))).toBe(true); + expect(isSshPaneId("x".repeat(65))).toBe(false); + expect(isSshPaneId("a/b")).toBe(false); + expect(isSshPaneId("")).toBe(false); + expect(isSshPaneId(7)).toBe(false); + expect(isSshPaneId(null)).toBe(false); + }); + + it("exposes the grant fingerprint-set predicate for the plane's pre-signing check", () => { + // The plane validates a grant's selection with the SAME rule its own + // parser enforces on receipt; one definition, two directions. + expect(isSshGrantFingerprints([])).toBe(true); // §5.4: empty names nothing, serves nothing + expect(isSshGrantFingerprints(["SHA256:AAAA"])).toBe(true); + expect(isSshGrantFingerprints(["SHA256:AAAA", "not-a-fingerprint"])).toBe(false); + expect( + isSshGrantFingerprints(Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => `SHA256:${i}`)), + ).toBe(false); + expect(isSshGrantFingerprints("SHA256:AAAA")).toBe(false); + }); + it("narrows the close: routing ref and a NAMED reason from the typed union, nothing else", () => { // The census members are the ONLY accepted spellings; each round-trips. for (const reason of SSH_RELAY_CLOSE_REASONS) { diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 8a4d1d276..32f30ed35 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -251,6 +251,8 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + isSshGrantFingerprints, + isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 4348f161d..c2731f96a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -76,15 +76,29 @@ export type SshRelayRole = "A" | "B"; * both node ids with their roles, the peer's registered signing AND * encryption public keys to pin (§4.4), the grant id with its selected * key-fingerprint set (§5.4, delivered so the responder enforces it without - * a REST call it cannot make), and the lifetime. Every field is REQUIRED: - * a partial pairing names no session the endpoint could honor, so the - * grammar refuses it rather than let an executor guess a half. + * a REST call it cannot make), the lifetime, and the pane the pairing serves. + * Every field is REQUIRED: a partial pairing names no session the endpoint + * could honor, so the grammar refuses it rather than let an executor guess a + * half. * - * The peer keys travel as their registered spellings: the ES256 signing half - * as a JSON-serialized public JWK (what §4.2/§4.3 store and report), the - * ECDH-ES encryption half as canonical base64 (what the link pins). The - * byte-equality enforcement is the receiver's machine pin store (§4.4), + * The peer keys travel as their ONE canonical spellings - the same strings + * §4.2/§4.3 store, transported byte-identically so the pin store's + * byte-equality rule has something to match: + * - `peerSigningPublicKey`: the peer's ES256 signing PUBLIC JWK, JSON- + * serialized (the registration/enroll reporting spelling). + * - `peerEncryptPublicKey`: base64 (standard, padded, `BASE64_RE`) of the + * UTF-8 bytes of the peer's ECDH-ES encryption PUBLIC JWK, JSON-serialized + * (what the receiver decodes back to the exact string its machine pin + * store holds, and what `seal` consumes). + * The byte-equality enforcement is the receiver's machine pin store (§4.4), * not this grammar's. + * + * `paneId` (Task 8 (b)): the B-side proxy socket is + * `/ssh//agent.sock`, and pane-runtime's path-composition + * guard (`buildSshConfigPath`/`buildAgentSocketPath`) accepts only ids + * matching {@link isSshPaneId}; the grammar names the SAME shape so a command + * that could only ever fail the node's socket-path guard is refused here, + * before the plane signs it. */ export interface SshRelayOpenCommand { type: "ssh_relay_open"; @@ -100,7 +114,7 @@ export interface SshRelayOpenCommand { bNodeId: string; /** The peer's registered ES256 signing public key (JSON public JWK) to pin. */ peerSigningPublicKey: string; - /** The peer's registered ECDH-ES encryption public key (canonical base64) to pin. */ + /** Base64 of the UTF-8 bytes of the peer's JSON-serialized encryption public JWK, to pin. */ peerEncryptPublicKey: string; /** The live grant this session runs under (revoke cuts the session, §6.3). */ grantId: string; @@ -108,6 +122,8 @@ export interface SshRelayOpenCommand { fingerprints: string[]; /** Session ceiling in ms; SSH_RELAY_LIFETIME_MS is what the plane sends. */ lifetimeMs: number; + /** The pane (subshell id) the pairing serves: names B's proxy socket path (§5.2). */ + paneId: string; } /** @@ -196,6 +212,25 @@ function isIdStr(value: unknown): value is string { */ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; +/** + * The pane id the relay-open command names: exactly the shape pane-runtime's + * socket-path composition accepts (`buildSshConfigPath`'s own guard - the + * grammar and the path guard cannot drift apart, because the plane's + * `openRelay` validates with THIS predicate before signing, both nodes' + * parsers validate it on receipt, and the node's bind still runs the path + * guard itself as the last station. + */ +const SSH_PANE_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; + +/** + * Whether `value` is a pane (subshell) id inside the socket-path composition + * shape. Exported so the PLANE validates the id it is about to sign into a + * relay-open with the same rule its parser enforces on receipt. + */ +export function isSshPaneId(value: unknown): value is string { + return isStr(value) && SSH_PANE_ID_RE.test(value); +} + /** * The grant's selected fingerprint set: strings, every one in the `SHA256:` * display form (an empty or free-text entry matches no key yet would ride a @@ -203,9 +238,10 @@ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; * {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a refusal at the grammar * so the truncation nobody may silently perform is impossible by construction * (§5.4). An EMPTY set parses: §5.4's "names no fingerprint, serves nothing" - * is a decision, not a defect. + * is a decision, not a defect. Exported for the plane's pre-signing check + * (one definition of the grant-selection law, both directions). */ -function isGrantFingerprints(value: unknown): value is string[] { +export function isSshGrantFingerprints(value: unknown): value is string[] { return ( isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS ); @@ -264,13 +300,18 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu return null; } if (!isRecord(peerSigningJwk) || "d" in peerSigningJwk) return null; - // The encryption half is a base64 key (the link pin's own spelling); a - // present-but-undecodable one is malformed, not "the pin sorts it out". + // The encryption half is base64 of the UTF-8 JSON public JWK (the one + // canonical spelling, see the command's doc); a present-but-undecodable + // one is malformed, not "the pin sorts it out". if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; - if (!isGrantFingerprints(value.fingerprints)) return null; + if (!isSshGrantFingerprints(value.fingerprints)) return null; // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS // as the ceiling the broker sends) is the broker's, this is the shape. if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; + // Task 8 (b): the pane the pairing serves. The B proxy socket is named + // by it, so it must be a path-composability id - the SAME guard the + // node's socket bind runs, applied before anything is signed. + if (!isSshPaneId(value.paneId)) return null; // Rebuilt from the validated fields, never a cast of the candidate: a // stray member on the wire drops here, exactly as the close arm drops. return { @@ -285,6 +326,7 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu grantId: value.grantId, fingerprints: [...value.fingerprints], lifetimeMs: value.lifetimeMs as number, + paneId: value.paneId, }; } case "ssh_relay_close": { From 96317bdfb93424a9372c8725f05d73920704e4f5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:50:32 -0700 Subject: [PATCH 103/170] feat(ssh-relay): plane relay broker with lifetime, quota, blind routing, audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing machine (spec 2026-10-08 §5.3): openRelay gates both roles on the plane's own ssh_enabled row and the agent kind (fail closed), refuses the 9th concurrent session on either node before touching anything, mints the opaque ref, audits node.ssh_relay.open, and delivers the two signed ssh_relay_open commands (role branches, peer keys, fingerprint set, lifetime, paneId). Routing is blind (§5.5): routeRelayFrame looks the ref up and hands the SAME frame object to the peer's pump; no record holds a blob. §5.6's cuts fire through an injectable scheduler: lifetime-expiry, handshake-grace without both sides, a-dropped (A socket death or a refused send - the phantom-hang cut), grant-revoked, child-exit, over-cap; each tells BOTH sides with the named reason and audits. B's answered socket path is byte-checked against the shared pane-runtime derivation and leaves only as the one-key scoped SSH_AUTH_SOCK pane env. Every seam (command transport, pump, rows, audit, clock) is injected; 27 tests, no DB. --- .../__tests__/ssh-relay.service.test.ts | 640 +++++++++++++++++ .../api/src/services/ssh-relay.service.ts | 673 ++++++++++++++++++ 2 files changed, 1313 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.service.test.ts create mode 100644 apps/server/api/src/services/ssh-relay.service.ts diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts new file mode 100644 index 000000000..1f8c0f81b --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -0,0 +1,640 @@ +import { describe, expect, it } from "bun:test"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import type { + NodeCommandBody, + RelayFrame, + SshRelayCloseReason, + SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { + NODE_PROTOCOL_VERSION, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, +} from "@internal/subshell-protocol"; +import type { NodeSocket } from "@/services/nodes/node-registry.js"; +import { attachConnection, getLive, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + closeRelayForPaneExit, + createRelayBroker, + type RelayBroker, + RelaySendError, + sendRelayFrameOverNodeSocket, + setRelayBrokerForTests, + sshRelayPaneEnv, + sweepRelayForPane, +} from "@/services/ssh-relay.service.js"; + +/** + * The plane relay broker (spec 2026-10-08 §5.1/§5.3/§5.5/§5.6, Task 8). The + * whole file runs against injected seams: no DB, no socket, no real clock. + * The acceptance gates get their own named test: + * (a) both opens are delivered as signed commands with the role branches and + * the close reaches both sides; (b) paneId carriage + refusal; + * (c) the per-node send glue throws on a dropped send and a frame routed to + * a dead peer tears the session down; (d) `ssh_enabled` and `local` refuse + * before anything is brokered; (e) SSH_AUTH_SOCK leaves ONLY as the scoped + * one-key pane env, byte-matching the B answer's socket path; (f) every + * §5.6 cut fires its named reason through injectable timers; (g) the quota + * is a loud refusal touching nothing; (h) the peer encryption key is carried + * as base64 of the UTF-8 JSON public JWK, and the plane is BLIND (§5.5): + * routing is a byte-for-byte copy and no session record ever holds a blob. + */ + +const PANE = "11111111-2222-4333-8444-555555555555"; +const A_SIGNING = '{"kty":"EC","crv":"P-256","x":"AX","y":"AY","use":"sig"}'; +const A_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"EX","y":"EY","use":"enc"}'; +const B_SIGNING = '{"kty":"EC","crv":"P-256","x":"BX","y":"BY","use":"sig"}'; +const B_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"FX","y":"FY","use":"enc"}'; +const FINGERPRINTS = ["SHA256:AAAA", "SHA256:BBBB"]; + +const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); + +const frame = (over: Partial = {}): RelayFrame => ({ + type: "relay", + ref: "r-unknown", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", // opaque; the broker must never decode it + ...over, +}); + +interface FakeTimer { + id: number; + at: number; + fn: () => void; + cancelled: boolean; +} + +function makeHarness( + opts: { + rows?: Record; + dataDir?: string | null; + ack?: (nodeId: string, cmd: NodeCommandBody) => unknown | Promise; + relaySend?: (nodeId: string, frame: RelayFrame) => void; + } = {}, +) { + const clock = { now: 1_730_000_000_000 }; + const timers: FakeTimer[] = []; + let nextTimer = 1; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + const relayFrames: { nodeId: string; frame: RelayFrame }[] = []; + const audits: { + action: string; + actorUserId: string | null; + targetType: string | null; + targetId: string | null; + meta: Record; + }[] = []; + const lines: string[] = []; + const rows = opts.rows ?? { a: { kind: "agent", sshEnabled: 1 }, b: { kind: "agent", sshEnabled: 1 } }; + const defaultAck = (_nodeId: string, cmd: NodeCommandBody): unknown => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: buildAgentSocketPath(opts.dataDir ?? "/data/b", cmd.paneId) } + : cmd.type === "ssh_relay_open" + ? { role: "A", relayId: cmd.relayId } + : { ok: true }; + + const broker: RelayBroker = createRelayBroker({ + async sendCommand(nodeId, cmd) { + commands.push({ nodeId, cmd }); + return (opts.ack ?? defaultAck)(nodeId, cmd); + }, + sendRelayFrame(nodeId, f) { + relayFrames.push({ nodeId, frame: f }); + if (opts.relaySend) return opts.relaySend(nodeId, f); + throw new RelaySendError(nodeId, "fake pump always drops"); + }, + async nodeRow(nodeId) { + const r = rows[nodeId]; + return r === undefined ? null : r; + }, + nodeDataDir: () => (opts.dataDir === undefined ? "/data/b" : opts.dataDir), + async audit(e) { + audits.push({ ...e, meta: JSON.parse(e.metadataJson ?? "{}") as Record }); + }, + nowMs: () => clock.now, + schedule: (fn, ms) => { + const t: FakeTimer = { id: nextTimer, at: clock.now + ms, fn, cancelled: false }; + nextTimer += 1; + timers.push(t); + return t; + }, + cancel: (t) => { + (t as FakeTimer).cancelled = true; + }, + log: (line) => lines.push(line), + }); + + const fireDue = (): number => { + let fired = 0; + for (;;) { + const due = timers.filter((t) => !t.cancelled && t.at <= clock.now).sort((x, y) => x.at - y.at)[0]; + if (!due) return fired; + due.cancelled = true; + due.fn(); + fired += 1; + } + }; + + return { + broker, + commands, + relayFrames, + audits, + lines, + timers, + clock, + advance: async (ms: number) => { + clock.now += ms; + // A macrotask turn drains every microtask chain the fake scheduler's + // callbacks started (allSettleds, awaits in closeRelay). + await new Promise((r) => setTimeout(r, 0)); + fireDue(); + await new Promise((r) => setTimeout(r, 0)); + }, + open: (over: Partial[0]> = {}) => + broker.openRelay({ + grantId: "grant-1", + fingerprints: FINGERPRINTS, + paneId: PANE, + aNode: "a", + bNode: "b", + aPeer: { signingPublicKey: A_SIGNING, encryptionPublicJwk: A_ENCRYPT }, + bPeer: { signingPublicKey: B_SIGNING, encryptionPublicJwk: B_ENCRYPT }, + ...over, + }), + }; +} + +/* ------------------------------------------------------------------ */ +/* openRelay: pairing, carriage, gates, quota */ +/* ------------------------------------------------------------------ */ + +describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { + it("pairs A and B with an opaque ref and delivers the whole pairing to BOTH sides", async () => { + const h = makeHarness(); + const r = await h.open(); + // Opaque ids, minted by the plane; the ref is the blind pairing key. + expect(r.ref).toMatch(/^[0-9a-f-]{36}$/); + expect(r.relayId).toMatch(/^[0-9a-f-]{36}$/); + expect(r.ref).not.toBe(r.relayId); + expect(new Date(r.expiresAt).getTime()).toBe(h.clock.now + SSH_RELAY_LIFETIME_MS); + + const toA = h.commands.find((c) => c.nodeId === "a")?.cmd as SshRelayOpenCommand | undefined; + const toB = h.commands.find((c) => c.nodeId === "b")?.cmd as SshRelayOpenCommand | undefined; + expect(toA?.type).toBe("ssh_relay_open"); + expect(toB?.type).toBe("ssh_relay_open"); + // (a) the role branches name each machine what it is; one ref pairs them. + expect(toA?.role).toBe("A"); + expect(toB?.role).toBe("B"); + expect(toA?.ref).toBe(r.ref); + expect(toB?.ref).toBe(r.ref); + expect(toA?.relayId).toBe(r.relayId); + expect(toA?.aNodeId).toBe("a"); + expect(toA?.bNodeId).toBe("b"); + expect(toA?.lifetimeMs).toBe(SSH_RELAY_LIFETIME_MS); + // Each side is told the PEER's keys to pin, never its own. + expect(toA?.peerSigningPublicKey).toBe(B_SIGNING); + expect(toB?.peerSigningPublicKey).toBe(A_SIGNING); + // (h) the encryption half is base64 of the UTF-8 JSON public JWK - the + // exact spelling the node decodes and the pin store holds. + expect(toA?.peerEncryptPublicKey).toBe(b64(B_ENCRYPT)); + expect(Buffer.from(String(toA?.peerEncryptPublicKey), "base64").toString("utf8")).toBe(B_ENCRYPT); + expect(toB?.peerEncryptPublicKey).toBe(b64(A_ENCRYPT)); + // (b) the pane rides the command (the B proxy socket is named by it). + expect(toA?.paneId).toBe(PANE); + expect(toB?.paneId).toBe(PANE); + // (a) the grant's selected fingerprints are delivered for A's responder. + expect(toA?.fingerprints).toEqual(FINGERPRINTS); + expect(toA?.grantId).toBe("grant-1"); + // The open is audited once, ids only. + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open"]); + expect(h.audits[0]?.meta).toEqual({ + relayId: r.relayId, + ref: r.ref, + grantId: "grant-1", + paneId: PANE, + aNodeId: "a", + bNodeId: "b", + fingerprintCount: FINGERPRINTS.length, + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }); + expect(h.audits[0]?.actorUserId).toBeNull(); + // And the plane records both sides as opened once the acks land. + const info = h.broker.sessionInfo(r.ref); + expect(info).not.toBeNull(); + expect(info?.aOpened).toBe(true); + expect(info?.bOpened).toBe(true); + }); + + it("answers the verified B socket path, byte-matching the shared derivation", async () => { + const h = makeHarness(); + const r = await h.open(); + expect(r.socketPath).toBe(buildAgentSocketPath("/data/b", PANE)); + }); + + it("refuses a B answer whose socket path is not THIS pane's derived path (never trusts a machine claim)", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: "/attacker/agent.sock" } + : { role: "A", relayId: cmd.relayId }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + // The session was torn down and BOTH sides were told, under a named cut. + expect(h.broker.sessionInfo(h.audits[0]?.meta.ref as string)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + }); + + it("refuses a missing or non-string B socket answer the same way", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" ? { role: "B", relayId: cmd.relayId } : { role: "A" }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + }); + + it("refuses a node whose ssh gate is off, is local, or has no row - before any command or audit (acceptance (d))", async () => { + for (const [rows, code] of [ + [{ a: { kind: "agent" as const, sshEnabled: 0 }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + [{ a: null, b: { kind: "agent" as const, sshEnabled: 1 } }, "no-node"], + [{ a: { kind: "agent" as const, sshEnabled: 1 }, b: { kind: "local" as const, sshEnabled: 1 } }, "local-node"], + [{ a: { kind: "agent" as const, sshEnabled: null }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + ] as const) { + const h = makeHarness({ rows: rows as never }); + await expect(h.open()).rejects.toMatchObject({ code }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + expect(h.broker.activeRelayCount("a")).toBe(0); + } + }); + + it("refuses the same machine as both A and B, and a paneId outside the path-composition shape (acceptance (b))", async () => { + const h = makeHarness(); + await expect(h.open({ aNode: "a", bNode: "a" })).rejects.toMatchObject({ code: "same-node" }); + await expect(h.open({ paneId: "../escape" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "x".repeat(65) })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ fingerprints: ["not-a-fingerprint"] })).rejects.toMatchObject({ code: "bad-fingerprints" }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + }); + + it("enforces SSH_RELAY_MAX_PER_NODE as a loud refusal that touches nothing (acceptance (g))", async () => { + const h = makeHarness({ + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + "fresh-b": { kind: "agent", sshEnabled: 1 }, + }, + }); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) { + await h.open({ paneId: `pane-${i}`, grantId: `grant-${i}` }); + } + expect(h.broker.activeRelayCount("b")).toBe(SSH_RELAY_MAX_PER_NODE); + const auditsBefore = h.audits.length; + const commandsBefore = h.commands.length; + await expect(h.open({ paneId: "pane-over", grantId: "grant-over" })).rejects.toMatchObject({ + code: "quota", + nodeId: "a", + }); + expect(h.audits).toHaveLength(auditsBefore); // no half-done audit + expect(h.commands).toHaveLength(commandsBefore); // no half-delivered command + // The cap counts each of A and B: a node at the cap cannot join even as A. + await expect( + h.open({ paneId: "pane-as-a", grantId: "grant-as-a", aNode: "b", bNode: "fresh-b" }), + ).rejects.toMatchObject({ code: "quota", nodeId: "b" }); + // A closed slot frees room (the count is LIVE sessions, not history). + const first = h.broker.sessionInfo(h.audits[0]?.meta.ref as string); + expect(first).not.toBeNull(); + await h.broker.closeRelay(first?.ref as string, "lifetime-expiry"); + await expect(h.open({ paneId: "pane-over", grantId: "grant-over" })).resolves.toMatchObject({}); + }); + + it("arms the lifetime and the grace on the INJECTED timers only (no real 30 s waits)", async () => { + const h = makeHarness(); + await h.open(); + const armed = h.timers.map((t) => t.at - 1_730_000_000_000).sort((a, b) => a - b); + expect(armed).toEqual([SSH_RELAY_TEARDOWN_GRACE_MS, SSH_RELAY_LIFETIME_MS]); + }); +}); + +/* ------------------------------------------------------------------ */ +/* routing: blind, by ref, throw-on-drop teardown */ +/* ------------------------------------------------------------------ */ + +describe("routeRelayFrame (spec §5.5, Task 8 (c))", () => { + it("forwards the blob byte-for-byte to the peer and holds nothing itself", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const f = frame({ ref: r.ref, direction: "B2A", blob: b64(JSON.stringify({ secret: "agent bytes" })) }); + h.broker.routeRelayFrame("b", f); + expect(h.relayFrames).toHaveLength(1); + expect(h.relayFrames[0]?.nodeId).toBe("a"); + // The exact same frame, same strings, same objects: an unopened copy. + expect(h.relayFrames[0]?.frame).toBe(f); + // The plane read the ref and nothing else: the session record has no + // blob slot at any point in its life (§5.5), and never gained one. + const info = h.broker.sessionInfo(r.ref); + expect(Object.keys(info ?? {}).sort()).toEqual( + ["aNode", "aOpened", "bNode", "bOpened", "expiresAt", "grantId", "paneId", "ref", "relayId", "socketPath"].sort(), + ); + // ...and the session view never holds the blob, encoded or decoded. + expect(JSON.stringify(info)).not.toContain(f.blob); + expect(JSON.stringify(info)).not.toContain("agent bytes"); + // Both directions route; A's reply rides to B untouched. + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B", seq: 0 })); + expect(h.relayFrames[1]?.nodeId).toBe("b"); + }); + + it("drops a frame for an unbrokered ref and a frame from a node that is not a party", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: "r-not-brokered" })); + h.broker.routeRelayFrame("stranger", frame({ ref: r.ref })); + expect(h.relayFrames).toHaveLength(0); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // a drop is not a teardown + }); + + it("a frame routed to a dead A peer throws through the glue and the session is torn down, not left spinning (acceptance (c))", async () => { + const h = makeHarness(); // the fake pump DROPS (throws) by default + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + // The teardown it triggers is async (the close sends, then the audit). + await new Promise((resolve) => setTimeout(resolve, 0)); + // The phantom-hang direction (B's request cannot reach A): the plane cuts + // now, with the named reason, and both sides are told. + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + expect(closes[0]?.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "a-dropped" }); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta.reason).toBe("a-dropped"); + }); + + it("an undeliverable reply to a dead B loses the frame but not the session (no b-dropped word exists; the caps own it)", async () => { + const h = makeHarness(); + const r = await h.open(); + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B" })); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // A-side cuts must not masquerade as a-dropped + expect(h.commands.filter((c) => c.cmd.type === "ssh_relay_close")).toHaveLength(0); + // The lifetime cap still ends it. + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits[1]?.meta.reason).toBe("lifetime-expiry"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* §5.6 teardown cuts, each with its named reason */ +/* ------------------------------------------------------------------ */ + +describe("teardown (spec §5.6, Task 8 (f))", () => { + it("lifetime expiry cuts with lifetime-expiry, tells both sides, audits once", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "lifetime-expiry" }); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta).toMatchObject({ ref: r.ref, relayId: r.relayId, reason: "lifetime-expiry" }); + }); + + it("the grace window elapsed without both sides cuts with handshake-grace", async () => { + let releaseB: ((v: unknown) => void) | undefined; + const h = makeHarness({ + ack: (nodeId, cmd) => { + if (nodeId === "b" && cmd.type === "ssh_relay_open") { + return new Promise((resolve) => { + releaseB = resolve; + }); + } + return cmd.type === "ssh_relay_open" ? { role: "A", relayId: cmd.relayId } : { ok: true }; + }, + }); + const opening = h.open(); + await new Promise((r) => setTimeout(r, 0)); + // A answered; B has not, inside the grace: the session survives. + const opened = h.commands.find((c) => c.cmd.type === "ssh_relay_open"); + const ref = opened?.cmd.type === "ssh_relay_open" ? opened.cmd.ref : ""; + expect(ref).not.toBe(""); + await h.advance(SSH_RELAY_TEARDOWN_GRACE_MS); + expect(h.broker.sessionInfo(ref)).toBeNull(); + expect( + h.commands.some( + (c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "handshake-grace", + ), + ).toBe(true); + // B's late ack lands on a session that no longer exists: it must not + // resurrect it and openRelay refuses loudly (the handshake never completed). + releaseB?.({ role: "B", socketPath: buildAgentSocketPath("/data/b", PANE) }); + await expect(opening).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.sessionInfo(ref)).toBeNull(); + }); + + it("a refusing node cuts the session under handshake-grace and openRelay throws the named reason", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_open" && cmd.role === "B") throw new Error("machine pin for a has MOVED"); + return { role: "A", relayId: cmd.relayId }; + }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.activeRelayCount("a")).toBe(0); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + const firstClose = closes[0]; + expect(firstClose?.cmd.type).toBe("ssh_relay_close"); + if (firstClose?.cmd.type === "ssh_relay_close") expect(firstClose.cmd.reason).toBe("handshake-grace"); + }); + + it("grant revoke closes every session of the grant with grant-revoked (hook entry; the revoke route is T10)", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r1 = await h.open({ paneId: "p1", grantId: "g-target" }); + const r2 = await h.open({ paneId: "p2", grantId: "g-keep" }); + const n = await h.broker.closeForGrant("g-target", "grant-revoked"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r1.ref)).toBeNull(); + expect(h.broker.sessionInfo(r2.ref)).not.toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r1.ref, reason: "grant-revoked" }); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + }); + + it("A's authenticated socket dropping cuts A's sessions with a-dropped and leaves B-side sessions alone", async () => { + const h = makeHarness({ + relaySend: () => {}, + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + c: { kind: "agent", sshEnabled: 1 }, + }, + }); + const asA = await h.open({ paneId: "p1" }); + // Same node as B of another pairing: its socket close is NOT a cut + // (§5.6 names only A-dropping; B's loss cuts via child-exit or the caps). + const asB = await h.open({ paneId: "p2", aNode: "c", bNode: "a" }); + const closed = await h.broker.onNodeSocketClosed("a"); + expect(closed).toBe(1); + expect(h.broker.sessionInfo(asA.ref)).toBeNull(); + expect(h.broker.sessionInfo(asB.ref)).not.toBeNull(); + expect( + h.commands.some( + (c) => + c.cmd.type === "ssh_relay_close" && c.nodeId === "b" && (c.cmd as { reason: string }).reason === "a-dropped", + ), + ).toBe(true); + }); + + it("child exit cuts by paneId; the sweep helper is best-effort and takes the row's id", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const n = await h.broker.closeForPane(PANE, "child-exit"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "child-exit"), + ).toBe(true); + // An unknown pane is a no-op (the sweep runs on every death transition). + expect(await h.broker.closeForPane("pane-nothing", "child-exit")).toBe(0); + // The row-shaped sweep fires through the module singleton without throwing. + setRelayBrokerForTests(h.broker); + try { + sweepRelayForPane({ id: "pane-nothing" }); + closeRelayForPaneExit("pane-nothing"); + closeRelayForPaneExit(null); + } finally { + setRelayBrokerForTests(null); + } + }); + + it("an over-cap frame closes the session it names, by name, and the blob never comes along", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.refuseOverCap(r.ref)).toBe(true); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "over-cap"), + ).toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(JSON.stringify(h.audits)).not.toContain("over-cap-blob-bytes"); + expect(await h.broker.refuseOverCap("r-unknown")).toBe(false); + }); + + it("closeRelay is idempotent: the second cut answers false and fires no second audit", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(true); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(false); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + // An unknown reason never leaves the plane either. + expect(await h.broker.closeRelay("r-none", "not-a-reason" as SshRelayCloseReason)).toBe(false); + }); + + it("teardown keeps the close command's own failure from poisoning anything", async () => { + const h = makeHarness({ + relaySend: () => {}, + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") throw new Error("node offline"); + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } + : { ok: true }; + }, + }); + const r = await h.open(); + await expect(h.broker.closeRelay(r.ref, "grant-revoked")).resolves.toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* audit hygiene, pane env, and the real per-node send glue */ +/* ------------------------------------------------------------------ */ + +describe("audit hygiene (Global Constraints)", () => { + it("open/close rows carry ids, hosts and the reason ONLY - never keys, fingerprints, or payloads", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + await h.advance(SSH_RELAY_LIFETIME_MS); + const serialized = JSON.stringify(h.audits); + for (const secret of [A_SIGNING, B_SIGNING, A_ENCRYPT, B_ENCRYPT, "SHA256:AAAA", "QUJDk5", r.socketPath]) { + expect(serialized).not.toContain(secret); + } + // fingerprint COUNT, never the set. + expect(h.audits[0]?.meta.fingerprintCount).toBe(2); + expect(h.audits[0]?.meta.fingerprints).toBeUndefined(); + }); +}); + +describe("SSH_AUTH_SOCK scoping (acceptance (e))", () => { + it("the relay pane env is EXACTLY the one-key scoped exception, nothing else", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(sshRelayPaneEnv(r.socketPath)).toEqual({ SSH_AUTH_SOCK: `/data/b/ssh/${PANE}/agent.sock` }); + // A caller cannot smuggle a second variable through the seam. + expect(Object.keys(sshRelayPaneEnv("x"))).toEqual(["SSH_AUTH_SOCK"]); + }); +}); + +describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptance (c))", () => { + it("throws when the node has no live connection", () => { + resetNodeRegistryForTests(); + expect(() => sendRelayFrameOverNodeSocket("gone", frame())).toThrow(RelaySendError); + }); + + it("throws on a closing or link-less connection and seals onto a live one", () => { + resetNodeRegistryForTests(); + const sent: (string | Buffer)[] = []; + const ws: NodeSocket = { + send: (d) => { + sent.push(d); + }, + close: () => {}, + }; + const conn = attachConnection("n1", ws); + // No link yet (the handshake window, or a hand-built conn): a relay + // frame is NEVER written plaintext. + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + conn.link = { + sealFrame: (s: string) => new TextEncoder().encode(`SEALED:${s}`), + } as never; + sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" })); + expect(sent).toHaveLength(1); + expect(Buffer.isBuffer(sent[0])).toBe(true); + // The Buffer carries the SEALED envelope, not the plaintext frame. + expect((sent[0] as Buffer).toString("utf8")).toContain("SEALED:"); + // Closing / superseded conns throw too. + conn.closing = true; + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + resetNodeRegistryForTests(); + expect(getLive("n1")).toBeUndefined(); + }); + + it("a throwing socket send surfaces as the named RelaySendError (never silence)", () => { + resetNodeRegistryForTests(); + const ws: NodeSocket = { + send: () => { + throw new Error("socket write failed"); + }, + close: () => {}, + }; + const conn = attachConnection("n2", ws); + conn.link = { sealFrame: () => new Uint8Array([1]) } as never; + expect(() => sendRelayFrameOverNodeSocket("n2", frame())).toThrow(RelaySendError); + resetNodeRegistryForTests(); + }); +}); + +describe("grammar/version posture", () => { + it("relays a protocol-18-only world: the constant is pinned here as the broker's precondition", () => { + // The handler refuses every relay frame from a pre-18 agent upstream; + // this tripwire documents the dependency at the broker. + expect(NODE_PROTOCOL_VERSION).toBe(18); + }); +}); diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts new file mode 100644 index 000000000..67204800a --- /dev/null +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -0,0 +1,673 @@ +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + isSshGrantFingerprints, + isSshPaneId, + NODE_MAX_FRAME_BYTES, + type NodeCommandBody, + type RelayFrame, + SSH_RELAY_CLOSE_REASONS, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, + type SshNodeCommandBody, + type SshRelayCloseReason, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import type { NodeKind } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { binaryPayload, sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The plane relay broker (spec 2026-10-08 §5.3, Task 8): the pairing machine + * for the sealed agent relay. `openRelay` brokers a session between A (the + * key home) and B (the connecting machine) - gate, quota, mint, audit, then + * the two signed `ssh_relay_open` commands carrying the WHOLE pairing: both + * node ids with roles, the peer's registered signing key (JSON public JWK) + * and encryption key (base64 of the UTF-8 JSON public JWK - the one canonical + * spelling, acceptance (h)), the grant's fingerprint set, the lifetime, and + * the pane (acceptance (b)). From then on the plane's only job is §5.5: + * route `relay` frames by their opaque ref, blob byte-for-byte unopened. + * + * **The blindness is the posture.** No session record holds a blob, a key, + * a fingerprint, or a payload; `routeRelayFrame` copies the frame object it + * was handed to the peer's socket and never reads `blob` (§5.5: an unopened + * byte copy is the whole forwarding job). Durably, the plane writes only the + * two audit rows - `node.ssh_relay.open` / `node.ssh_relay.close`, ids, hosts + * and the named reason, never secrets (Global Constraints, docs/security.md + * §10). + * + * **Teardown is by named reason only** (§5.1/§5.6). The grammar's six words + * are the whole vocabulary, and every cut below maps to exactly one: + * lifetime expiry, the handshake grace elapsed without both sides, A's link + * dropping, a grant revoke, the pane's child exiting, and an over-cap frame. + * Each cut sends the named-reason `ssh_relay_close` to BOTH sides and writes + * the close audit. A B-side link loss deliberately names no new reason: the + * grammar has no `b-dropped` word, §5.6 does not name B-dropping as a cut, + * and the task-7 handoff is explicit that phantom state ends at Task 8's + * timers - so an undeliverable reply to a gone B loses the ONE frame and the + * session dies at its lifetime cap, child exit, or revoke, never at a + * misnamed reason. + * + * **The per-node send glue throws on drop** (acceptance (c), mirroring the + * T6/T7 deliver-or-throw contract at the endpoints): {@link + * sendRelayFrameOverNodeSocket} refuses LOUDLY when the peer has no live + * socket, its record is closing or superseded, its encrypted link is not + * established, or the write itself fails. A silent-return pump reintroduces + * the phantom-request hang, so a routed frame whose peer socket is dead + * tears the session down (`a-dropped`) instead of leaving it spinning. + * + * **The machine answer about the socket is never trusted** (acceptance (e)): + * B's open answers with the proxy socket path it bound, and the plane + * re-derives the same path (`buildAgentSocketPath`, the shared pane-runtime + * helper the node itself binds) from ITS record of B's dataDir and the + * command's paneId, refusing a single-byte mismatch before the launch + * exports anything. `SSH_AUTH_SOCK` then leaves through + * {@link sshRelayPaneEnv} as the one-key scoped exception - the exact channel + * `ssh-launch.service.ts`'s `composeSshLaunch` uses for the ssh invocation + * env (`createSubshell`'s `extraPaneEnv` member) - never the whole-pane env. + * + * Both relay roles require a switched-on AGENT node (acceptance (d)): the + * plane's own `ssh_enabled` row must read 1 (default off fails closed, and a + * missing row refuses too) and `kind` must be `agent` - `local` is never an + * A or B. Both checks run before any session exists, any command leaves, or + * any audit row is written, as does the quota (acceptance (g)): a 9th live + * session on either node is a loud `SshRelayRefusal("quota")` that touched + * nothing. + * + * Everything outside the module is a {@link RelayBrokerDeps} seam (the + * `getNodeWsDeps` pattern): the command transport, the per-node relay pump, + * the node rows, the audit sink, and a clock/scheduler so the 30 s lifetime + * and the 5 s grace are testable without waiting. The production wiring is + * the default at the bottom of this file; the singleton is the seam + * `nodes/relay-frames.ts` and the pane-death sweeps route through. + */ + +/** The peer's registered relay keys, as the identities store holds them (public halves only). */ +export interface RelayPeerKeys { + /** The peer's ES256 signing public key: JSON-serialized public JWK (§4.2's reporting spelling). */ + signingPublicKey: string; + /** + * The peer's ECDH-ES encryption public key: JSON-serialized public JWK. + * The signed command carries it as base64 of its UTF-8 bytes (acceptance + * (h)); the pin stores the decoded string byte-equal. + */ + encryptionPublicJwk: string; +} + +/** Why an `openRelay` refused. Each code names its own door; the message says more. */ +export type SshRelayRefusalCode = + | "bad-pane-id" + | "bad-fingerprints" + | "same-node" + | "no-node" + | "local-node" + | "node-off" + | "no-datadir" + | "quota" + | "handshake" + | "bad-socket-path"; + +/** The loud refusal {@link RelayBroker.openRelay} throws. No session exists after one. */ +export class SshRelayRefusal extends Error { + readonly code: SshRelayRefusalCode; + /** The node the refusal is about, when it is node-shaped. */ + readonly nodeId: string | undefined; + + constructor(code: SshRelayRefusalCode, message: string, nodeId?: string) { + super(message); + this.name = "SshRelayRefusal"; + this.code = code; + this.nodeId = nodeId; + } +} + +/** + * The per-node relay pump's refusal (acceptance (c)): a frame that could not + * be delivered. Thrown, never swallowed - the endpoint's deliver-or-throw + * contract and the broker's teardown both depend on the raise. + */ +export class RelaySendError extends Error { + readonly nodeId: string; + + constructor(nodeId: string, message: string) { + super(`relay send to node "${nodeId}" refused: ${message}`); + this.name = "RelaySendError"; + this.nodeId = nodeId; + } +} + +/** The observable view of one brokered session. Never carries a blob, key, or payload (§5.5). */ +export interface RelaySessionInfo { + relayId: string; + ref: string; + grantId: string; + paneId: string; + aNode: string; + bNode: string; + /** Epoch-ms expiry (lifetime cap, §5.6). */ + expiresAt: number; + /** B's verified proxy socket path; null until B's open ack lands. */ + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; +} + +/** Inputs to {@link RelayBroker.openRelay}. A grant's id and selection are T10's; they arrive as inputs here. */ +export interface OpenRelayInput { + /** The live grant this session runs under (revoke cuts the session, §6.3). */ + grantId: string; + /** The grant's selected fingerprints (§5.4); validated with the grammar's own predicate before signing. */ + fingerprints: readonly string[]; + /** The B-side pane the pairing serves (grammar (b)); pre-minted subshell id. */ + paneId: string; + /** The key home's node id. */ + aNode: string; + /** The connecting machine's node id. */ + bNode: string; + /** A's registered public halves, from A's identity row. */ + aPeer: RelayPeerKeys; + /** B's registered public halves, from B's identity row. */ + bPeer: RelayPeerKeys; +} + +/** What a successful `openRelay` answers: the pairing, plus the socket the pane's scoped env must point at. */ +export interface OpenRelayResult { + relayId: string; + /** The opaque routing ref - the plane's blind pairing key (the plan's `sessionRef`). */ + ref: string; + /** ISO 8061 expiry: the lifetime cap (§5.6). */ + expiresAt: string; + /** B's proxy socket, byte-verified against the shared derivation (acceptance (e)). */ + socketPath: string; +} + +/** Opaque timer handle; the scheduler seam's own identity. */ +export type TimerHandle = { readonly brand?: never }; + +/** The broker's world, every seam injectable (production default at the bottom). */ +export interface RelayBrokerDeps { + /** The signed command transport (`node-rpc.sendCommand`). */ + sendCommand(nodeId: string, cmd: NodeCommandBody, opts?: { timeoutMs?: number }): Promise; + /** The per-node relay pump; MUST throw on any non-delivery (acceptance (c)). */ + sendRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The plane's own node row (kind + gate), or null when there is none. */ + nodeRow(nodeId: string): Promise<{ kind: NodeKind; sshEnabled: number | null } | null | undefined>; + /** The node's dataDir from its live `ready` facts, or null (not fully connected). */ + nodeDataDir(nodeId: string): string | null; + /** The audit sink (best-effort by contract, never throws). */ + audit(event: AuditEventInput): Promise; + nowMs(): number; + /** Injected scheduler so the 30 s / 5 s windows drive without waiting. */ + schedule(fn: () => void, ms: number): TimerHandle; + cancel(handle: TimerHandle): void; + log(line: string): void; +} + +/** The broker surface: open, route, the named cuts, and the census views. */ +export interface RelayBroker { + /** Broker A+B for one pane under one grant; throws {@link SshRelayRefusal} loudly. */ + openRelay(input: OpenRelayInput): Promise; + /** Route one grammar-parsed relay frame from `nodeId` BLIND to its peer (§5.5). */ + routeRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The single teardown entry: named reason to BOTH sides + the close audit. Idempotent. */ + closeRelay(ref: string, reason: SshRelayCloseReason): Promise; + /** Grant-revoke hook (§6.3; the revoke route lands in T10). Returns sessions closed. */ + closeForGrant(grantId: string, reason: SshRelayCloseReason): Promise; + /** Pane-death hook (the §5.6 child-exit cut; wired at the sweep sites). Returns sessions closed. */ + closeForPane(paneId: string, reason: SshRelayCloseReason): Promise; + /** §5.1's over-cap refusal: close the named ref with reason `over-cap`. */ + refuseOverCap(ref: string): Promise; + /** The `a-dropped` cut, witnessed by the node socket's death. Returns sessions closed. */ + onNodeSocketClosed(nodeId: string): Promise; + /** Live sessions touching this node in EITHER role (the quota's count). */ + activeRelayCount(nodeId: string): number; + /** The observable view of one ref (test/diagnosis); null when nothing is brokered under it. */ + sessionInfo(ref: string): RelaySessionInfo | null; + /** Drop everything (timer-cancelling; daemon-test isolation). */ + reset(): void; +} + +/** One brokered pairing, in memory only (§5.3: durable life is the audit rows). */ +interface Session { + relayId: string; + ref: string; + grantId: string; + paneId: string; + aNode: string; + bNode: string; + expiresAt: number; + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; + lifetimeTimer: TimerHandle; + graceTimer: TimerHandle; +} + +function base64OfJwk(jwkJson: string): string { + // Acceptance (h): base64 of the UTF-8 bytes of the JSON public JWK - the + // spelling the node decodes back into the byte-equal pin string. + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +function openCmd(input: OpenRelayInput, role: "A" | "B", ref: string, relayId: string): SshRelayOpenCommand { + const peer = role === "A" ? input.bPeer : input.aPeer; // each side pins the PEER, never itself + return { + type: "ssh_relay_open", + relayId, + ref, + role, + aNodeId: input.aNode, + bNodeId: input.bNode, + peerSigningPublicKey: peer.signingPublicKey, + peerEncryptPublicKey: base64OfJwk(peer.encryptionPublicJwk), + grantId: input.grantId, + fingerprints: [...input.fingerprints], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: input.paneId, + }; +} + +/** Build a broker over the given seams. The module singleton (bottom) is one of these with the real world. */ +export function createRelayBroker(deps: RelayBrokerDeps): RelayBroker { + /** The ONE map: opaque ref -> session (§5.3). A ref nothing brokered is refused, never guessed. */ + const byRef = new Map(); + + const infoOf = (s: Session): RelaySessionInfo => ({ + relayId: s.relayId, + ref: s.ref, + grantId: s.grantId, + paneId: s.paneId, + aNode: s.aNode, + bNode: s.bNode, + expiresAt: s.expiresAt, + socketPath: s.socketPath, + aOpened: s.aOpened, + bOpened: s.bOpened, + }); + + const closeSends = (s: Session, reason: SshRelayCloseReason): Promise[]> => { + // The named close to BOTH sides, best-effort on the transport (the audit + // records the cut whatever the sockets do; a refused close delivery names + // the side in the log and nothing more). + const cmd: SshNodeCommandBody = { type: "ssh_relay_close", ref: s.ref, reason }; + return Promise.allSettled([ + Promise.resolve().then(() => deps.sendCommand(s.aNode, cmd)), + Promise.resolve().then(() => deps.sendCommand(s.bNode, cmd)), + ]); + }; + + const closeRelay = async (ref: string, reason: SshRelayCloseReason): Promise => { + const s = byRef.get(ref); + if (!s) return false; + // Grammar membership is a code-path guard, not paranoia theater: the + // node's parser refuses an unnamed close, and §5.1's posture is that an + // unnamed close is the silence this family refuses. + if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(reason)) { + deps.log(`relay broker: close refused for ${ref}: reason "${reason}" is not a named reason`); + return false; + } + // Remove FIRST, arm the cut's sends next: a concurrent route/drop must + // never forward onto a session already being torn down. + byRef.delete(ref); + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + await closeSends(s, reason); + await deps.audit({ + actorUserId: null, // the plane brokered it; the human's act is the launch/grant audit + action: "node.ssh_relay.close", + targetType: "node", + targetId: s.aNode, + // ids, hosts, reason - never keys, fingerprints, sockets, or payloads + // (Global Constraints; docs/security.md §10). + metadataJson: JSON.stringify({ + relayId: s.relayId, + ref: s.ref, + grantId: s.grantId, + paneId: s.paneId, + aNodeId: s.aNode, + bNodeId: s.bNode, + reason, + }), + }); + return true; + }; + + const forEach = (match: (s: Session) => boolean): Session[] => [...byRef.values()].filter(match); + + const openRelay = async (input: OpenRelayInput): Promise => { + // Validation FIRST: a malformed input is refused before a row is read, a + // session exists, or a byte is audited. + if (!isSshPaneId(input.paneId)) { + throw new SshRelayRefusal("bad-pane-id", `relay open refused: paneId "${input.paneId}" is not a path id`); + } + if (!isSshGrantFingerprints(input.fingerprints)) { + throw new SshRelayRefusal( + "bad-fingerprints", + "relay open refused: the grant's fingerprint set is malformed or over SSH_MAX_GRANT_FINGERPRINTS", + ); + } + if (input.aNode === input.bNode) { + // A relay bridges two machines; a one-machine pairing means a caller + // that should not have asked at all. + throw new SshRelayRefusal("same-node", "relay open refused: A and B name the same machine"); + } + // (d) the gate: BOTH sides must be switched-on AGENT nodes, fail closed. + // The plane's row is the authoritative half of the double gate (the node + // re-checks its own mirror on delivery; both refusals are this posture). + for (const nodeId of [input.aNode, input.bNode]) { + const row = await deps.nodeRow(nodeId); + if (!row) throw new SshRelayRefusal("no-node", `relay open refused: node "${nodeId}" has no row`, nodeId); + if (row.kind !== "agent") { + throw new SshRelayRefusal("local-node", `relay open refused: "${nodeId}" is not an agent machine`, nodeId); + } + if (row.sshEnabled !== 1) { + throw new SshRelayRefusal("node-off", `relay open refused: SSH is not enabled on "${nodeId}"`, nodeId); + } + } + // The socket-path byte-check needs B's dataDir; without `ready` facts the + // plane cannot verify B's answer, so it refuses rather than trust it. + const bDataDir = deps.nodeDataDir(input.bNode); + if (bDataDir === null) { + throw new SshRelayRefusal( + "no-datadir", + `relay open refused: "${input.bNode}" has not reported its data dir yet`, + input.bNode, + ); + } + // (g) the quota, before ANY state: a 9th live session on either node is + // a loud refusal that touched nothing - no audit, no command, no record. + for (const nodeId of [input.aNode, input.bNode]) { + const live = forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + if (live >= SSH_RELAY_MAX_PER_NODE) { + throw new SshRelayRefusal( + "quota", + `relay open refused: node "${nodeId}" already holds ${live} live relay sessions (max ${SSH_RELAY_MAX_PER_NODE})`, + nodeId, + ); + } + } + + const relayId = crypto.randomUUID(); + const ref = crypto.randomUUID(); // opaque pairing key; entropy is minted, never negotiated + const expiresAt = deps.nowMs() + SSH_RELAY_LIFETIME_MS; + const s: Session = { + relayId, + ref, + grantId: input.grantId, + paneId: input.paneId, + aNode: input.aNode, + bNode: input.bNode, + expiresAt, + socketPath: null, + aOpened: false, + bOpened: false, + lifetimeTimer: deps.schedule(() => { + void closeRelay(ref, "lifetime-expiry").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_LIFETIME_MS), + graceTimer: deps.schedule(() => { + if (s.aOpened && s.bOpened) return; + void closeRelay(ref, "handshake-grace").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_TEARDOWN_GRACE_MS), + }; + byRef.set(ref, s); + await deps.audit({ + actorUserId: null, + action: "node.ssh_relay.open", + targetType: "node", + targetId: input.aNode, + metadataJson: JSON.stringify({ + relayId, + ref, + grantId: input.grantId, + paneId: input.paneId, + aNodeId: input.aNode, + bNodeId: input.bNode, + fingerprintCount: input.fingerprints.length, // the COUNT, never the set + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }), + }); + + // Deliver the pairing. Both opens go out now; the acks complete the + // handshake the grace timer watches. The open command's transport + // deadline is the grace itself - past that window the session is already + // cut, so waiting longer only delays the refusal the human reads. + const expectedSocketPath = buildAgentSocketPath(bDataDir, input.paneId); + const [resA, resB] = await Promise.allSettled([ + deps.sendCommand(input.aNode, openCmd(input, "A", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + deps.sendCommand(input.bNode, openCmd(input, "B", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + ]); + if (resA.status === "fulfilled") s.aOpened = true; + if (resB.status === "fulfilled") s.bOpened = true; + + if (resB.status === "fulfilled") { + // (e): B answers the socket it bound. The plane re-derives the path and + // refuses a byte mismatch - a machine answer never names the path the + // pane's ssh gets pointed at. + const ack = resB.value as { socketPath?: unknown }; + if (typeof ack?.socketPath !== "string" || ack.socketPath !== expectedSocketPath) { + await closeRelay(ref, "handshake-grace"); + throw new SshRelayRefusal( + "bad-socket-path", + `relay open refused: "${input.bNode}" answered a socket path the plane did not derive`, + input.bNode, + ); + } + s.socketPath = ack.socketPath; + } + + if (!(s.aOpened && s.bOpened) || !byRef.has(ref)) { + // Either side refused (pin moved, mirror off, offline, timeout) or the + // grace already cut the pairing: the handshake never completed. Close + // under the handshake word (idempotent if the grace already did) - the + // grammar names no "refused-open", and §5.6's window cut is the honest + // bucket for a pairing that did not come up on both ends. + await closeRelay(ref, "handshake-grace"); + const why = + resA.status === "rejected" + ? `A refused or did not answer: ${String(resA.reason)}` + : resB.status === "rejected" + ? `B refused or did not answer: ${String(resB.reason)}` + : "the handshake window closed before both sides answered"; + throw new SshRelayRefusal("handshake", `relay open refused: ${why}`); + } + + // Both sides are live: the grace is no longer owed (the lifetime cap stays + // as §5.6's ceiling). + deps.cancel(s.graceTimer); + return { relayId, ref, expiresAt: new Date(expiresAt).toISOString(), socketPath: s.socketPath as string }; + }; + + const routeRelayFrame = (nodeId: string, frame: RelayFrame): void => { + // §5.5 in one line's worth of reading: look up the ref, find the peer, + // copy the frame. The blob never comes anywhere near this function's + // comprehension - it is passed by reference and that IS the forwarding. + const s = byRef.get(frame.ref); + if (!s) { + // A stale ref (the session was cut around the plane's own close) or a + // stranger pointing at someone's pairing: refused, logged by ref, and + // NOTHING else - refs are opaque ids, not secrets, and naming one is + // how an operator correlates the two machines' logs. + deps.log(`relay frame for unbrokered ref ${frame.ref}: refused`); + return; + } + const fromA = s.aNode === nodeId; + if (!fromA && s.bNode !== nodeId) { + deps.log(`relay frame for ref ${frame.ref} from non-party ${nodeId}: refused`); + return; + } + const peer = fromA ? s.bNode : s.aNode; + try { + deps.sendRelayFrame(peer, frame); + } catch (err) { + deps.log(`relay frame for ref ${frame.ref} could not reach ${peer}: ${String(err)}`); + if (fromA) { + // A's reply cannot reach B: the grammar has no b-dropped word, §5.6 + // does not list B's link as a cut, and T7's handoff is explicit that + // the caps own the end of a B-side stall. Lose this frame; the + // lifetime, child exit, or revoke cuts the session. + return; + } + // B's request cannot reach A: THAT is §5.6's A-dropping (and the + // phantom-request hang acceptance (c) exists to kill). Cut now with the + // named reason; B is still reachable to hear it. + void closeRelay(frame.ref, "a-dropped").catch((err2: unknown) => deps.log(`relay close threw: ${String(err2)}`)); + } + }; + + return { + openRelay, + routeRelayFrame, + closeRelay, + async closeForGrant(grantId, reason) { + let n = 0; + for (const s of forEach((x) => x.grantId === grantId)) if (await closeRelay(s.ref, reason)) n += 1; + return n; + }, + async closeForPane(paneId, reason) { + let n = 0; + for (const s of forEach((x) => x.paneId === paneId)) if (await closeRelay(s.ref, reason)) n += 1; + return n; + }, + refuseOverCap: (ref) => closeRelay(ref, "over-cap"), + async onNodeSocketClosed(nodeId) { + // ONLY the A-side cut (§5.6: "A-drop ... as earlier cuts"). B's link + // may redial with its registry intact; B's real end is the child-exit + // sweep or a cap, not this witness. + let n = 0; + for (const s of forEach((x) => x.aNode === nodeId)) if (await closeRelay(s.ref, "a-dropped")) n += 1; + return n; + }, + activeRelayCount(nodeId) { + return forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + }, + sessionInfo(ref) { + const s = byRef.get(ref); + return s ? infoOf(s) : null; + }, + reset() { + for (const s of byRef.values()) { + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + } + byRef.clear(); + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* the real per-node pump (acceptance (c)): deliver, or THROW */ +/* ------------------------------------------------------------------ */ + +/** + * Write one relay frame to `nodeId`'s CURRENT live socket, sealed through + * its established link (a relay frame is never plaintext: the version gate + * guarantees any node that carries one speaks protocol 18, whose sockets are + * handshake-classified). Throws {@link RelaySendError} on every non-delivery: + * no live connection, a closing/superseded record, an unestablished link, or + * a failing write. The broker's teardown and the endpoints' deliver-or-throw + * contract both read the throw; a silent return is the phantom-hang bug. + */ +export function sendRelayFrameOverNodeSocket(nodeId: string, frame: RelayFrame): void { + const conn = getLive(nodeId); + if (!conn) throw new RelaySendError(nodeId, "node has no live connection"); + if (conn.closing) throw new RelaySendError(nodeId, "connection is closing (superseded or revoked)"); + if (!conn.link) throw new RelaySendError(nodeId, "encrypted link is not established"); + const payload = JSON.stringify(frame); + if (Buffer.byteLength(payload) > NODE_MAX_FRAME_BYTES) { + throw new RelaySendError(nodeId, `frame exceeds ${NODE_MAX_FRAME_BYTES} bytes`); + } + try { + conn.ws.send(binaryPayload(conn.link.sealFrame(payload))); + } catch (err) { + throw new RelaySendError(nodeId, `socket send failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the pane-scoped env (acceptance (e)) + the death sweeps */ +/* ------------------------------------------------------------------ */ + +/** + * The pane env the relay launch exports: EXACTLY the scoped `SSH_AUTH_SOCK` + * exception, the one key the `ssh` invocation gets through `createSubshell`'s + * `extraPaneEnv` member - the same channel `composeSshLaunch` uses for the + * snapshot's agent socket (spec 2026-10-07 §5.2, decision 3). Never merged + * into the whole-pane env (Global Constraints: the agent-capable socket + * belongs to the connecting process, not the pane's shell). + */ +export function sshRelayPaneEnv(socketPath: string): { SSH_AUTH_SOCK: string } { + return { SSH_AUTH_SOCK: socketPath }; +} + +/** + * The §5.6 child-exit cut for one pane, best-effort by shape (the + * `sweepLocalSshDir` doctrine restated for relay sessions): every site where + * a pane stops existing calls it, the broker's map lookup no-ops the + * overwhelmingly common "this pane never relayed" case, and a failure costs + * one log line. The reason is `child-exit` for every stop path: the pane's + * process is gone, which is what the word means to the endpoints. + */ +export function closeRelayForPaneExit(paneId: string | null | undefined): void { + if (!paneId) return; + try { + void getRelayBroker() + .closeForPane(paneId, "child-exit") + .catch((err: unknown) => logger.warn(`relay sweep for pane ${paneId} failed: ${String(err)}`)); + } catch (err) { + logger.withError(err).warn(`relay sweep for pane ${paneId} threw`); + } +} + +/** Row-shaped twin of {@link closeRelayForPaneExit} for the sites that hold a row. */ +export function sweepRelayForPane(row: { id: string } | null | undefined): void { + closeRelayForPaneExit(row?.id); +} + +/* ------------------------------------------------------------------ */ +/* production wiring + module singleton */ +/* ------------------------------------------------------------------ */ + +let defaultDeps: RelayBrokerDeps | undefined; + +function productionDeps(): RelayBrokerDeps { + defaultDeps ??= { + sendCommand: (nodeId, cmd, opts) => sendCommand(nodeId, cmd, opts ?? {}), + sendRelayFrame: sendRelayFrameOverNodeSocket, + async nodeRow(nodeId) { + const row = await getRequestlessContext().repos.nodes.findById(nodeId); + return row ? { kind: row.kind, sshEnabled: row.sshEnabled } : null; + }, + nodeDataDir: (nodeId) => getLive(nodeId)?.agent?.dataDir ?? null, + audit, + nowMs: () => Date.now(), + schedule: (fn, ms) => { + const t = setTimeout(fn, ms); + t.unref?.(); // a brokered relay must never hold the process (or a test runner) open + return t as unknown as TimerHandle; + }, + cancel: (handle) => clearTimeout(handle as ReturnType), + log: (line) => logger.debug(line), + }; + return defaultDeps; +} + +let singleton: RelayBroker | undefined; + +/** The broker the handler routing and the pane-death sweeps use (lazy, like `getNodeWsDeps`). */ +export function getRelayBroker(): RelayBroker { + singleton ??= createRelayBroker(productionDeps()); + return singleton; +} + +/** + * Install (or with null, drop) the module singleton. + * @internal test-only - relay-frames routing and the sweep helpers reach the + * broker through it, and a suite with injected seams must be able to point + * those two entry points at a recorder. + */ +export function setRelayBrokerForTests(broker: RelayBroker | null): void { + singleton = broker ?? undefined; +} From a1f43c385915915ddbb977fa711ccc05bd1d52e2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:50:35 -0700 Subject: [PATCH 104/170] feat(ssh-relay): route the handler seam and every pane death through the broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay-frames' Task-8 body: onRelayFrame hands the frame object to the broker's blind route and keeps only a blob-free per-node routing summary (count, last ref, last direction - no slot could hold a byte, §5.5); the over-cap seam refuses BY NAME through the broker; a throwing broker is contained so one refused send never costs the socket its frame chain. handleNodeClose's live-socket branch is the a-dropped witness (a superseded close tells the broker nothing). The §5.6 child-exit cut rides every site the local ssh sweep already knows - report, terminate, the maintenance kill, the death transition, the create rollback, the reconcile fast path, and delete - through closeRelayForPaneExit, idempotent and best-effort like the dir sweep beside it. --- .../subshells-exit-ssh-sweep.test.ts | 39 +++++ .../subshells-terminate-ssh-sweep.test.ts | 36 ++++ .../nodes/__tests__/node-ws-handler.test.ts | 54 +++++- .../nodes/__tests__/relay-frames.test.ts | 128 +++++++++++--- .../api/src/services/nodes/node-ws-handler.ts | 10 +- .../api/src/services/nodes/relay-frames.ts | 160 ++++++++++-------- .../src/services/subshell-manager.service.ts | 11 ++ .../api/src/services/subshells.service.ts | 3 + 8 files changed, 343 insertions(+), 98 deletions(-) diff --git a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts index 220a8fae6..876417e2f 100644 --- a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts @@ -8,6 +8,7 @@ import { runMigrations } from "@/db/migrate.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; @@ -115,4 +116,42 @@ describe("the exit report sweeps the LOCAL ssh config dir", () => { await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); }); + + it("(M2 §5.6 child-exit) the report also witnesses the pane's death to the relay broker", async () => { + // The relay cut is keyed by the PANE, so it fires for every row flavor + // (local or agent, ssh or not - the broker's map lookup no-ops the rows + // that never brokered anything). A spy broker observes the witness. + const cuts: { paneId: string; reason: string }[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push({ paneId, reason }); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + await service.reportExit(id, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + // The report's own witness AND the manager's shared death transition + // both speak the same word (the dir sweep's redundancy, restated): + // closeForPane is idempotent, and at least one sure thing is the bar. + expect(cuts.length).toBeGreaterThanOrEqual(1); + for (const cut of cuts) expect(cut).toEqual({ paneId: id, reason: "child-exit" }); + } finally { + setRelayBrokerForTests(null); + } + }); }); diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts index c79f85832..d5aaa39d8 100644 --- a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -9,6 +9,7 @@ import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; @@ -100,6 +101,41 @@ describe("the terminate verb sweeps the LOCAL ssh config dir", () => { expect(existsSync(localDir(id))).toBe(true); rmSync(localDir(id), { recursive: true, force: true }); }); + + it("(M2 §5.6 child-exit) terminate also cuts any relay session brokered for the pane", async () => { + // The kill hand is the sure thing: a brokered B-side relay must die with + // the pane even when the node's dying report never lands. The broker's + // pane lookup no-ops rows that never relayed, so this witness fires for + // every terminate. + const cuts: string[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push(`${paneId}:${reason}`); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + expect(cuts).toEqual([`${id}:child-exit`]); + } finally { + setRelayBrokerForTests(null); + } + }); }); /** diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index be5cb0218..bcf725ee7 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -22,6 +22,7 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; import { handleSubshellMessage } from "@/ws/subshell-ws.js"; @@ -1394,6 +1395,47 @@ describe("handleNodeClose (superseded-close hygiene, spec §5.3)", () => { await handleNodeClose(h.deps, fakeSocket("n1")); expect(h.statuses).toEqual([]); }); + + it("(Task 8, spec 2026-10-08 §5.6) the LIVE socket's death is witnessed by the relay broker; a superseded one is not", async () => { + // The `a-dropped` cut's ONLY trigger is this witness: the handler owns + // "the authenticated link is gone", the broker owns "which sessions that + // kills". A superseded close means the node is NOT gone - its newer + // socket still answers - so the witness must fire once, for the real + // death only. + const seen: string[] = []; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async () => false, + onNodeSocketClosed: async (nodeId) => { + seen.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(broker); + try { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + const second = fakeSocket("n1"); // newest-wins: the first is superseded + handleNodeOpen(OPEN_DEPS, second); + await handleNodeClose(h.deps, first); + expect(seen).toEqual([]); // a superseded close tells the broker NOTHING + await handleNodeClose(h.deps, second); + expect(seen).toEqual(["n1"]); // the real death is reported exactly once + await new Promise((resolve) => setTimeout(resolve, 0)); // drain the fire-and-forget + } finally { + setRelayBrokerForTests(null); + } + }); }); /** @@ -1788,16 +1830,18 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ await handleNodeMessage(h.deps, ws, bindingBytes(f)); // established at 18 const relayFrame: RelayFrame = { type: "relay", ref: "r-1", seq: 3, direction: "A2B", blob: "QUJD" }; await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); - // The seam saw EXACTLY the frame, blob included - and nothing else did: - // the plane is a blind router (§5.5), the stub only records. - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); + // The frame reached the broker (Task 8's route: pair by ref, copy the + // blob unopened). The seam's own record is a routing SUMMARY - the plane + // is a blind router (§5.5) and the summary has no slot to hold a blob. + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); + expect(JSON.stringify(relayFramesSeenFor("n1"))).not.toContain("QUJD"); // The over-cap blob is refused by the grammar upstream of the ROUTER seam // (§5.1: cap is law); the socket survives, the frame is not forwarded. // But §5.1's named-reason close needs the refusal to be OBSERVABLE, and // the handler's over-cap probe is what routes it - ref only, no blob. const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // router seam: NOT the normal route + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); // router seam: NOT the normal route expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-1" }); // over-cap seam, ref carried // The blob never crossed: the record has no byte slot, and the only // bytes the seam could know are the ref (§5.5 blindness end to end). @@ -1827,7 +1871,7 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ // And a well-formed UNDER-cap frame goes the other way: routed, not refused. const goodFrame: RelayFrame = { type: "relay", ref: "r-11", seq: 0, direction: "A2B", blob: "QUJD" }; await handleNodeMessage(h.deps, ws, JSON.stringify(goodFrame)); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: goodFrame }); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-11", lastDirection: "A2B" }); expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); // still just the two expect(ws.closed).toHaveLength(0); }); diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts index fe971f092..2d69dce76 100644 --- a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import type { RelayFrame } from "@internal/subshell-protocol"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { + onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap, relayFramesSeenFor, @@ -9,12 +11,13 @@ import { } from "../relay-frames.js"; /** - * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream - * (parseRelayFrame via the handler's parse), the held/supersede guards - * upstream of that, and the version gate upstream of everything. What this - * file pins is the stub's CONTRACT that Task 8's broker inherits: route in, - * recorded verbatim out, blob never touched. The end-to-end routing (which - * socket a frame reaches only after a brokered open) is Task 8's suite. + * The Task-8 seam (spec 2026-10-08 §5.1/§5.3/§5.5): validation happened + * upstream (parseRelayFrame via the handler's parse), the held/supersede + * guards upstream of that, and the version gate upstream of everything. This + * file pins the SEAM's own contract now that the broker is real: every entry + * point reaches the broker, the per-node record is a routing SUMMARY with no + * blob slot anywhere (§5.5), a throwing broker never escapes the seam, and + * the test reset keeps suites isolated under `bun test --parallel`. */ const frame: RelayFrame = { @@ -25,47 +28,105 @@ const frame: RelayFrame = { blob: "QUJDk5+ToQ==", }; -describe("onRelayFrame (the pre-broker stub)", () => { - it("records a routed frame per node, verbatim and blob-intact", () => { +/** A recording broker: the seam's three verbs, counted, with an optional throw. */ +function spyBroker(overrides: Partial = {}) { + const calls: { route: [string, RelayFrame][]; overCap: string[]; socketClosed: string[] } = { + route: [], + overCap: [], + socketClosed: [], + }; + const noop = (): RelayBroker["closeRelay"] => async () => false; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("seam test must not open"); + }, + routeRelayFrame: (nodeId, f) => { + calls.route.push([nodeId, f]); + }, + closeRelay: noop(), + closeForGrant: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async (ref) => { + calls.overCap.push(ref); + return false; + }, + onNodeSocketClosed: async (nodeId) => { + calls.socketClosed.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + ...overrides, + }; + return { broker, calls }; +} + +afterEach(() => { + setRelayBrokerForTests(null); + resetRelayFramesForTests(); +}); + +describe("onRelayFrame (the brokered seam, Task 8)", () => { + it("hands the broker the EXACT frame object and records a blob-free summary", () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrame("n-a", frame); - expect(relayFramesSeenFor("n-a")).toEqual({ frames: 1, last: frame }); - // The stub is not a reader: the stored frame is the SAME shape that went - // in, base64 kept as the opaque string §5.5 demands, decoded nowhere. - expect(relayFramesSeenFor("n-a")?.last.blob).toBe(frame.blob); + expect(calls.route).toHaveLength(1); + expect(calls.route[0]?.[0]).toBe("n-a"); + expect(calls.route[0]?.[1]).toBe(frame); // by reference: the blind hand-off + const seen = relayFramesSeenFor("n-a"); + expect(seen).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); + // §5.5, structurally: the record has no blob slot at all - the seam + // could not hold the bytes even if a test asked it to. + expect(Object.keys(seen ?? {})).toEqual(["frames", "lastRef", "lastDirection"]); + expect(JSON.stringify(seen)).not.toContain(frame.blob); + expect(JSON.stringify(seen)).not.toContain("QUJD"); }); - it("counts per node and keeps only the newest frame", () => { + it("counts per node and keeps only the newest routing facts", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrame("n-b", frame); - onRelayFrame("n-b", { ...frame, seq: 1, direction: "A2B" }); - expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, last: { ...frame, seq: 1, direction: "A2B" } }); + onRelayFrame("n-b", { ...frame, ref: "r-5", seq: 1, direction: "A2B" }); + expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, lastRef: "r-5", lastDirection: "A2B" }); // Per-node isolation: A's traffic is not B's record. expect(relayFramesSeenFor("n-a")).toBeUndefined(); }); - it("reset clears the registry (the --parallel fresh-registry posture)", () => { - onRelayFrame("n-c", frame); + it("a throwing broker is CONTAINED (the handler's frame chain survives)", () => { + const { broker } = spyBroker({ + routeRelayFrame: () => { + throw new Error("broker exploded"); + }, + }); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); - expect(relayFramesSeenFor("n-c")).toBeUndefined(); + expect(() => onRelayFrame("n-c", frame)).not.toThrow(); + expect(relayFramesSeenFor("n-c")).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); }); }); -describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () => { - it("counts refusals per node and keeps ONLY the ref; the blob never comes", () => { +describe("onRelayFrameOverCap (the named refusal, §5.1)", () => { + it("refuses BY NAME through the broker and records ONLY the ref", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrameOverCap("n-d", "r-1"); - expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 1, lastRef: "r-1" }); onRelayFrameOverCap("n-d", "r-2"); + await Promise.resolve(); + expect(calls.overCap).toEqual(["r-1", "r-2"]); expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 2, lastRef: "r-2" }); - // Per-node isolation, same posture as the routed stub. + // Per-node isolation, same posture as the routed summary. expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); - // The record has no blob slot at all: an over-cap frame is refused - // unread, and the stub could not store the bytes even if it wanted to. expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); }); - it("the two seams are independent, and reset clears both", () => { + it("the seams are independent, and reset clears them", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrameOverCap("n-f", "r-9"); expect(relayFramesSeenFor("n-f")).toBeUndefined(); // a refusal is not a routed frame @@ -76,3 +137,18 @@ describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () expect(relayFramesSeenFor("n-f")).toBeUndefined(); }); }); + +describe("onNodeSocketClosed (the a-dropped witness, §5.6)", () => { + it("reports the dead node to the broker and never throws", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); + onNodeSocketClosed("n-g"); + await Promise.resolve(); + expect(calls.socketClosed).toEqual(["n-g"]); + const { broker: bad } = spyBroker({ + onNodeSocketClosed: () => Promise.reject(new Error("broker rejected the witness")), + }); + setRelayBrokerForTests(bad); + expect(() => onNodeSocketClosed("n-h")).not.toThrow(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index a8cb5911f..1fc2ee210 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -48,7 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; +import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -1166,6 +1166,14 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi // touched any of them — so without this the dashboard keeps rendering // them as healthy until the viewer reconnects. announceNodePresence(nodeId); + // The relay broker's `a-dropped` witness (spec 2026-10-08 §5.6): this is + // the honest "this node's authenticated link is gone" moment - the + // broker cuts sessions where this machine was A and leaves its B-side + // sessions for the redial (or a child-exit / cap cut). The superseded + // branch below deliberately calls nothing: a replacement socket means + // the node is NOT gone. Fire-and-forget by contract (relay-frames owns + // the swallow). + onNodeSocketClosed(nodeId); logger.debug(`node ws: ${nodeId} disconnected → offline`); return; } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 48d636c56..edfd398dc 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -1,87 +1,101 @@ import type { RelayFrame } from "@internal/subshell-protocol"; +import { getRelayBroker } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; /** * The plane-side recognition seam for `relay` link frames (spec 2026-10-08 * §5.1/§5.3) - the single named point between "a decrypted, grammar-validated - * relay frame arrived on an authenticated node socket" and whoever brokers - * it. `node-ws-handler.ts` routes here from its event switch; nothing else - * calls in, and nothing here calls outward. + * relay frame arrived on an authenticated node socket" and the broker that + * pairs the sockets. `node-ws-handler.ts` routes here from its event switch + * and from its close teardown; nothing else calls in. * - * **This is deliberately a stub.** Task 8 (the plane relay broker) replaces - * the body of {@link onRelayFrame} with the real broker. What is ALREADY - * decided before a frame reaches this file, and must not be re-decided here: - * the socket is authenticated (the upgrade chain), the link is encrypted and - * version-matched at NODE_PROTOCOL_VERSION (the handshake - which is why a - * pre-18 agent never gets a relay frame routed at all), the frame is - * well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, via - * `parseNodeEvent`), the node is not held, and the socket is not superseded - * (both guards run upstream in the handler). + * What was ALREADY decided before a frame reaches this file, and is not + * re-decided here: the socket is authenticated (the upgrade chain), the link + * is encrypted and version-matched at NODE_PROTOCOL_VERSION (the handshake - + * which is why a pre-18 agent never gets a relay frame routed at all), the + * frame is well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, + * via `parseNodeEvent`), the node is not held, and the socket is not + * superseded (all guards run upstream in the handler). * - * What Task 8's broker owns, and NOTHING here does: - * - pair the two live sockets by the frame's routing `ref` against the relay - * sessions their `ssh_relay_open` commands established (§5.1); - * - forward `blob` to the peer socket BLIND - the plane reads the ref and - * nothing else (§5.5; an unopened byte copy is the whole job); - * - enforce SSH_RELAY_MAX_PER_NODE at session-open time (§5.3's loud refusal); - * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, - * A dropping, grant revoke) with the named-reason close and the - * `node.ssh_relay.close` audit. - * A second entry point, {@link onRelayFrameOverCap}, carries the refusal - * this grammar performs but cannot express in a `NodeEvent` (an over-cap - * blob) so §5.1's named-reason close has something to be ABOUT. + * The Task-8 broker (`services/ssh-relay.service.ts`) owns everything past + * this door: it pairs the two live sockets by the frame's routing `ref`, + * forwards `blob` to the peer BLIND (the plane reads the ref and nothing + * else, §5.5), enforces the quota at open, and cuts on every §5.6 event with + * the named-reason close and the `node.ssh_relay.close` audit. This module + * adds only the per-node routing SUMMARY - frame count, last ref, last + * direction - so the handler's wiring tests can see that a routed frame + * reached the broker without this seam itself holding a byte of any blob + * (§5.5: the summary's record deliberately has no blob slot). * - * The stub records what arrived per node so the wiring is testable today - * without the broker being real, and so the day it lands, the tests that - * assert "a routed frame reaches the seam" do not move. + * {@link onRelayFrameOverCap} is the second entry point: the refusal the + * grammar performed (an over-cap blob answers `parseRelayFrame` with null) + * but cannot express in a `NodeEvent`; the handler's shape probe routes the + * named case here and the broker closes the session it names with reason + * `over-cap`. The blob never comes along - the probe reads its raw length + * and nothing else. + * + * {@link onNodeSocketClosed} is the third: when an authenticated live socket + * dies, the broker cuts the sessions where that node was A (§5.6's + * `a-dropped`); a node's B-side link redialing is not a cut, so the broker + * decides by role itself and this seam just reports the witness. + * + * Routing is FIRE-AND-FORGET by contract: the handler's frame chain must not + * die because a broker send refused - the broker already handled the drop + * (its teardown IS the response to a refused send), and a throw here would + * cost the socket every frame behind this one. */ -/** What the stub holds for one node until Task 8's broker replaces it. */ -export interface RelayFrameStubRecord { +/** What the seam records for one node's routed frames: counters and routing ids, never payloads. */ +export interface RelayFrameRouteRecord { /** How many relay frames have been routed from this node. */ frames: number; - /** The most recent frame, kept verbatim - blob included, still opaque. */ - last: RelayFrame; + /** The routing ref of the most recent routed frame (an opaque id, not a secret). */ + lastRef: string; + /** The direction of the most recent routed frame. */ + lastDirection: RelayFrame["direction"]; } -/** What the over-cap stub holds for one node until Task 8 replaces it. */ -export interface RelayOverCapStubRecord { +/** What the seam holds for one node's over-cap refusals. */ +export interface RelayOverCapRecord { /** How many over-cap relay frames have been refused from this node. */ overCaps: number; /** The routing ref of the most recent refusal. NEVER the blob: an over-cap frame is refused unread (§5.1, §5.5). */ lastRef: string; } -const seenByNode = new Map(); -const overCapByNode = new Map(); +const routedByNode = new Map(); +const overCapByNode = new Map(); /** - * Route one validated relay frame from `nodeId`. Today: record it. Task 8: - * replace with the blind pair-and-forward of §5.1/§5.3/§5.5. + * Route one validated relay frame from `nodeId` to the broker. Records the + * per-node routing summary first (the handler tests' observability), then + * hands the frame object to the broker unchanged. * @param nodeId - the node whose authenticated socket produced the frame * @param frame - the grammar-validated frame (`parseRelayFrame` narrowed it) */ export function onRelayFrame(nodeId: string, frame: RelayFrame): void { - const record = seenByNode.get(nodeId); + const record = routedByNode.get(nodeId); if (record) { record.frames += 1; - record.last = frame; + record.lastRef = frame.ref; + record.lastDirection = frame.direction; } else { - seenByNode.set(nodeId, { frames: 1, last: frame }); + routedByNode.set(nodeId, { frames: 1, lastRef: frame.ref, lastDirection: frame.direction }); + } + try { + // Blind hand-off: the broker pairs by `ref` and copies `blob` to the + // peer's socket unopened (§5.5). A refused send (peer socket gone) is + // the broker's named cut to perform, not an exception to propagate. + getRelayBroker().routeRelayFrame(nodeId, frame); + } catch (err: unknown) { + logger.withError(err).warn(`relay routing for node ${nodeId} threw (contained): ${String(err)}`); } - // TODO(Task 8): pair by `frame.ref` against the relay session this - // pairing's ssh_relay_open established, forward the blob unopened to the - // peer's socket, and close-with-named-reason on every §5.6 cut. A frame - // with no brokered session behind it is refused there, with a reason. } /** - * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1). This is the - * observability half of the refusal the strict grammar already performed: - * `parseRelayFrame` answers an over-cap blob with null (so the `NodeEvent` - * union stays safe) and `node-ws-handler.ts` probes the shape with - * `relayFrameRefIfOverCap` and routes the named case here instead of the - * silent "unrecognized" drop. The blob never comes along - the probe reads - * its raw length and nothing else (§5.5). + * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1): the broker + * closes the session named by `ref` with reason `over-cap`, to both sides, + * audited. An unbrokered ref has no session to close and costs a log line. * @param nodeId - the node whose authenticated socket produced the frame * @param ref - the routing ref the well-formed candidate carried */ @@ -93,35 +107,49 @@ export function onRelayFrameOverCap(nodeId: string, ref: string): void { } else { overCapByNode.set(nodeId, { overCaps: 1, lastRef: ref }); } - // TODO(Task 8): this is where §5.1's named-reason close lands: look the - // session up by `ref`, cut BOTH ends with ssh_relay_close reason - // "over-cap" (SSH_RELAY_CLOSE_REASONS), and audit `node.ssh_relay.close`. - // Until the broker exists, the record only proves the refusal is visible. + void getRelayBroker() + .refuseOverCap(ref) + .catch((err: unknown) => logger.withError(err).warn(`relay over-cap close for ${ref} threw: ${String(err)}`)); +} + +/** + * An authenticated LIVE node socket died. Tell the broker: sessions where + * this node was A are cut with `a-dropped` (§5.6); its B-side sessions are + * deliberately not (a B link that redials keeps its registry, and B's real + * end is child-exit or a cap). A superseded socket's close never calls this + * (the handler's per-connection identity owns the distinction) - the newer + * socket's node is not gone. + * @param nodeId - the node whose live connection just went away + */ +export function onNodeSocketClosed(nodeId: string): void { + void getRelayBroker() + .onNodeSocketClosed(nodeId) + .catch((err: unknown) => logger.withError(err).warn(`relay socket-drop cut for ${nodeId} threw: ${String(err)}`)); } /** - * What the stub recorded for one node (the frame count and the last frame, - * verbatim). - * @internal test-only; Task 8's broker replaces this module's surface + * What the seam recorded for one node (the routed-frame summary: count, last + * ref, last direction - never a blob). + * @internal test-only: the handler's routing tests */ -export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undefined { - return seenByNode.get(nodeId); +export function relayFramesSeenFor(nodeId: string): RelayFrameRouteRecord | undefined { + return routedByNode.get(nodeId); } /** - * What the over-cap stub recorded for one node (the refusal count and the - * last refused ref; never a blob). - * @internal test-only; Task 8's broker replaces this module's surface + * What the seam recorded for one node's over-cap refusals (count and last + * ref; never a blob). + * @internal test-only */ -export function relayOverCapsSeenFor(nodeId: string): RelayOverCapStubRecord | undefined { +export function relayOverCapsSeenFor(nodeId: string): RelayOverCapRecord | undefined { return overCapByNode.get(nodeId); } /** - * Drop every stub record (routed frames and over-cap refusals alike). + * Drop every seam record (routed summaries and over-cap refusals alike). * @internal test-only; per-file isolation under `bun test --parallel` */ export function resetRelayFramesForTests(): void { - seenByNode.clear(); + routedByNode.clear(); overCapByNode.clear(); } diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 1c6b7fa51..8e706a090 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -60,6 +60,7 @@ import { getNotifyService, type NotifyKind } from "@/services/notify.service.js" import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; import { logger } from "@/utils/logger.js"; @@ -568,6 +569,9 @@ export class SubshellManagerService { // no-ops every non-local / non-ssh create; `targetNode` keeps a remote // pane's dir on ITS node's disk untouched. sweepLocalSshDir({ id, nodeId: targetNode, ssh: ssh ? JSON.stringify(ssh.snapshot) : null }); + // A relay session (M2) bound to this pane must die with the create too: + // the B proxy socket would otherwise outlive the row it was named for. + closeRelayForPaneExit(id); await this.#subshells.markTerminated(id, new Date().toISOString()); await this.#revokeTokenOrUnlink(id); publishLive({ kind: "subshell.changed", id }); @@ -1054,6 +1058,7 @@ export class SubshellManagerService { async terminateForMaintenance(row: SubshellTable): Promise { const stopped = await this.terminateSubshell(row.userId, row.id); sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // relay sessions are per-pane and die with the pane (M2 §5.6 child-exit) if (stopped) void this.#notify(row.id, "maintenance"); } @@ -1111,6 +1116,7 @@ export class SubshellManagerService { const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // the pane stops existing in every flavor: any brokered relay ends here // And the generated MCP config (no secrets, but nothing to leave behind). try { unlinkSync(subshellMcpConfigPath(id)); @@ -1445,6 +1451,7 @@ export class SubshellManagerService { // fixture-shaped belt that keeps "whichever hand first notices // removes the dir" true for EVERY alive→dead stamp, not most. sweepLocalSshDir(row); + closeRelayForPaneExit(row.id); // the same belt for relay sessions } } continue; @@ -1640,6 +1647,10 @@ export class SubshellManagerService { // reconcile reaches this same line whenever the process is gone, so this is // the deterministic sweep. Guarded on `!restarted` (never clobber a live // re-render) and by the helper itself (local ∧ ssh; every other row no-ops). + // The relay cut runs whatever the restart ladder decided: an agent-capable + // ssh pane never auto-restarts (M1 decision 7), and a restart of anything + // else must not strand the relay session of the process that just died. + closeRelayForPaneExit(fresh.id); if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index b345ccae8..3bc22c3dd 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -40,6 +40,7 @@ import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS, @@ -739,6 +740,7 @@ export class SubshellsService extends BaseService { return null; }), ); + closeRelayForPaneExit(id); // the pane's own report is the §5.6 child-exit signal await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } @@ -1477,6 +1479,7 @@ export class SubshellsService extends BaseService { // it lands, stays the second sure thing (its sweep is idempotent). The // helper's guard no-ops this for every non-local-ssh row. sweepLocalSshDir(row); + sweepRelayForPane(row); // terminate kills the ssh child too: its relay dies with it return { ok: true }; } From ad4b52feffef2f6e38321cba9227b70bb555d317 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:52:40 -0700 Subject: [PATCH 105/170] feat(ssh-relay): node relay executors, deliver-or-throw pump, dispatch arms Acceptance (a) end to end on the machine: ssh_relay_open / ssh_relay_close join the dispatch switch. Open consults the ssh-enabled mirror first and branches on the command's own role - A drives openARelaySession OFF the command chain (the numbering probe can worst-case run two ~10 s agent round trips; the ack answers pending:true immediately and a late MOVED-pin refusal becomes a log line, never a queue that parks input), B awaits openBRelaySession (local and fast, answers socketPath) with the pane from the COMMAND (grammar (b), no launch correlation). Close is ungated: ending a session is teardown, not an SSH act, and reaches RelaySessions.close with the named reason. ctx.relay carries the daemon's registry plus relay-send.ts, whose pump THROWS on every non-delivery (no socket, unestablished link, oversize frame, failing write) - the node-side mirror of the plane's contract and the phantom-hang defense. A context built without the plumbing refuses loudly. --- .../src/__tests__/commands-ssh-relay.test.ts | 238 ++++++++++++++++++ .../agent/src/__tests__/relay-send.test.ts | 74 ++++++ apps/node/agent/src/commands/context.ts | 21 ++ apps/node/agent/src/commands/index.ts | 13 +- .../node/agent/src/commands/ssh-relay-exec.ts | 127 ++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 17 +- apps/node/agent/src/daemon.ts | 11 + apps/node/agent/src/relay-send.ts | 53 ++++ 8 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-relay.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-send.test.ts create mode 100644 apps/node/agent/src/commands/ssh-relay-exec.ts create mode 100644 apps/node/agent/src/relay-send.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts new file mode 100644 index 000000000..522e20d5c --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, RelayFrame, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { type ARelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; +import { execSshRelayClose, execSshRelayOpen, type RelayOpenSeams } from "../commands/ssh-relay-exec.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The relay executor arms (spec 2026-10-08 §5.1, Task 8 acceptance (a) and + * the probe-off-pump half of the T7 handoff). Posture copied from + * commands-ssh.test.ts: real temp dirs, the gate mirror written by its own + * writer, refusals asserted by `ok:false` + exact message. + * + * What THIS file owns that nothing else does: + * - the dispatch arms exist: `ssh_relay_open` and `ssh_relay_close` answer + * from the executor, never the switch's `unsupported`. + * - the GATE (mirror first) on open; and that CLOSE is not gated - teardown + * must land even while a machine is switched off. + * - acceptance (a) in both role branches: A drives the openARelaySession + * seam, B drives openBRelaySession with the COMMAND's paneId (grammar (b) + * end to end), B's answer carries the socket path the plane will byte-check. + * - the T7 obligation: the A branch does not await the numbering probe on + * the shared command chain - a stuck agent's openARelaySession must not + * serialize anything behind it (the never-resolving seam is the proof), + * and its late named refusal lands in the log, never as an uncaught throw. + * - the ctx.relay plumbing check (a daemon built without it refuses loudly). + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const PANE = "11111111-2222-4333-8444-555555555555"; + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-relay-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, opts: { gateOn?: boolean; nodeId?: string; relay?: boolean } = {}) { + const data = dataDir(tag, opts.gateOn ?? true); + const sends: RelayFrame[] = []; + const relay = + opts.relay === false + ? undefined + : { sessions: new RelaySessions(), sendRelayFrame: (f: RelayFrame) => sends.push(f) }; + const ctx = { config: { dataDir: data, nodeId: opts.nodeId ?? "node-self" }, relay } as unknown as CommandContext; + return { ctx, sends, relay }; +} + +function openCmd(over: Partial = {}): SshRelayOpenCommand { + return { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "node-self", + bNodeId: "node-peer", + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AX","y":"AY"}', + peerEncryptPublicKey: Buffer.from('{"kty":"EC","crv":"P-256","x":"BX","y":"BY"}', "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: ["SHA256:AAAA"], + lifetimeMs: 30_000, + paneId: PANE, + ...over, + }; +} + +describe("the dispatch arms exist (acceptance (a))", () => { + it("ssh_relay_open reaches the executor, not the switch's unsupported", async () => { + const { ctx } = makeCtx("dispatch"); + // Role B naming a different machine as B: the executor's own first guard + // answers, which is the proof the arm exists (an unwired switch says + // `unsupported`, never this sentence). + const res = await dispatchCommand(ctx, openCmd({ role: "B", aNodeId: "other", bNodeId: "not-me" })); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("not the connecting side"); + }); + + it("ssh_relay_close reaches RelaySessions.close and an unknown ref is an idempotent ok", async () => { + const { ctx, relay } = makeCtx("dispatch-close"); + const res = await dispatchCommand(ctx, { type: "ssh_relay_close", ref: "r-none", reason: "child-exit" }); + expect(res).toEqual({ ok: true, data: { ref: "r-none", closed: false } }); + const closed: string[] = []; + relay?.sessions.register("r-live", { onRelayFrame: () => {}, close: (reason) => closed.push(reason) }); + const res2 = await dispatchCommand(ctx, { + type: "ssh_relay_close", + ref: "r-live", + reason: "a-dropped", + } as NodeCommandBody); + expect(res2).toEqual({ ok: true, data: { ref: "r-live", closed: true } }); + expect(closed).toEqual(["a-dropped"]); // §5.1: the named reason reaches the endpoint + }); + + it("a daemon context WITHOUT the relay plumbing refuses, loudly", async () => { + const { ctx } = makeCtx("no-plumbing", { relay: false }); + const res = await dispatchCommand(ctx, openCmd()); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("relay plumbing"); + }); +}); + +describe("the gate (mirror first, spec §4.3)", () => { + it("an off mirror refuses ssh_relay_open before any pairing mechanics", async () => { + const { ctx } = makeCtx("gate-off", { gateOn: false }); + const res = await execSshRelayOpen(ctx, openCmd({ role: "B" })); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + + it("ssh_relay_close is NOT gated: teardown answers even while the machine is switched off", async () => { + const { ctx } = makeCtx("gate-off-close", { gateOn: false }); + const res = await execSshRelayClose(ctx, { type: "ssh_relay_close", ref: "r-x", reason: "grant-revoked" }); + expect(res).toEqual({ ok: true, data: { ref: "r-x", closed: false } }); + }); +}); + +describe("the A branch: the probe runs OFF the command chain (T7 handoff)", () => { + it("acks immediately while openARelaySession is still running", async () => { + const { ctx } = makeCtx("a-slow"); + let started: (() => void) | undefined; + const never = new Promise<{ relayId: string }>((resolve) => { + started = () => resolve({ relayId: "relay-1" }); + }); + const seams: RelayOpenSeams = { + openA: async () => never, + openB: async () => { + throw new Error("must not run"); + }, + }; + const exec = execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + // The proof is timing: if the executor awaited the probe, this race would + // resolve with the timeout instead of the ack. + const winner = await Promise.race([ + exec.then((r) => ({ kind: "ack" as const, r })), + new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 25)), + ]); + expect(winner.kind).toBe("ack"); + if (winner.kind === "ack") { + expect(winner.r).toEqual({ ok: true, data: { role: "A", relayId: "relay-1", pending: true } }); + } + started?.(); // let the detached promise settle so nothing leaks a rejection + await never; + }); + + it("a late named refusal from the detached open lands in the log and never throws past the arm", async () => { + const { ctx } = makeCtx("a-refuse"); + const lines: string[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("machine pin for node-peer has MOVED"); + }, + openB: async () => { + throw new Error("must not run"); + }, + log: (line) => lines.push(line), + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + expect(res.ok).toBe(true); // the ack was already earned; the refusal is a LOG fact (§4.5: re-pair is an operator act at the machine) + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(lines.join("\n")).toContain("MOVED"); + expect(lines.join("\n")).toContain("r-1"); + }); +}); + +describe("the B branch: the pane rides the command, the answer carries the socket", () => { + it("openBRelaySession gets the COMMAND's paneId and its socket path answers back", async () => { + const { ctx, relay } = makeCtx("b-ok"); + const seenArgs: ARelaySessionArgs[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + seenArgs.push(args as unknown as ARelaySessionArgs); + relay?.sessions.register(args.cmd.ref, { onRelayFrame: () => {}, close: () => {} }); + return { socketPath: `${args.dataDir}/ssh/${args.paneId}/agent.sock` }; + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(seenArgs).toHaveLength(1); + expect(seenArgs[0]?.paneId).toBe(PANE); // grammar (b): the pane arrives on the WIRE, not a side channel + expect(seenArgs[0]?.selfNodeId).toBe("node-self"); + expect(seenArgs[0]?.cmd.role).toBe("B"); + expect(res).toEqual({ + ok: true, + data: { role: "B", relayId: "relay-1", socketPath: `${ctx.config.dataDir}/${"ssh"}/${PANE}/agent.sock` }, + }); + }); + + it("a throwing open (moved pin, bad keys, owned ref) answers ok:false with the named refusal", async () => { + const { ctx } = makeCtx("b-refuse"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async () => { + throw new Error("relay open refused: machine pin for node-peer has MOVED (re-pair per §4.5)"); + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("MOVED"); + }); +}); + +describe("the daemon seam wiring", () => { + it("the B branch's sendRelayFrame is the ctx pump (deliver-or-throw, forwarded unchanged)", async () => { + const { ctx, sends } = makeCtx("b-pump"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + // The proxy hands the pump a frame; whatever it does is the pump's + // contract - here the ctx recorder proves identity of the seam. + args.sendRelayFrame({ type: "relay", ref: args.cmd.ref, seq: 0, direction: "B2A", blob: "QUJD" }); + return { socketPath: "/x/agent.sock" }; + }, + }; + await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(sends).toHaveLength(1); + expect(sends[0]?.ref).toBe("r-1"); + }); +}); diff --git a/apps/node/agent/src/__tests__/relay-send.test.ts b/apps/node/agent/src/__tests__/relay-send.test.ts new file mode 100644 index 000000000..a58df881e --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-send.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { NODE_MAX_FRAME_BYTES } from "@internal/subshell-protocol"; +import { createDaemonRelaySend, type RelaySendState } from "../relay-send.js"; + +/** + * The daemon's per-socket relay pump (Task 8 acceptance (c), the node-side + * half of the deliver-or-throw contract): the frame that leaves this function + * either hit the CURRENT socket through the ESTABLISHED link or the call + * THREW. The proxy and the responder both act on the throw (fail the request, + * consume no seq) - a silent return is what parked phantom replies in the + * T6 review, so every drop branch here is a named raise. + */ + +const frame: RelayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + +/** A negotiator-shaped fake link; `up: false` mirrors the not-yet-established window. */ +function fakeLink(up = true): RelaySendState["link"] { + return { + established: () => up, + session: () => (up ? { sealFrame: (text: string) => new TextEncoder().encode(`SEALED:${text}`) } : undefined), + }; +} + +describe("createDaemonRelaySend", () => { + it("seals the JSON frame and writes a Buffer onto the live link", () => { + const sealed: (string | Buffer)[] = []; + const relaySend = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: fakeLink(), + })); + relaySend(frame); + expect(sealed).toHaveLength(1); + const payload = sealed[0]; + expect(Buffer.isBuffer(payload)).toBe(true); // never a bare Uint8Array (the Bun framing fact) + expect((payload as Buffer).toString("utf8")).toBe(`SEALED:${JSON.stringify(frame)}`); + }); + + it("THROWS when there is no socket (between connections), when the link is down, and when the write fails", () => { + // Between connections: the plane re-pumps on the fresh socket, but THIS + // caller must know its frame did not leave. + const noWs = createDaemonRelaySend(() => ({ ws: undefined, link: undefined })); + expect(() => noWs(frame)).toThrow(/no live socket/); + // The open→established window (or after the close): a protocol-18 socket + // has no plaintext path, so a relay frame simply cannot leave. + const noLink = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: fakeLink(false) })); + expect(() => noLink(frame)).toThrow(/link/); + const noLinkAtAll = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: undefined })); + expect(() => noLinkAtAll(frame)).toThrow(/link/); + // A throwing write propagates as the named refusal, never silence. + const thrower = createDaemonRelaySend(() => ({ + ws: { + send: () => { + throw new Error("socket write failed"); + }, + }, + link: fakeLink(), + })); + expect(() => thrower(frame)).toThrow(/socket write failed/); + }); + + it("refuses an oversize frame before touching the socket (the cap is law both directions)", () => { + const sealed: (string | Buffer)[] = []; + const giant = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: { + established: () => true, + session: () => ({ sealFrame: (t: string) => new TextEncoder().encode(t) }), + }, + })); + expect(() => giant({ ...frame, blob: "A".repeat(NODE_MAX_FRAME_BYTES) })).toThrow(/exceeds/); + expect(sealed).toHaveLength(0); // no seal, no ratchet step spent on a doomed frame + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index a3de26868..33e329a2e 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -6,11 +6,13 @@ import type { NodeMaintenanceWire, NodeRuntimeReport, NodeSshEnabledWire, + RelayFrame, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; import type { ServiceDeps } from "../service.js"; import type { SubshellMetaStore } from "../subshell-meta.js"; import type { NodeBinaryDeps } from "../update.js"; +import type { RelaySessions } from "./ssh-relay.js"; /** * The command-executor seam (spec 2026-08-31 §7): everything an executor may @@ -109,6 +111,25 @@ export interface CommandContext { binaryDeps?: NodeBinaryDeps; /** Outbound event seam (inventory events now; tail output/exit events later). */ ws: CommandWs; + /** + * The sealed-agent-relay plumbing (spec 2026-10-08 §5.1, Task 8): the + * daemon's routing-ref registry plus the per-socket relay pump, handed to + * the `ssh_relay_open` branches and their sessions. OPTIONAL by type so + * every pre-relay context fake keeps compiling; the real daemon always + * provides it, and the relay arms refuse loudly when a context does not. + */ + relay?: { + /** The per-daemon ref registry; sessions outlive the socket they opened on. */ + sessions: RelaySessions; + /** + * Hand one relay frame to the CURRENT established link. DELIVER-OR-THROW + * (acceptance (c), mirroring the endpoints' T6/T7 contract): a frame that + * cannot leave RAISES - the proxy fails its request and the responder + * consumes no seq, because a silent return parks a phantom reply whose + * answer never comes. + */ + sendRelayFrame(frame: RelayFrame): void; + }; /** * The panes supervised for natural death, keyed by subshellId → * {@link WatcherRegistration} (socket + registration token + unreachable diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 1dbeb746e..82df78cf6 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -30,6 +30,7 @@ import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; import { execSshRegisterIdentity } from "./ssh-identity.js"; +import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -54,9 +55,13 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- * to-machine write), the two SSH read arms `ssh_discover_aliases` and * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the - * local gate mirror before any lookup or spawn), and the M2 §4.3 bootstrap + * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing - * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act). + * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act), + * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` + * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the + * T6/T7 pairing branches by the command's own role; close is ungated teardown, + * because ending a session is not an SSH act). * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. @@ -140,6 +145,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshResolveConfig(ctx, cmd); case "ssh_register_identity": return await execSshRegisterIdentity(ctx); + case "ssh_relay_open": + return await execSshRelayOpen(ctx, cmd); + case "ssh_relay_close": + return execSshRelayClose(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-relay-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts new file mode 100644 index 000000000..6df0a3440 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -0,0 +1,127 @@ +import type { JsonValue, SshRelayCloseCommand, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { log } from "../log.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { type ARelaySessionArgs, type BRelaySessionArgs, openARelaySession, openBRelaySession } from "./ssh-relay.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two relay command arms (spec 2026-10-08 §5.1, Task 8 acceptance (a)): + * the daemon-side executors the plane's signed `ssh_relay_open` / + * `ssh_relay_close` land on. The mechanics they drive are T6/T7's reviewed + * code ({@link openBRelaySession} / {@link openARelaySession} in + * `ssh-relay.ts`); this file owns the DELIVERY decisions: + * + * - **Gate first, open only.** Every SSH arm consults this machine's + * `ssh_enabled` mirror before pairing mechanics (spec §4.3), and this one + * refuses {@link SSH_GATE_REFUSAL} exactly like the discovery arms. The + * CLOSE is deliberately NOT gated: a switch-off mid-session must still be + * able to end the session - teardown is not an SSH act (the + * `set_ssh_enabled` precedent), and refusing it would strand a bound proxy + * socket on a machine that has decided to stop serving SSH. + * - **The A branch answers immediately, the probe runs off the chain** + * (Task 7's handoff, binding): `openARelaySession` awaits the numbering + * probe, which can worst-case run two ~10 s agent round trips against a + * hung agent. The daemon's executor chain serializes every command, so + * awaiting it here would park `input`, `capture`, and every unrelated + * command behind someone else's agent. The ack says what it means - + * `pending: true`, the pairing is DELIVERED, not yet PROBED. A named + * refusal that arrives after the ack (a MOVED pin, an owned ref) is a log + * fact and a dead session the plane's cuts end; §4.5 makes re-pair an + * operator act at the machine, and the answer's absence (never a frame + * from the responder) is what B's side reads as the refusal. + * - **The B branch answers with its socket path.** Binding is local and fast, + * so it runs ON the chain and answers `socketPath` - which the plane + * re-derives from its own facts and byte-checks (the launch-config + * doctrine restated for the agent socket). The pane comes from the COMMAND + * (`cmd.paneId`, grammar (b)), never a launch-side correlation. + * - **The pump is the daemon's** (`ctx.relay.sendRelayFrame`), forwarded to + * both branches unchanged: its deliver-or-throw contract (acceptance (c)) + * is the daemon's `relay-send.ts`, and a context built without the relay + * plumbing refuses loudly instead of half-opening. + * + * The seams (`{@link RelayOpenSeams}`) exist for the probe-off-chain and + * refusal-observability tests; production omits them and gets the real pair. + */ + +/** Injectable pairing seams (test-only; production is the T6/T7 pair). */ +export interface RelayOpenSeams { + openA(args: ARelaySessionArgs): Promise<{ relayId: string }>; + openB(args: BRelaySessionArgs): Promise<{ socketPath: string }>; + /** Line sink for the detached A-branch's outcome (defaults to the agent log). */ + log?: (line: string) => void; +} + +/** Does this machine permit SSH right now? (the one question every SSH arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_relay_open`: branch on the command's own `role`. */ +export async function execSshRelayOpen( + ctx: CommandContext, + cmd: SshRelayOpenCommand, + seams?: RelayOpenSeams, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const relay = ctx.relay; + if (!relay) { + // Not a user-facing condition: a daemon that runs commands without the + // relay plumbing would half-open sessions with no pump, so this is the + // impossible-state guard, answered loudly. + return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + } + const say = seams?.log ?? ((line: string): void => log(line)); + if (cmd.role === "A") { + const openA = seams?.openA ?? openARelaySession; + // OFF the chain, deliberately (the module doc names the cost). The guard + // refusals inside openARelaySession (role/self/pin/ref) become log lines + // here, never a second answer to the plane - the ack already left. + void openA({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }).then( + (r) => say(`relay session ${cmd.ref}: A side live (relay ${r.relayId})`), + (err: unknown) => say(`relay session ${cmd.ref}: A open refused after ack: ${String(err)}`), + ); + // The seam cast is the JSON-safety posture the sibling executors use + // (`node-results.ts` owns the answer contract; a relay ack is a plain + // object by the plan's interface note). + return { ok: true, data: { role: "A", relayId: cmd.relayId, pending: true } as unknown as JsonValue }; + } + const openB = seams?.openB ?? openBRelaySession; + try { + const { socketPath } = await openB({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + paneId: cmd.paneId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }); + return { ok: true, data: { role: "B", relayId: cmd.relayId, socketPath } as unknown as JsonValue }; + } catch (err: unknown) { + // Named refusals (MOVED pin, malformed peer key, owned ref): the plane's + // openRelay turns the rejection into its own named refusal. The string + // travels the RESULT channel only - openBRelaySession's refusals name + // ids and the §4.5 remedy, never key material. + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Execute `ssh_relay_close`: stop the session named by the ref and hand the + * named reason to the local owner (§5.6's endpoint line). Ungated by design + * (module doc). An unknown ref answers `closed: false` - a close racing the + * plane's own cut is routine, and a false answer to a redundant close is the + * honest record, not an error. + */ +export function execSshRelayClose(ctx: CommandContext, cmd: SshRelayCloseCommand): CommandResult { + const relay = ctx.relay; + if (!relay) return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + const closed = relay.sessions.close(cmd.ref, cmd.reason); + return { ok: true, data: { ref: cmd.ref, closed } as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 02273ba02..1330e1df2 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -28,15 +28,12 @@ import { startRelayResponder } from "../relay-responder.js"; * pin; a pin that moved is a hard block naming the peer and §4.5's recovery, * never a silent overwrite) and only then binds the pane's agent socket. * - * WHAT THE WIRE DOES NOT CARRY YET (handed to Task 8): the frozen - * `ssh_relay_open` grammar names no pane. §5.2 puts the socket in the PANE's - * ssh dir (`/ssh//agent.sock`), so {@link openBRelaySession} - * takes the `paneId` as an explicit executor input - whoever delivers the - * command must supply it (a grammar addition or a launch-side correlation; - * the proxy refuses an id outside the path-composition shape either way, so - * guessing is structurally impossible). No executor arm is registered on this - * pass: `ssh_relay_open`/`ssh_relay_close` keep answering `unsupported` from - * the dispatch switch until Task 8's delivery wires them. + * THE TASK-8 WIRING (landed): the `ssh_relay_open` grammar carries the pane + * (`cmd.paneId`, acceptance (b)), so {@link openBRelaySession} takes it from + * the command it serves and binds §5.2's `/ssh//agent.sock`; + * the executor arms live in `ssh-relay-exec.ts`, registered on the dispatch + * switch, and the pump they hand the branches is the daemon's + * deliver-or-throw `relay-send.ts` (acceptance (c)). */ /** One local owner of a relay session's inbound frames (a B proxy, or Task 7's A responder). */ @@ -141,7 +138,7 @@ export interface BRelaySessionArgs { dataDir: string; /** This machine's node id (config.nodeId) - the open-as principal. */ selfNodeId: string; - /** The pane whose ssh connects through the socket (see the module header's Task-8 note). */ + /** The pane whose ssh connects through the socket: the command's `paneId` (grammar (b)). */ paneId: string; /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ cmd: SshRelayOpenCommand; diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 02aa1cf7b..a7f14c663 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -40,6 +40,7 @@ import { binaryPayload, createLinkNegotiator, type LinkNegotiator, type LinkNego import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; +import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; @@ -503,6 +504,16 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom tails: new Map(), uploads: new Map(), runtime, + // The relay plumbing (spec 2026-10-08 §5.1, Task 8): the per-daemon + // registry (sessions survive reconnects - the plane re-pumps on the fresh + // socket) plus the DELIVER-OR-THROW pump. The pump reads the CURRENT + // socket and link through a getter, exactly like the stale-socket guard in + // `send` above: a frame sent after this socket died targets the new link + // if one exists, and throws if none does (acceptance (c)). + relay: { + sessions: relaySessions, + sendRelayFrame: createDaemonRelaySend(() => ({ ws: currentWs, link })), + }, requestRestart: () => { // Deferred, because the daemon is the only sender of `result`: the // executor returns `{ ok: true }`, the frame leaves on this turn, and diff --git a/apps/node/agent/src/relay-send.ts b/apps/node/agent/src/relay-send.ts new file mode 100644 index 000000000..2edc7ac2d --- /dev/null +++ b/apps/node/agent/src/relay-send.ts @@ -0,0 +1,53 @@ +import { NODE_MAX_FRAME_BYTES, type RelayFrame } from "@internal/subshell-protocol"; +import { binaryPayload } from "./link-crypto.js"; + +/** + * The daemon's relay pump, factored out of `daemon.ts` so the + * deliver-or-throw contract is testable without a socket (Task 8 acceptance + * (c), the node-side mirror of the plane's `sendRelayFrameOverNodeSocket`). + * + * `send` in the daemon LOGS a dropped event; a relay frame may not be dropped + * quietly. The proxy parks a phantom request on a send it believes delivered + * (T6 review, restated in T7's handoff), and the responder consumes a signed + * seq for a reply that never left - so every non-delivery here RAISES and the + * endpoints act on the throw (fail the request, consume no seq, stall to the + * plane's timers). State is read through a getter because the pump is handed + * to sessions that OUTLIVE a socket: the registry survives reconnects and the + * plane re-pumps on the fresh link, so the frame targets whatever socket is + * CURRENT at send time, never the socket the open rode in on. + */ + +/** The negotiator slice the pump reads: the per-CONNECTION link, possibly mid-handshake. */ +export interface RelaySendLink { + established(): boolean; + session(): { sealFrame(text: string): Uint8Array } | undefined; +} + +/** The live facts at send time: the CURRENT socket (or none) and its link (or none). */ +export interface RelaySendState { + ws?: { send(data: string | Buffer): unknown } | undefined; + link?: RelaySendLink | undefined; +} + +/** + * Build the pump over a state getter. Throws {@link Error} with a named + * reason on: no live socket (between connections), no established link (a + * protocol-18 socket has no plaintext path at all), an oversize envelope, or + * a failing write. On success the socket received a Buffer of sealed bytes. + */ +export function createDaemonRelaySend(getState: () => RelaySendState): (frame: RelayFrame) => void { + return (frame: RelayFrame): void => { + const { ws, link } = getState(); + if (!ws) throw new Error("relay send refused: no live socket"); + const session = link?.established() ? link.session() : undefined; + if (!session) { + throw new Error("relay send refused: the encrypted link is not established"); + } + const payload = JSON.stringify(frame); + const size = Buffer.byteLength(payload); + if (size > NODE_MAX_FRAME_BYTES) { + throw new Error(`relay send refused: frame ${size}B exceeds the ${NODE_MAX_FRAME_BYTES}B link cap`); + } + ws.send(binaryPayload(session.sealFrame(payload))); + }; +} From 182d23f25dd6612ed610a651352a15745e49c8b7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:54:19 -0700 Subject: [PATCH 106/170] feat(ssh-relay): bound the node relay registry to SSH_RELAY_MAX_PER_NODE Acceptance (g)'s defense-in-depth half: the plane's broker counts live sessions authoritatively at open; RelaySessions.register now also refuses a 9th concurrent ref on this machine with a named throw (the dup-ref false path stands - a routing collision is still the caller's unwind, a full registry is the machine saying no). The responder test fixtures carry the grammar's new paneId; the broker's scheduler seam carries handles as an opaque unknown so any fake timer fits. --- .../src/__tests__/commands-ssh-relay.test.ts | 32 ++++++++++++++++--- .../src/__tests__/relay-responder.test.ts | 4 +++ apps/node/agent/src/commands/ssh-relay.ts | 19 ++++++++++- .../__tests__/ssh-relay.service.test.ts | 4 +-- .../api/src/services/ssh-relay.service.ts | 4 +-- 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index 522e20d5c..b4013a5c1 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -2,10 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { NodeCommandBody, RelayFrame, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { + type NodeCommandBody, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; -import { type ARelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; +import { type BRelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; import { execSshRelayClose, execSshRelayOpen, type RelayOpenSeams } from "../commands/ssh-relay-exec.js"; import { writeSshEnabled } from "../ssh-enabled.js"; @@ -179,13 +184,13 @@ describe("the A branch: the probe runs OFF the command chain (T7 handoff)", () = describe("the B branch: the pane rides the command, the answer carries the socket", () => { it("openBRelaySession gets the COMMAND's paneId and its socket path answers back", async () => { const { ctx, relay } = makeCtx("b-ok"); - const seenArgs: ARelaySessionArgs[] = []; + const seenArgs: BRelaySessionArgs[] = []; const seams: RelayOpenSeams = { openA: async () => { throw new Error("must not run"); }, openB: async (args) => { - seenArgs.push(args as unknown as ARelaySessionArgs); + seenArgs.push(args); relay?.sessions.register(args.cmd.ref, { onRelayFrame: () => {}, close: () => {} }); return { socketPath: `${args.dataDir}/ssh/${args.paneId}/agent.sock` }; }, @@ -217,6 +222,25 @@ describe("the B branch: the pane rides the command, the answer carries the socke }); }); +describe("RelaySessions concurrency bound (acceptance (g), node-side defense)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("rejects a 9th concurrent session with a named refusal and keeps the dup-ref false", () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`r-${i}`, handler)).toBe(true); + // The existing dup-ref contract is UNCHANGED: false, no throw. + expect(relay.register("r-0", handler)).toBe(false); + // A NEW ref over the cap is a loud named refusal (defense-in-depth; the + // plane's quota is authoritative, this is the machine refusing to hold a + // 9th proxy anyway). + expect(() => relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toThrow(/max 8/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + // Releasing a ref frees a slot: the next register lands. + relay.close("r-3", "lifetime-expiry"); + expect(relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toBe(true); + }); +}); + describe("the daemon seam wiring", () => { it("the B branch's sendRelayFrame is the ctx pump (deliver-or-throw, forwarded unchanged)", async () => { const { ctx, sends } = makeCtx("b-pump"); diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 8419edca9..0912cec0d 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -337,6 +337,7 @@ async function openFixture( grantId: "grant-1", fingerprints: [fp(KEY_IN)], lifetimeMs: 30_000, + paneId: "pane-a-test", // grammar (b): the command names the pane ...overrides, }; await openARelaySession({ @@ -1032,6 +1033,7 @@ test("openARelaySession refuses a command that does not name this machine in its grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }; // role B: the A branch is not for this command. await expect( @@ -1101,6 +1103,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }; // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. await expect( @@ -1163,6 +1166,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }, sendRelayFrame: () => {}, resolveAgentSocket: () => null, diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 1330e1df2..e2c30ee8d 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -1,5 +1,10 @@ import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; -import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { + bytesOfJwk, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; @@ -70,9 +75,21 @@ export class RelaySessions { * Take ownership of a ref. False when the ref is ALREADY owned - two live * sessions on one routing ref is a broker bug, and the second opener's * resources belong to ITSELF to release (its caller sees the false). + * Throws a named refusal when this machine is already holding + * {@link SSH_RELAY_MAX_PER_NODE} sessions and `ref` is NEW: the plane's + * broker enforces the same cap authoritatively at open, and this is the + * node refusing to hold a 9th proxy socket off a miscounting broker + * (defense-in-depth, Task 8 (g)). The throw path and the dup-ref false + * path are deliberately different: an owned ref is a routing collision + * the CALLER unwinds, a full registry is the machine saying no. */ register(ref: string, handler: RelaySessionHandler): boolean { if (this.#byRef.has(ref)) return false; + if (this.#byRef.size >= SSH_RELAY_MAX_PER_NODE) { + throw new Error( + `relay registry full: ${this.#byRef.size} live sessions on this machine (max ${SSH_RELAY_MAX_PER_NODE})`, + ); + } this.#byRef.set(ref, handler); return true; } diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index 1f8c0f81b..c025f0269 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -239,7 +239,7 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { ack: (_nodeId, cmd) => cmd.type === "ssh_relay_open" && cmd.role === "B" ? { role: "B", relayId: cmd.relayId, socketPath: "/attacker/agent.sock" } - : { role: "A", relayId: cmd.relayId }, + : { role: "A" }, }); await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); // The session was torn down and BOTH sides were told, under a named cut. @@ -441,7 +441,7 @@ describe("teardown (spec §5.6, Task 8 (f))", () => { const h = makeHarness({ ack: (_nodeId, cmd) => { if (cmd.type === "ssh_relay_open" && cmd.role === "B") throw new Error("machine pin for a has MOVED"); - return { role: "A", relayId: cmd.relayId }; + return { role: "A", relayId: cmd.type === "ssh_relay_open" ? cmd.relayId : "" }; }, }); await expect(h.open()).rejects.toMatchObject({ code: "handshake" }); diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts index 67204800a..e22a15a63 100644 --- a/apps/server/api/src/services/ssh-relay.service.ts +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -184,8 +184,8 @@ export interface OpenRelayResult { socketPath: string; } -/** Opaque timer handle; the scheduler seam's own identity. */ -export type TimerHandle = { readonly brand?: never }; +/** Opaque timer handle: the scheduler seam's own identity, carried untouched to `cancel`. */ +export type TimerHandle = unknown; /** The broker's world, every seam injectable (production default at the bottom). */ export interface RelayBrokerDeps { From f579b6a74c28adbdf9fe3ce248d835e22fb0ffa0 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:07:20 -0700 Subject: [PATCH 107/170] fix(ssh-relay): release proxy/responder on cap-throw; tombstone closed refs to stop orphaned late opens Review round on Task 8 found two node-side resource leaks on failure paths, both reachable on a slow/hung-agent node. MAJOR 1: RelaySessions.register throws at the SSH_RELAY_MAX_PER_NODE cap, and the ssh_relay_open executors only cleaned up on the false return - a 9th open leaked its already-bound proxy listener fd and socket name until daemon restart; both call sites now wrap the register in try/catch and release on every refusal shape. MAJOR 2: the A branch acks pending:true before its probe, so a plane close that lands mid-probe found no owned ref, and the late-successful open then registered a responder nothing would ever close - a permanent orphan slot. RelaySessions.close now tombstones an unknown ref for RELAY_CLOSE_TOMBSTONE_MS (2x the session lifetime, swept at every touch, clock/TTL injectable) and register refuses a tombstoned ref, so the late open's own refused path releases its responder/proxy. NIT 2 (doc): the node refusal string also reaches the plane's thrown SshRelayRefusal, so the ids-never-secrets contract is load-bearing for any logger. --- .../src/__tests__/commands-ssh-relay.test.ts | 52 +++++ .../src/__tests__/relay-responder.test.ts | 181 +++++++++++++++++- .../node/agent/src/commands/ssh-relay-exec.ts | 18 +- apps/node/agent/src/commands/ssh-relay.ts | 145 +++++++++++--- 4 files changed, 366 insertions(+), 30 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index b4013a5c1..90e727daa 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -239,6 +239,58 @@ describe("RelaySessions concurrency bound (acceptance (g), node-side defense)", relay.close("r-3", "lifetime-expiry"); expect(relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toBe(true); }); + + it("a THROWN cap refusal does not create a session and the refused opener can still release itself", () => { + // The call-site contract the executors rely on (fix round T8 MAJOR 1): + // every refusal path of register leaves the registry exactly as it was, + // so the OPENERS cleanup (its try/catch around register) is the whole + // release story - the cap throw must not half-own anything. + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) relay.register(`r-${i}`, handler); + expect(() => relay.register("r-over", handler)).toThrow(/relay registry full/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + expect(relay.has("r-over")).toBe(false); + }); +}); + +describe("RelaySessions close tombstones (fix round T8 MAJOR 2)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("remembers a close of an UNKNOWN ref, refuses a late register, and expires the tombstone", () => { + // The clock and the TTL are injected: the race window (a detached open + // worst-case ~20 s deep in its probe) is real time the suite cannot pay. + let now = 1_000_000; + const relay = new RelaySessions(undefined, { nowMs: () => now, tombstoneMs: 5_000 }); + // The plane's cut lands BEFORE the open ever registers: nothing is owned, + // and the answer stays the honest `closed: false` the executor answers. + expect(relay.close("r-late", "handshake-grace")).toBe(false); + expect(relay.isTombstoned("r-late")).toBe(true); + // A late SUCCESS must not own a ref the plane already dropped. + expect(relay.register("r-late", handler)).toBe(false); + expect(relay.size).toBe(0); + // A close of an OWNED ref is NOT tombstoned: it took the normal path. + expect(relay.register("r-own", handler)).toBe(true); + expect(relay.close("r-own", "lifetime-expiry")).toBe(true); + expect(relay.isTombstoned("r-own")).toBe(false); + // The set is TTL-bounded, not a growing ledger: after the tombstone + // expires, the ref (or any other) registers normally again. + now += 5_001; + expect(relay.isTombstoned("r-late")).toBe(false); + expect(relay.register("r-late", handler)).toBe(true); + }); + + it("sweeps expired tombstones on every touch so repeated unknown closes stay bounded", () => { + let now = 0; + const lines: string[] = []; + const relay = new RelaySessions((line) => lines.push(line), { nowMs: () => now, tombstoneMs: 100 }); + for (let i = 0; i < 50; i += 1) expect(relay.close(`r-${i}`, "grant-revoked")).toBe(false); + now += 101; // every tombstone is now past its TTL + expect(relay.register("sweep-probe", handler)).toBe(true); // the sweep ran at register + for (let i = 0; i < 50; i += 1) expect(relay.isTombstoned(`r-${i}`)).toBe(false); + // The remembered close is observable in the log (ids and the named + // reason only - the same refusal vocabulary every relay line keeps). + expect(lines.some((l) => l.includes("r-0") && l.includes("grant-revoked"))).toBe(true); + }); }); describe("the daemon seam wiring", () => { diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 0912cec0d..f4af31db4 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,7 @@ import { openRelayEnvelope, parseRelayFrame, type RelayFrame, + SSH_RELAY_MAX_PER_NODE, type SshRelayOpenCommand, sealRelayEnvelope, verifyRelayEnvelope, @@ -19,6 +20,7 @@ import { exportJWK, generateKeyPair } from "jose"; import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; import { loadOrCreateIdentity } from "../identity.js"; import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { buildAgentSocketPath } from "../relay-proxy.js"; import { startRelayResponder } from "../relay-responder.js"; /** @@ -1213,6 +1215,183 @@ test("with no live agent socket the session opens with no scheme and answers SSH } }); +/* ------------------------------------------------------------------ */ +/* refused opens release what they bound (fix round T8 MAJOR 1 + 2) */ +/* ------------------------------------------------------------------ */ + +/** + * A stub agent that answers EVERY request with FAILURE(5) only after + * `delayMs`: the open-time numbering probe stays in flight for the whole + * (delayed) round trip, which is the window the race test drops the plane's + * close into. Same one-shot wire posture as the real round trips. + */ +async function startLaggingAgent(delayMs: number): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-lag-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + setTimeout(() => { + if (!conn.destroyed) conn.write(framed(Buffer.from([5]))); + }, delayMs); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* nothing well-formed to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +const REFUSAL_FILLER = { onRelayFrame: () => {}, close: () => {} }; + +/** A command whose peer keys are REAL P-256 material: these tests must reach + * the register step, which sits past every pin gate, so a placeholder JWK + * would refuse in the wrong place. */ +async function refusalCmd(over: Partial): Promise { + const bKeys = await bKeysReady; // here they stand for the PEER's registered keys + return { + type: "ssh_relay_open", + relayId: "relay-refusal", + ref: "r-refusal", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-refusal", + ...over, + }; +} + +test("a B open refused at the node cap releases its bound proxy: no socket file survives (fix MAJOR 1)", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-cap-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-cap"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-cap", + cmd: await refusalCmd({ role: "B", ref: "r-cap-9", relayId: "relay-cap-9" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/relay registry full/); + // The proxy had already listen()ed when the cap THREW out of register: the + // throw path must run the same release the dup-ref path always ran - the + // listener closed and the socket name unlinked (close() does both, so the + // absent name is the witness that the release ran). + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("an A open refused at the node cap closes its responder too (fix MAJOR 1, A twin)", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-acap-")); + const lines: string[] = []; + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-cap-a", relayId: "relay-cap-a" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, // no probe: the cap refusal is the registry's own + log: (line) => lines.push(line), + }), + ).rejects.toThrow(/relay registry full/); + // The responder existed when register threw; the symmetric catch closes it. + expect(lines.some((l) => l.includes("r-cap-a") && l.includes("A-side responder closed"))).toBe(true); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("a close that lands mid-probe wins the race: the late A open is refused and releases its responder (fix MAJOR 2)", async () => { + const lag = await startLaggingAgent(80); // each probe answer is ~80 ms late + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-race-")); + const lines: string[] = []; + try { + // The ack has already left the executor (pending: true); the detached + // open is inside its probe when the plane's cut arrives - worst-case + // ~20 s of agent round trips, here 2 x 80 ms of lagging answers. + const pending = openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-race", relayId: "relay-race" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => lag.path, + log: (line) => lines.push(line), + }); + await sleep(20); // mid-probe: nothing owns the ref yet + expect(lag.received.length).toBeGreaterThan(0); // the probe really is running + expect(relay.close("r-race", "handshake-grace")).toBe(false); // the plane's ssh_relay_close lands first + await expect(pending).rejects.toThrow(/already closed by the plane/); + // (b) release: the responder's own close line ran (the probe's one-shot + // connections are gone by then; nothing was ever forwarded). + expect(lines.some((l) => l.includes("r-race") && l.includes("A-side responder closed"))).toBe(true); + // (c) no orphan: the refused late register took no slot... + expect(relay.size).toBe(0); + // (d) and the tombstone punishes only THAT ref: a genuinely new session + // registers normally (the TTL-bounded expiry itself is pinned in + // commands-ssh-relay.test.ts with an injected clock). + expect(relay.register("r-fresh", REFUSAL_FILLER)).toBe(true); + expect(relay.isTombstoned("r-race")).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + await lag.close(); + } +}); + +test("a late B open for a ref the plane already closed unbinds its proxy (fix MAJOR 2, B twin)", async () => { + const relay = new RelaySessions(); + // The plane's cut lands before the open ever registers. + expect(relay.close("r-late-b", "handshake-grace")).toBe(false); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-btomb-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-late"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-late", + cmd: await refusalCmd({ role: "B", ref: "r-late-b", relayId: "relay-late-b" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/already closed by the plane/); + // The proxy bound, the refused register released it: no socket remains and + // no slot was taken. + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(0); + expect(relay.isTombstoned("r-late-b")).toBe(true); +}); + /* ------------------------------------------------------------------ */ /* startRelayResponder's own gates */ /* ------------------------------------------------------------------ */ diff --git a/apps/node/agent/src/commands/ssh-relay-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts index 6df0a3440..d657503a8 100644 --- a/apps/node/agent/src/commands/ssh-relay-exec.ts +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -104,10 +104,14 @@ export async function execSshRelayOpen( }); return { ok: true, data: { role: "B", relayId: cmd.relayId, socketPath } as unknown as JsonValue }; } catch (err: unknown) { - // Named refusals (MOVED pin, malformed peer key, owned ref): the plane's - // openRelay turns the rejection into its own named refusal. The string - // travels the RESULT channel only - openBRelaySession's refusals name - // ids and the §4.5 remedy, never key material. + // Named refusals (MOVED pin, malformed peer key, owned ref, registry + // cap, already-closed ref): the plane's openRelay turns the rejection + // into its own named refusal. The string travels the RESULT channel - + // and the plane ALSO interpolates a rejected command's reason into the + // THROWN `SshRelayRefusal("handshake", ...)` that openRelay raises, so + // anything that ever logs either shape, at any level, inherits this as + // load-bearing: node refusals name ids and the §4.5 remedy, never key + // material (NIT, fix round T8). return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } @@ -117,7 +121,11 @@ export async function execSshRelayOpen( * named reason to the local owner (§5.6's endpoint line). Ungated by design * (module doc). An unknown ref answers `closed: false` - a close racing the * plane's own cut is routine, and a false answer to a redundant close is the - * honest record, not an error. + * honest record, not an error. The registry additionally TOMBSTONES an + * unknown ref for a bounded TTL (RelaySessions, fix round T8 MAJOR 2), so + * the close also disarms the one race that answer used to hide: a detached + * A-open that succeeds AFTER this cut can no longer register a responder + * nothing will ever close. */ export function execSshRelayClose(ctx: CommandContext, cmd: SshRelayCloseCommand): CommandResult { const relay = ctx.relay; diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index e2c30ee8d..3d928f453 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -2,6 +2,7 @@ import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; import { bytesOfJwk, type RelayFrame, + SSH_RELAY_LIFETIME_MS, SSH_RELAY_MAX_PER_NODE, type SshRelayOpenCommand, } from "@internal/subshell-protocol"; @@ -49,6 +50,18 @@ export interface RelaySessionHandler { close(reason: string): void; } +/** + * How long a close of a ref this machine never owned is remembered. The race + * it bounds (fix round T8 MAJOR 2): the A branch acks `pending: true` and its + * detached open probes worst-case ~20 s (two 10 s agent round trips), so the + * plane's `ssh_relay_close` can land while the open is still in flight - and + * the open would then REGISTER a responder nothing will ever close, because + * the plane already dropped the ref. A few multiples of the session lifetime + * covers command delivery plus the probe with room; the entry is a marker, + * not a resource, and it EXPIRES (nothing here grows unbounded). + */ +export const RELAY_CLOSE_TOMBSTONE_MS = 2 * SSH_RELAY_LIFETIME_MS; + /** * The per-daemon map from routing ref to its local owner. ONE instance lives * for the daemon's life (relay sessions outlive a link reconnect - the plane @@ -57,13 +70,29 @@ export interface RelaySessionHandler { * shares (Task 7). Frames for an unknown ref are dropped with a log line: a * stale ref (session already closed around the plane's own cut) is routine, * and the refusal the plane deserves is Task 8's broker's, not this map's. + * + * Two refusals beyond ownership, both for the openers' cleanup paths (the + * executors' try/catch around {@link register} releases whatever they just + * bound on EITHER refusal shape): + * - the {@link SSH_RELAY_MAX_PER_NODE} cap THROWS a named refusal (the + * machine saying no to a miscounting broker); + * - a ref closed while unowned is TOMBSTONED for {@link + * RELAY_CLOSE_TOMBSTONE_MS}, and {@link register} answers the dup-path + * false for it (the plane's cut beat a late open; the late open must not + * own a dead ref). */ export class RelaySessions { #byRef = new Map(); + /** Ref closed while unowned -> expiry in ms. TTL-bounded, swept at every touch. */ + #closedRefs = new Map(); #say: (line: string) => void; + #now: () => number; + #tombstoneMs: number; - constructor(logLine?: (line: string) => void) { + constructor(logLine?: (line: string) => void, opts: { nowMs?: () => number; tombstoneMs?: number } = {}) { this.#say = logLine ?? ((line: string): void => log(line)); + this.#now = opts.nowMs ?? Date.now; + this.#tombstoneMs = opts.tombstoneMs ?? RELAY_CLOSE_TOMBSTONE_MS; } /** Sessions currently owned (census/test surface). */ @@ -72,8 +101,10 @@ export class RelaySessions { } /** - * Take ownership of a ref. False when the ref is ALREADY owned - two live - * sessions on one routing ref is a broker bug, and the second opener's + * Take ownership of a ref. False when the ref is ALREADY owned, or when it + * carries a close tombstone (the plane closed this ref before the open + * landed; {@link isTombstoned} lets the refused opener name which) - two + * live sessions on one routing ref is a broker bug, and the second opener's * resources belong to ITSELF to release (its caller sees the false). * Throws a named refusal when this machine is already holding * {@link SSH_RELAY_MAX_PER_NODE} sessions and `ref` is NEW: the plane's @@ -81,10 +112,14 @@ export class RelaySessions { * node refusing to hold a 9th proxy socket off a miscounting broker * (defense-in-depth, Task 8 (g)). The throw path and the dup-ref false * path are deliberately different: an owned ref is a routing collision - * the CALLER unwinds, a full registry is the machine saying no. + * the CALLER unwinds, a full registry is the machine saying no. EITHER + * way, an opener that already bound a resource must release it on both + * shapes (fix round T8 MAJOR 1: the throw path once bypassed that). */ register(ref: string, handler: RelaySessionHandler): boolean { + this.#sweepTombstones(); if (this.#byRef.has(ref)) return false; + if (this.#closedRefs.has(ref)) return false; if (this.#byRef.size >= SSH_RELAY_MAX_PER_NODE) { throw new Error( `relay registry full: ${this.#byRef.size} live sessions on this machine (max ${SSH_RELAY_MAX_PER_NODE})`, @@ -104,6 +139,16 @@ export class RelaySessions { return this.#byRef.has(ref); } + /** + * Whether a close of this ref is currently remembered while the ref is + * unowned: the refused opener's discriminator between "already owned" and + * "the plane already closed it" (fix round T8 MAJOR 2). + */ + isTombstoned(ref: string): boolean { + this.#sweepTombstones(); + return this.#closedRefs.has(ref); + } + /** * THE single inbound relay dispatch (the Task-7-reused seam): route one * parsed frame to whichever local role owns its ref. A throwing owner is @@ -127,11 +172,20 @@ export class RelaySessions { /** * Close the session named by `ref` (what Task 8's `ssh_relay_close` * executor will call). True when a session was owned and closed, false for - * an unknown ref. + * an unknown ref - and an unknown ref is not merely ignored: the close is + * TOMBSTONED for {@link RELAY_CLOSE_TOMBSTONE_MS}, so a detached open that + * succeeds after the plane already cut can no longer own the ref (fix + * round T8 MAJOR 2). A close of an OWNED ref takes the normal path and + * tombstones nothing. */ close(ref: string, reason: string): boolean { + this.#sweepTombstones(); const handler = this.#byRef.get(ref); - if (!handler) return false; + if (!handler) { + this.#closedRefs.set(ref, this.#now() + this.#tombstoneMs); + this.#say(`relay close for ${ref} (${reason}): no local session; remembering the close against late opens`); + return false; + } this.#byRef.delete(ref); try { handler.close(reason); @@ -145,6 +199,19 @@ export class RelaySessions { closeAll(reason: string): void { for (const ref of [...this.#byRef.keys()]) this.close(ref, reason); } + + /** + * Drop every expired tombstone. Called at BOTH mutating touches (register + * and close), so the set is TTL-window-bounded rather than a ledger: a + * burst of closes for unknown refs ages out on the next touch and nothing + * is ever remembered past {@link RELAY_CLOSE_TOMBSTONE_MS}. + */ + #sweepTombstones(): void { + const now = this.#now(); + for (const [ref, expiresAt] of this.#closedRefs) { + if (expiresAt <= now) this.#closedRefs.delete(ref); + } + } } /** Everything {@link openBRelaySession} needs: the daemon's seams + the command's pairing + the pane. */ @@ -190,9 +257,12 @@ function decodePeerEncryptionJwk(b64: string): string { * A (first pairing pins both halves, a moved pin is a hard block), then bind * the pane's agent proxy socket and register its ref. Throws a named refusal * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an - * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), or a - * ref already owned. Resolves with the composed socket path - the value the - * launch's scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a ref + * already owned, a ref the plane already closed (tombstoned), or the + * node-side registry cap - and EVERY refusal releases the proxy this call + * just bound (fix round T8 MAJOR 1: a thrown cap refusal once bypassed that + * release). Resolves with the composed socket path - the value the launch's + * scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. @@ -252,14 +322,26 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock ...(args.log === undefined ? {} : { log: args.log }), }); - if ( - !args.relay.register(cmd.ref, { - onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), - close: () => proxy.close(), - }) - ) { + try { + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), + close: () => proxy.close(), + }) + ) { + throw new Error( + args.relay.isTombstoned(cmd.ref) + ? "relay open refused: routing ref already closed by the plane (late open dropped)" + : "relay open refused: routing ref already owned by a live session", + ); + } + } catch (err) { + // EVERY refused register releases the just-bound proxy: the dup/tombstone + // false path as before, and the THROWN cap refusal too - a refused open + // must never leave a listening fd and a socket name behind (fix round T8 + // MAJOR 1; proxy.close() is idempotent). proxy.close(); - throw new Error("relay open refused: routing ref already owned by a live session"); + throw err; } return { socketPath: proxy.socketPath }; } @@ -305,7 +387,10 @@ export interface ARelaySessionArgs { * enforces it without a REST call it cannot make) and register the ref. * Throws a named refusal (the dispatch wrapper turns it into `ok:false`) on: * role other than A, a pairing that names a different machine as A, an - * unusable peer key, a MOVED pin, or a ref already owned. A probe failure is + * unusable peer key, a MOVED pin, a ref already owned, a ref the plane + * already closed (tombstoned - the race this branch's detached open makes + * real, fix round T8 MAJOR 2), or the node-side registry cap - and EVERY + * refusal closes the responder it just started. A probe failure is * NOT a throw: the session opens refuse-closed. Resolves with the session id * - nothing about the machine is in the answer, and no path exists to leak. * @@ -388,14 +473,26 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela ...(args.log === undefined ? {} : { log: args.log }), }); - if ( - !args.relay.register(cmd.ref, { - onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), - close: (reason) => responder.close(reason), - }) - ) { + try { + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), + close: (reason) => responder.close(reason), + }) + ) { + throw new Error( + args.relay.isTombstoned(cmd.ref) + ? "relay open refused: routing ref already closed by the plane (late open dropped)" + : "relay open refused: routing ref already owned by a live session", + ); + } + } catch (err) { + // Symmetric with the B branch (fix round T8 MAJOR 1): the dup false path + // closed the responder before, and the THROWN cap refusal closes it too. + // The A responder holds no fd, but a refused open must never leave one + // alive and unregistered to classify bytes forever. responder.close(); - throw new Error("relay open refused: routing ref already owned by a live session"); + throw err; } return { relayId: cmd.relayId }; } From 6d10cfb694a26c2f2c1ccc33fb7a7cccb5a125ad Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:21:26 -0700 Subject: [PATCH 108/170] test(ssh-relay): typecheck seam for the agent relay sources the integration test drives The relay integration matrix imports the agent's relay modules IN PLACE, so the server typecheck project must admit files outside src/ (rootDir/outDir move to tsconfig.build.json, which keeps the shipped emit byte-identical, diffed) and carry the one WebCrypto global (JsonWebKey) those sources name: supplied by a test-scoped shim instead of DOM lib, which re-types Blob across seven server files (measured). The build config excludes every __tests__ path, so neither seam can reach the shipped binary. --- .../helpers/agent-relay-globals.d.ts | 37 +++++++++++++++++++ apps/server/api/tsconfig.build.json | 12 ++++-- apps/server/api/tsconfig.json | 10 ++++- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts diff --git a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts new file mode 100644 index 000000000..2052fd47c --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -0,0 +1,37 @@ +/** + * Ambient globals the agent's relay sources reference but this project's + * typecheck lib (ES2023, no DOM) does not declare. + * + * The relay integration test imports the agent's relay modules (under + * apps/node/agent) IN PLACE, so `verify-types` typechecks those sources under + * this app's `lib`. The agent's own tsconfig carries `lib: ["ES2023", "DOM"]`, + * which is where it gets the WebCrypto `JsonWebKey` global; this app + * deliberately does not add DOM (it changes `Blob`/`Response` typing across + * seven server files, measured). Adding only the one name the agent relay + * sources actually use keeps the shim to its smallest honest size. + * + * Lives under `__tests__` so `tsc -p tsconfig.build.json` (which excludes + * `src/**/ __tests__; /**`) never sees it: the shipped server emit is untouched. + */ + +/** The WebCrypto JSON Web Key shape, as lib.dom declares it (agent relay only). */ +interface JsonWebKey { + alg?: string; + crv?: string; + d?: string; + dp?: string; + dq?: string; + e?: string; + ext?: boolean; + k?: string; + key_ops?: string[]; + kty?: string; + n?: string; + oth?: Array<{ d?: string; n?: string; t?: string }>; + p?: string; + q?: string; + qi?: string; + use?: string; + x?: string; + y?: string; +} diff --git a/apps/server/api/tsconfig.build.json b/apps/server/api/tsconfig.build.json index d84e3e347..673468734 100644 --- a/apps/server/api/tsconfig.build.json +++ b/apps/server/api/tsconfig.build.json @@ -1,9 +1,11 @@ { "extends": "./tsconfig.json", "//": [ - "Build-only config. Identical to tsconfig.json except that test files and", - "the cross-suite fixtures under src/test-helpers/ are excluded from emit,", - "so dist/ contains shipping code only.", + "Build-only config. Adds the emit layout (rootDir/outDir, kept out of the", + "shared config below so the relay integration test can import agent", + "sources outside src/ without a TS6059) and excludes test files and the", + "cross-suite fixtures under src/test-helpers/ from emit, so dist/ contains", + "shipping code only.", "", "Two things depended on this: `prod` runs from dist/, so compiled tests were", "being shipped; and a bare `bun test` picked up both src/ and dist/ copies,", @@ -12,5 +14,9 @@ "verify-types deliberately stays on tsconfig.json (the full project) so test", "files are still type-checked — they just aren't emitted." ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, "exclude": ["src/**/__tests__/**", "src/test-helpers/**", "src/test-preload.ts"] } diff --git a/apps/server/api/tsconfig.json b/apps/server/api/tsconfig.json index 550f4b7f6..ae790a6da 100644 --- a/apps/server/api/tsconfig.json +++ b/apps/server/api/tsconfig.json @@ -1,9 +1,15 @@ { "extends": "@internal/tsconfig/tsconfig.json", "include": ["src/**/*"], + "//": [ + "No rootDir/outDir here (the typecheck config): the relay integration test", + "under src/**/__tests__ imports the agent's relay modules from", + "apps/node/agent IN PLACE, and an emit layout (explicit or inferred from", + "outDir) makes every such file a TS6059. The build config pins both.", + "The DOM lib (which the agent's own tsconfig carries too) supplies the", + "WebCrypto globals (JsonWebKey) the agent's relay modules reference." + ], "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", "module": "NodeNext", "moduleResolution": "node16", "paths": { From ad0b53cd8e4c807b4c11cf059090fda210fe086c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:21:29 -0700 Subject: [PATCH 109/170] test(ssh-relay): relay integration matrix, replay inertness, reverse verification Nine whole-stack cases over two real machine identities, the real B proxy socket, the real A responder over a scheme-behavior stub agent, and the real plane broker behind a structurally-blind fake plane (no private key, no opener, JSON text only): granted sign round trip sealed end to end, the method allow-list over the socket, wrong-origin refusal, scope-before-forward after the filtered roster, byte-opacity of the exchange at the plane (wire, audits, session view, broker log), replay inertness inside the window and into a reused routing ref, the multi-key session at strictly increasing signed seqs, non-increasing seq refusal both directions, and forged/garbled A2B refusal at B. Matrix case 10 (roster command) is a named todo for Task 11. The stack fixture rides the T6/T7 scheme-byte posture. --- .../__tests__/helpers/ssh-relay-stack.ts | 580 +++++++++++++++++ .../__tests__/ssh-relay.integration.test.ts | 595 ++++++++++++++++++ 2 files changed, 1175 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts new file mode 100644 index 000000000..e39597172 --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -0,0 +1,580 @@ +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + type NodeCommandBody, + openRelayEnvelope, + type RelayFrame, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import type { AuditEventInput } from "@/services/audit.js"; +import { + createRelayBroker, + type OpenRelayResult, + type RelayBroker, + type TimerHandle, +} from "@/services/ssh-relay.service.js"; +import { + openARelaySession, + openBRelaySession, + RelaySessions, +} from "../../../../../../node/agent/src/commands/ssh-relay.js"; +import { loadOrCreateIdentity, type NodeIdentity } from "../../../../../../node/agent/src/identity.js"; +import { + type AgentScheme, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, +} from "../../../../../../node/agent/src/relay-agent-scheme.js"; + +/** + * The whole-stack fixture for `ssh-relay.integration.test.ts` (spec 2026-10-08 + * M2 Task 9): two in-process machine identities (real `loadOrCreateIdentity` + * keypairs in real temp data dirs), a REAL B-side agent proxy, a REAL A-side + * responder fed by a scheme-behavior stub agent, and the REAL plane broker + * (`createRelayBroker`) wired to both through fake transport seams. + * + * **The fake plane is blind by construction.** It is given no private key of + * any machine, no opener, and no codec call: the node-to-plane and + * plane-to-node seams serialize the frame to JSON text and re-parse it (the + * socket is what a plane actually holds), and the broker forwards `blob` by + * reference without reading it. Everything the plane can see is enumerated on + * `Stack.planeVisible()`: wire capture lines, audit rows, session views, and + * its own log lines. + * + * The agent-byte builders and the stub agent below are the T6/T7 fixture + * spellings (measured OpenSSH_10.2p1 behavior, hand-spelled byte literals), + * reused rather than re-invented; the scheme constants come from the agent's + * own `relay-agent-scheme.ts`. + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire, spelled by hand (T7 fixture posture) */ +/* ------------------------------------------------------------------ */ + +/** The roster's granted key: its blob embeds the ASCII marker "KEY-IN". */ +export const KEY_IN = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-IN")), +]); +/** The roster's ungranted key: the scope-before-forward witness blob. */ +export const KEY_OUT = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-OUT")), +]); + +/** One roster entry as the stub agent carries it. */ +export interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** The stub's default roster: one granted key, one ungranted key. */ +export const ROSTER: RosterEntry[] = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url. */ +export function fp(wire: Uint8Array): string { + return fingerprintAgentBlob(wire); +} + +/** 4-byte big-endian length. */ +export function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +export function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** A framed agent message: 4-byte BE length + payload (the ssh-agent wire). */ +export function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/** An IDENTITIES_ANSWER in the stub's scheme. */ +export function buildIdentitiesAnswer(scheme: AgentScheme, entries: RosterEntry[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/** A classic-scheme SIGN_REQUEST (15): blob, data, flags. */ +export function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST (13): blob, data, flags, optional algorithms. */ +export function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** Decode an IDENTITIES_ANSWER independently of the production parser. */ +export function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +/* ------------------------------------------------------------------ */ +/* the stub live agent (T7 fixture, 10.x by default) */ +/* ------------------------------------------------------------------ */ + +/** A scheme-behavior stub agent socket: identities answers, well-formed signs answer SIGN_RESPONSE("SIG"). */ +export interface StubAgent { + path: string; + /** Every request payload the stub received, in order (the open-time probes included). */ + received: Buffer[]; + close(): Promise; +} + +async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (request.length === 1 && type === scheme.identities) { + conn.write(framed(buildIdentitiesAnswer(scheme, roster))); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + conn.write(framed(Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]))); + } else { + conn.write(framed(Buffer.from([5]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/** The stub's own sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): boolean => { + if (off + 4 > p.length) return false; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return false; + off += n; + return true; + }; + if (!str()) return false; // blob + if (off <= 5) return false; // empty blob length means an empty string: refuse + if (!str()) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; + if (!str()) return false; // algorithms + return off === p.length; +} + +/* ------------------------------------------------------------------ */ +/* the stack: broker + A machine + B machine over blind transport */ +/* ------------------------------------------------------------------ */ + +/** The pane the brokered pairing serves: a path-id spelling both grammars accept. */ +export const PANE_ID = "11111111-2222-4333-8444-555555555555"; + +/** One recorded crossing of the plane: the JSON text and the node it landed on. */ +export interface WireEntry { + /** True for plane-to-node deliveries (the fake link's outbound half). */ + outbound: boolean; + /** The node this text reached (or left). */ + nodeId: "a-node" | "b-node"; + /** The JSON text exactly as the fake socket carried it. */ + raw: string; +} + +/** What {@link openStack} builds: the live stack plus everything it observed. */ +export interface Stack { + broker: RelayBroker; + /** The broker's successful open result (ref, relayId, verified socketPath). */ + result: OpenRelayResult; + /** The A machine's real identity (registered public halves live here). */ + aId: NodeIdentity; + /** The B machine's real identity. */ + bId: NodeIdentity; + /** The A machine's data dir (its identity and pin store live here). */ + aDir: string; + /** The B machine's data dir (the proxy socket was bound under here). */ + bDir: string; + /** The A-side node registry (responder under the brokered ref). */ + aRelay: RelaySessions; + /** The B-side node registry (agent proxy under the brokered ref). */ + bRelay: RelaySessions; + /** The stub agent A forwards granted requests to. */ + stub: StubAgent; + /** Requests the stub received AFTER the open-time probe. */ + forwarded(): Buffer[]; + /** Every JSON text that crossed the plane, both directions, in order. */ + wire: WireEntry[]; + /** Audit rows the broker wrote, metadata re-serialized from the JSON string. */ + audits: { action: string; metadata: Record; metadataJson: string }[]; + /** Broker log lines (plane-visible text). */ + brokerLines: string[]; + /** Commands the broker sent (plane-side composites; the open grammar's own view). */ + commands: { nodeId: string; cmd: NodeCommandBody }[]; + /** + * Plane-visible text for the byte-opacity scan: wire captures, audit rows, + * session views, broker log lines. Never the machines' innards. + */ + planeVisible(): string[]; + /** + * Simulated plane outage: while true, inbound B2A frames are captured but + * not routed (A answers nothing until `releaseB2A` runs). + */ + swallowB2A: boolean; + /** Route every swallowed B2A frame now (A's late answers flow). */ + releaseB2A(): void; + /** Deliver one frame INTO the plane as if `nodeId`'s socket carried it. */ + routeFromNode(nodeId: "a-node" | "b-node", frame: RelayFrame): void; + /** + * Run the named close on BOTH node registries WITHOUT the broker's own + * teardown: the machines end the session while the plane's blind lane for + * the ref stays up (still routing anything that re-presents it). The + * ref-reuse replay case drives exactly this resurrection. + */ + closeNodeSessions(ref: string, reason: string): void; + /** Tear everything down (registries, stub socket, broker timers). */ + cleanup(): Promise; +} + +/** Options for {@link openStack}. */ +export interface StackOptions { + /** The grant's fingerprint set (default: the one granted key). */ + fingerprints?: readonly string[]; + /** The stub's roster (default: {@link ROSTER}). */ + roster?: RosterEntry[]; + /** The numbering the stub agent speaks (default: the fleet's measured 10.x). */ + scheme?: AgentScheme; +} + +/** + * Open the whole stack: real broker, real node branches, real identities, a + * stub agent, and the blind fake plane. Resolves after the handshake + * completed (both opens acked, B's socket path byte-verified by the broker + * itself, so a returned stack means the plane's acceptance (e) check passed + * against the proxy's real bound path). + */ +export async function openStack(opts: StackOptions = {}): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; + const fingerprints = opts.fingerprints ?? [fp(KEY_IN)]; + const aDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-a-")); + const bDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-b-")); + // The machines' REAL identities: signing + encryption keypairs as identity.ts + // generates and stores them; the broker pairs their registered public halves. + const aId = await loadOrCreateIdentity(aDir); + const bId = await loadOrCreateIdentity(bDir); + const stub = await startStubAgent(opts.roster ?? ROSTER, scheme); + + const aRelay = new RelaySessions(); + const bRelay = new RelaySessions(); + const wire: WireEntry[] = []; + const audits: Stack["audits"] = []; + const brokerLines: string[] = []; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + let swallowedB2A: RelayFrame[] = []; + + /** Plane-to-node: serialize like a socket would, re-parse, hand to the node. */ + const deliverToNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: true, nodeId, raw }); + const parsed = JSON.parse(raw) as RelayFrame; + (nodeId === "a-node" ? aRelay : bRelay).onInboundRelayFrame(parsed); + }; + + /** Node-to-plane: the handler seam; the outage swallows B2A while armed. */ + const routeFromNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: false, nodeId, raw }); + if (nodeId === "b-node" && frame.direction === "B2A" && stack.swallowB2A) { + swallowedB2A.push(JSON.parse(raw) as RelayFrame); + return; + } + broker.routeRelayFrame(nodeId, frame); + }; + + const sendCommand = async (nodeId: string, cmd: NodeCommandBody): Promise => { + commands.push({ nodeId, cmd }); + if (cmd.type === "ssh_relay_open") { + if (cmd.role === "A") { + await openARelaySession({ + relay: aRelay, + dataDir: aDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: (f) => routeFromNode("a-node", f), + resolveAgentSocket: () => stub.path, + }); + return { relayId: cmd.relayId }; + } + const { socketPath } = await openBRelaySession({ + relay: bRelay, + dataDir: bDir, + selfNodeId: "b-node", + paneId: cmd.paneId, + cmd, + sendRelayFrame: (f) => routeFromNode("b-node", f), + }); + return { socketPath }; + } + if (cmd.type === "ssh_relay_close") { + (nodeId === "a-node" ? aRelay : bRelay).close(cmd.ref, cmd.reason); + return { ok: true }; + } + throw new Error(`fake node ${nodeId}: unexpected command ${String(cmd.type)}`); + }; + + const broker = createRelayBroker({ + sendCommand, + sendRelayFrame: deliverToNode, + async nodeRow(nodeId) { + return nodeId === "a-node" || nodeId === "b-node" ? { kind: "agent", sshEnabled: 1 } : null; + }, + nodeDataDir: (nodeId) => (nodeId === "b-node" ? bDir : null), + async audit(e: AuditEventInput) { + audits.push({ + action: e.action, + metadata: JSON.parse(e.metadataJson ?? "{}") as Record, + metadataJson: e.metadataJson ?? "", + }); + }, + nowMs: () => 1_730_000_000_000, // fixed clock: no timer in the matrix should ever fire + schedule: (): TimerHandle => { + const handle: { fn?: () => void } = {}; + return handle; // never fired; cancel is a no-op + }, + cancel: () => {}, + log: (line) => brokerLines.push(line), + }); + + const result = await broker.openRelay({ + grantId: "grant-1", + fingerprints, + paneId: PANE_ID, + aNode: "a-node", + bNode: "b-node", + aPeer: { signingPublicKey: aId.signingPublicJwk, encryptionPublicJwk: aId.publicJwk }, + bPeer: { signingPublicKey: bId.signingPublicJwk, encryptionPublicJwk: bId.publicJwk }, + }); + + const probeCount = stub.received.length; // whatever the open-time numbering probe asked + + const stack: Stack = { + broker, + result, + aId, + bId, + aDir, + bDir, + aRelay, + bRelay, + stub, + forwarded: () => stub.received.slice(probeCount), + wire, + audits, + brokerLines, + commands, + planeVisible: () => [ + ...wire.map((w) => w.raw), + ...audits.map((a) => a.metadataJson), + ...brokerLines, + JSON.stringify(broker.sessionInfo(result.ref)), + ], + swallowB2A: false, + releaseB2A() { + const pending = swallowedB2A; + swallowedB2A = []; + for (const f of pending) broker.routeRelayFrame("b-node", f); + }, + routeFromNode, + closeNodeSessions(ref, reason) { + // Closes ONLY the node-registry handlers for the ref (owned close, so + // no tombstone). The broker's own session for the ref is left intact: + // the plane routes by ref and knows nothing of relay ids, so a ref + // re-presented by restarted endpoints is a plane-level event the + // broker must keep routing while the ENDPOINTS refuse the old bytes. + aRelay.close(ref, reason); + bRelay.close(ref, reason); + }, + async cleanup() { + bRelay.closeAll("child-exit"); + aRelay.closeAll("child-exit"); + broker.reset(); + await stub.close(); + }, + }; + return stack; +} + +/* ------------------------------------------------------------------ */ +/* wire-side decoders: open + verify one captured frame the peer way */ +/* ------------------------------------------------------------------ */ + +/** What {@link decodeB2A}/{@link decodeA2B} return: the plaintext agent bytes + the bindings. */ +export interface DecodedFrame { + agentBytes: Buffer; + seq: number; + /** B's endpoint nonce: carried by every B2A AND echoed in every A2B (§5.6). */ + nB: string; + /** A's endpoint nonce: absent only from the session's first request. */ + nA?: string; +} + +/** + * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, + * A's real encryption private half opens the envelope. Throws exactly where + * A's responder threw. + */ +export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:a-node", publicJwk: s.aId.publicJwk, privateJwk: s.aId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.bId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "B2A", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** + * Open + verify an A2B frame AS B did: A's signing key is the pinned origin, + * B's real encryption private half opens the envelope. The witness for the + * matrix's "B can open it with A's pinned encryption key" assertions. + */ +export async function decodeA2B(s: Stack, frame: RelayFrame): Promise { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:b-node", publicJwk: s.bId.publicJwk, privateJwk: s.bId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.aId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "A2B", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** Seal one B2A request with the given signer (evil signer = the wrong-origin attack). */ +export async function sealB2A( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: s.aId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "B2A", blob }; +} + +/** Seal one A2B reply with the given signer (evil signer = the forged-reply attack). */ +export async function sealA2B( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:b-node", publicJwk: s.bId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* tiny async helpers */ +/* ------------------------------------------------------------------ */ + +/** Sleep. */ +export async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +/** Wait until `cond` holds or throw with a named timeout. */ +export async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts new file mode 100644 index 000000000..d8fd9cd98 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -0,0 +1,595 @@ +import { expect, test } from "bun:test"; +import { createConnection, type Socket } from "node:net"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + base64UrlNoPad, + newNonce, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_LIFETIME_MS, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { openARelaySession, openBRelaySession } from "../../../../../node/agent/src/commands/ssh-relay.js"; +import { + buildSignRequestTenX, + decodeA2B, + decodeB2A, + fp, + framed, + KEY_IN, + KEY_OUT, + openStack, + PANE_ID, + parseAnswer, + type Stack, + sealA2B, + sealB2A, + sleep, + sshStr, + waitUntil, +} from "./helpers/ssh-relay-stack.js"; + +/** + * The M2 relay integration matrix (spec 2026-10-08, Task 9): the whole stack + * at once. Two REAL machine identities (`loadOrCreateIdentity` in temp data + * dirs), the REAL B proxy socket, the REAL A responder over a stub agent, and + * the REAL plane broker, wired through a fake plane that is blind by + * construction: it holds no private key, imports no opener, and touches only + * JSON text ({type, ref, seq, direction, blob}) - the envelope is base64 + * ciphertext to it, as it is to the shipped broker. + * + * What each case asserts is observable bytes and side effects: the stub + * agent's received list (what A's agent was actually asked), the pane + * socket's received frames (what the pane's ssh actually got), the plane's + * wire capture, and the serialized audit rows. Case 6 (replay inertness), + * case 3 (wrong origin) and case 4 (scope-before-forward) are the crown + * jewels: a replayed envelope must be inert, a foreign signature must not + * reach the key home, and an ungranted blob must never be put in front of + * A's agent, roster answer first or not. + * + * Matrix case 10 (the `ssh_agent_identities` roster command accepted with no + * grant) is DEFERRED to Task 11, which builds that command; the todo at the + * bottom names it. + */ + +/** An ES256 pair pinned by nobody: signatures from it are forgeries. */ +const evilReady: Promise<{ privateJwk: string; publicJwk: string }> = generateKeyPair("ES256", { + extractable: true, +}).then(async ({ publicKey, privateKey }) => ({ + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), +})); + +/** The b64-of-UTF8 spelling the open command carries the peer encryption key in. */ +function b64Jwk(jwkJson: string): string { + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +/** The pane's ssh end: connect, collect framed replies, observe death. */ +function paneAt(socketPath: string): { + conn: Socket; + closed: () => boolean; + payloads: () => Buffer[]; + destroy(): void; +} { + const conn = createConnection({ path: socketPath }); + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + conn.on("error", () => { + /* failures are read through the close/data events, never thrown */ + }); + return { + conn, + closed: () => closed, + payloads: (): Buffer[] => { + const buf = Buffer.concat(chunks); + const out: Buffer[] = []; + let off = 0; + while (off + 4 <= buf.length) { + const len = buf.readUInt32BE(off); + if (off + 4 + len > buf.length) break; + out.push(buf.subarray(off + 4, off + 4 + len)); + off += 4 + len; + } + return out; + }, + destroy: () => conn.destroy(), + }; +} + +/** The frames the plane RECEIVED (node-to-plane legs), decoded, in order. */ +function ingress(s: Stack): RelayFrame[] { + return s.wire.filter((w) => !w.outbound).map((w) => JSON.parse(w.raw) as RelayFrame); +} + +/** A2B envelopes A actually PRODUCED (the plane saw them leave A's socket). */ +function producedByA(s: Stack): number { + return s.wire.filter((w) => !w.outbound && w.nodeId === "a-node").length; +} + +/** Change the first base64 character: same length, valid alphabet, broken first byte. */ +function garbleBlob(blob: string): string { + const replacement = blob[0] === "A" ? "B" : "A"; + return replacement + blob.slice(1); +} + +/* ------------------------------------------------------------------ */ +/* 1. the happy path: a granted sign round trips sealed */ +/* ------------------------------------------------------------------ */ + +test("1. a granted SIGN_REQUEST from the pane reaches A's agent and the signature comes back sealed: B opens it with A's pinned encryption key", async () => { + const s = await openStack(); + try { + // The broker's byte-check (acceptance (e)) already ran inside openRelay: + // the returned path IS the proxy's real bound path, equal to the plane's + // own derivation from B's dataDir and the command's pane. + expect(s.result.socketPath).toBe(buildAgentSocketPath(s.bDir, PANE_ID)); + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the SIGN_RESPONSE on the pane socket"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // measured 10.x bytes + expect(s.forwarded()).toEqual([request]); // A's agent saw EXACTLY the scoped request, byte-identical + + const inF = ingress(s); + expect(inF.length).toBe(2); // one B2A, one A2B; nothing else crossed + expect(parseRelayFrame(JSON.stringify(inF[0]))).not.toBeNull(); + expect(inF[0].ref).toBe(s.result.ref); + expect(inF[0].direction).toBe("B2A"); + expect(inF[1].direction).toBe("A2B"); + + // Decode the pair independently, the way each endpoint's checklist does. + const asA = await decodeB2A(s, inF[0]); + expect(asA.agentBytes).toEqual(request); + expect(asA.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // B minted its endpoint nonce + expect(asA.nA).toBeUndefined(); // B spoke first; A's nonce is not in a request yet + const asB = await decodeA2B(s, inF[1]); // opens with B's own pinned encryption half + expect(asB.agentBytes).toEqual(p.payloads()[0]); // what B can OPEN is what B's pane receives + expect(asB.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(asB.nB).toBe(asA.nB); // the SAME session nonces ride both directions + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 2. the method allow-list end to end */ +/* ------------------------------------------------------------------ */ + +test("2. a non-allow-listed agent method (EXTENSION) is refused at A and never forwarded, over the real socket", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.concat([Buffer.from([27]), sshStr("ssh-agent-extension-op"), Buffer.from([1])]))); + await waitUntil(() => p.payloads().length === 1, "the clean refusal"); + expect([...p.payloads()[0]]).toEqual([5]); // SSH2_AGENT_FAILURE, never a hang + await sleep(30); + expect(s.forwarded()).toEqual([]); // A's agent was never asked + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 3. wrong origin: an unpinned machine key must not reach the key home */ +/* ------------------------------------------------------------------ */ + +test("3. a B2A envelope signed by a machine key that is not B's pin opens at A but verifies nowhere and reaches nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + // The plane (or anyone) holds A's PUBLIC encryption key and can seal at + // will (spec §5.6's first line): the envelope opens. Only the signature + // can name the origin, and the evil key is not B's pin. + const forged = await sealB2A(s, { + agentBytes: Buffer.from([11]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + s.routeFromNode("b-node", forged); + await sleep(60); + expect(s.forwarded()).toEqual([]); // the key home never saw the request + expect(producedByA(s)).toBe(0); // and a forgery costs no answer: no signature oracle + expect(ingress(s).length).toBe(1); // the only thing the plane routed was the forged B2A + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 4. scope-before-forward, after a correctly filtered roster */ +/* ------------------------------------------------------------------ */ + +test("4. after the filtered roster answer, a SIGN_REQUEST for the UNGRANTED blob is refused at A: A's agent is never asked to sign it", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster answer"); + const roster = parseAnswer(p.payloads()[0]); + expect(roster.type).toBe(12); + expect(roster.count).toBe(1); // two keys on A's agent, one key for the pane + expect(roster.comments).toEqual(["granted key"]); + + p.conn.write(framed(buildSignRequestTenX(KEY_OUT))); // the blob the roster never showed + await waitUntil(() => p.payloads().length === 2, "the refusal"); + expect([...p.payloads()[1]]).toEqual([5]); + + const fwd = s.forwarded(); + expect(fwd.length).toBe(1); // ONLY the identities request went to A's agent + expect(fwd[0]).toEqual(Buffer.from([11])); + for (const seen of s.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); // byte 13 in the stub's view is only the probe + } + expect(producedByA(s)).toBe(2); // both answers came back, but the second is a refusal, not a signature + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 5. the relayed blob is byte-opaque AT THE PLANE */ +/* ------------------------------------------------------------------ */ + +test("5. a full exchange leaves no plaintext anywhere the plane can see: wire capture, audits, session view, broker log", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const signRequest = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-SECRET") }); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster"); + p.conn.write(framed(signRequest)); + await waitUntil(() => p.payloads().length === 2, "the signature"); + + const signResponse = p.payloads()[1]; + // Only full multi-field payloads ride the marker list: a one-byte + // request's two-character base64 would "collide" inside random + // ciphertext by chance and tell us nothing. + const payloadB64 = [signRequest, signResponse].flatMap((b) => [ + b.toString("base64"), + base64UrlNoPad(new Uint8Array(b)), + ]); + const markers = [ + "KEY-IN", + "KEY-OUT", + "ssh-ed25519", + "granted key", + "ungranted key", + "DATA-SECRET", // markers containing a hyphen or a space cannot occur inside base64 by chance + ]; + + // Scan every string the plane holds BEFORE the close, then force the + // close audit and scan again (the lifetime audit rides the close). + const sessionView = JSON.stringify(s.broker.sessionInfo(s.result.ref)); + const beforeClose = [...s.planeVisible(), sessionView]; + expect(await s.broker.closeRelay(s.result.ref, "child-exit")).toBe(true); + const afterClose = s.planeVisible(); + + const blobs = ingress(s).map((f) => f.blob); + expect(blobs.length).toBeGreaterThanOrEqual(4); // two requests and two answers crossed + for (const text of [...beforeClose, ...afterClose]) { + for (const marker of markers) expect(text).not.toContain(marker); + for (const form of payloadB64) expect(text).not.toContain(form); + } + for (const blob of blobs) { + // The opaque envelope itself never leaks into a durable structure: + // audits and the session view carry ids and paths, never relay bytes. + for (const a of s.audits) expect(a.metadataJson).not.toContain(blob); + expect(sessionView).not.toContain(blob); + } + + // The audit census is the shipped posture: routing ids, hosts, the + // reason, and the fingerprint COUNT (never the set) - and no fingerprints. + const open = s.audits.find((a) => a.action === "node.ssh_relay.open"); + const close = s.audits.find((a) => a.action === "node.ssh_relay.close"); + expect(open).toBeDefined(); + expect(close).toBeDefined(); + expect(open?.metadata.fingerprintCount).toBe(1); + expect(Object.keys(open?.metadata ?? {}).sort()).toEqual( + ["aNodeId", "bNodeId", "fingerprintCount", "grantId", "lifetimeMs", "paneId", "ref", "relayId"].sort(), + ); + expect(close?.metadata.reason).toBe("child-exit"); + for (const a of s.audits) { + expect(a.metadataJson).not.toContain(fp(KEY_IN)); + expect(a.metadataJson).not.toContain(fp(KEY_OUT)); + } + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 6. replay inertness: inside the window, and into a ref-reuse */ +/* ------------------------------------------------------------------ */ + +test("6. a captured envelope is INERT replayed inside the lifetime and inert replayed into a later session on the reused routing ref", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + const b2a = inF[0]; + const a2b = inF[1]; + const decReq = await decodeB2A(s, b2a); + const decRep = await decodeA2B(s, a2b); + + // (a) inside the lifetime window. + // (a1) the byte-identical B2A resend (the plane routes what it already + // routed): refused by the binding flip (the recorded nA must ride) and, + // whatever path refuses it, A asks its agent nothing and answers nothing. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + expect(p.payloads().length).toBe(1); + + // (a2) a freshly signed B2A that passes EVERY binding except seq: the + // signed anti-replay value goes backwards, so the SeqGate alone refuses. + const rewind = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + s.routeFromNode("b-node", rewind); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // (a3) the captured A2B replayed toward B: refused by B's gate; the pane + // stream gains nothing and the connection is not punished for it. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // (b) the crown jewel: both machines end this pairing and a LATER + // session re-presents the SAME routing ref with a fresh relay id and + // fresh endpoint state. The ref is `crypto.randomUUID()` per pairing, so + // the broker itself would never re-issue it; what the test forces is the + // state the plane is actually in when a ref somehow comes back: it routes + // the ref blindly (it never sees relay ids or nonces), so only the + // ENDPOINTS' bindings can make the old bytes inert. + s.closeNodeSessions(s.result.ref, "child-exit"); + expect(s.aRelay.has(s.result.ref)).toBe(false); + expect(s.bRelay.has(s.result.ref)).toBe(false); + expect(s.broker.sessionInfo(s.result.ref)).not.toBeNull(); // the plane's lane for the ref is still open + const cmd2 = (role: "A" | "B"): SshRelayOpenCommand => ({ + type: "ssh_relay_open", + relayId: "relay-second", + ref: s.result.ref, + role, + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: role === "A" ? s.bId.signingPublicJwk : s.aId.signingPublicJwk, + peerEncryptPublicKey: b64Jwk(role === "A" ? s.bId.publicJwk : s.aId.publicJwk), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: PANE_ID, + }); + await openARelaySession({ + relay: s.aRelay, + dataDir: s.aDir, + selfNodeId: "a-node", + cmd: cmd2("A"), + sendRelayFrame: (f) => s.routeFromNode("a-node", f), + resolveAgentSocket: () => s.stub.path, + }); + await openBRelaySession({ + relay: s.bRelay, + dataDir: s.bDir, + selfNodeId: "b-node", + paneId: PANE_ID, + cmd: cmd2("B"), + sendRelayFrame: (f) => s.routeFromNode("b-node", f), + }); + const fwdBase = s.forwarded().length; // session 2's open re-probed; baseline moves + const aProducedBase = producedByA(s); + + // (b1) the old relayId, old-nonce, old-seq B2A envelope, replayed into + // the session that shares its ref: A's session-2 responder opens it (the + // key home never moved) and the signature bindings refuse it - fresh + // gate and reused ref notwithstanding, the relay id and endpoint nonces + // are what it dies on. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(fwdBase); + expect(producedByA(s)).toBe(aProducedBase); + + // (b2) the old A2B envelope replayed at session 2's idle proxy: nothing + // is written to any pane socket by it. + const p2 = paneAt(s.result.socketPath); + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p2.payloads().length).toBe(0); + + // (b3) and session 2 itself is healthy: a genuine sign completes on it, + // proving the refusals above refused the FORGERY, not the session. + p2.conn.write(framed(buildSignRequestTenX(KEY_IN))); + await waitUntil(() => p2.payloads().length === 1, "session 2's own signature"); + expect([...p2.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p2.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 7. multi-key session: several granted keys, strictly increasing seq */ +/* ------------------------------------------------------------------ */ + +test("7. a multi-key session: SIGN_REQUESTs for two granted keys at strictly increasing seqs both complete", async () => { + const s = await openStack({ fingerprints: [fp(KEY_IN), fp(KEY_OUT)] }); + try { + const p = paneAt(s.result.socketPath); + const req1 = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-IN") }); + const req2 = buildSignRequestTenX(KEY_OUT, { data: Buffer.from("DATA-OUT") }); + p.conn.write(framed(req1)); + await waitUntil(() => p.payloads().length === 1, "the first signature"); + p.conn.write(framed(req2)); + await waitUntil(() => p.payloads().length === 2, "the second signature"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect([...p.payloads()[1]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect(s.forwarded()).toEqual([req1, req2]); + + const inF = ingress(s); + const b2a = inF.filter((f) => f.direction === "B2A"); + const a2b = inF.filter((f) => f.direction === "A2B"); + expect(b2a.length).toBe(2); + expect(a2b.length).toBe(2); + const signedB2A = await Promise.all(b2a.map((f) => decodeB2A(s, f))); + const signedA2B = await Promise.all(a2b.map((f) => decodeA2B(s, f))); + expect(signedB2A.map((d) => d.seq)).toEqual([0, 1]); // the SIGNED seqs are strictly increasing + expect(signedA2B.map((d) => d.seq)).toEqual([0, 1]); + expect(new Set(signedB2A.map((d) => d.nB)).size).toBe(1); // one session, one pair of nonces + expect(new Set(signedA2B.map((d) => d.nA)).size).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 8. a resent or non-increasing seq is refused, both directions */ +/* ------------------------------------------------------------------ */ + +test("8. a resent envelope and a non-increasing signed seq are refused in BOTH directions, and the session still serves the next request", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + + // B2A direction, byte-identical resend (the plane can resend any envelope). + s.routeFromNode("b-node", JSON.parse(JSON.stringify(inF[0])) as RelayFrame); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // B2A direction, a freshly signed envelope whose seq does not advance + // (every other binding correct): the gate alone refuses it. + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + const sameSeq = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + s.routeFromNode("b-node", sameSeq); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // A2B direction: the captured answer resent toward B; the pane's stream + // gains no duplicate and the connection lives. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(inF[1])) as RelayFrame); + await sleep(50); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // Both gates moved forward, so the session continues: request two answers + // at signed seq 1 in each direction. + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 2, "the next genuine signature"); + const secondA2B = ingress(s) + .filter((f) => f.direction === "A2B") + .at(-1); + expect((await decodeA2B(s, secondA2B as RelayFrame)).seq).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 9. B rejects a forged or garbled A2B: nothing reaches the socket */ +/* ------------------------------------------------------------------ */ + +test("9. a forged A2B signature fails the pane's connection without a byte written, and a garbled envelope is noise that costs the session nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine first signature"); + const inF = ingress(s); + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + + // Request two, with the plane withholding A's answer so the slot stays + // open: the forged reply races nothing. + s.swallowB2A = true; + p.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 2, + "the second request reaches the plane", + ); + const forged = await sealA2B(s, { + agentBytes: Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("EVIL-SIG"))]), + seq: 1, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: evil.privateJwk, // opens (sealed to B's public half), verifies against nobody's pin + }); + s.routeFromNode("a-node", forged); + await waitUntil(() => p.closed(), "the forged reply fails the waiting connection"); + expect(p.payloads().length).toBe(1); // the refused frame wrote nothing... + for (const pl of p.payloads()) expect(pl.includes(Buffer.from("EVIL-SIG"))).toBe(false); // ...and it was never the forged bytes + + // A's late answer to the dead request 2 is absorbed by the burned slot. + s.swallowB2A = false; + s.releaseB2A(); + await sleep(30); + + // The garble, against a fresh connection: valid base64, broken JWE bytes. + const p3 = paneAt(s.result.socketPath); + s.swallowB2A = true; + p3.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 3, + "the third request reaches the plane", + ); + const garbled: RelayFrame = { ...inF[1], blob: garbleBlob(inF[1].blob) }; // the genuine answer's envelope, one byte broken + s.routeFromNode("a-node", garbled); + await sleep(50); + expect(p3.payloads().length).toBe(0); // an unopenable envelope writes nothing + expect(p3.closed()).toBe(false); // ...and is noise, not a forgery: the session lives + s.swallowB2A = false; + s.releaseB2A(); // A's real answer to request 3 lands: the garble cost the session nothing + await waitUntil(() => p3.payloads().length === 1, "the real answer after the garbled noise"); + expect([...p3.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p3.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 10. the roster command (DEFERRED to Task 11) */ +/* ------------------------------------------------------------------ */ + +// Matrix case 10 - `ssh_agent_identities` accepted with no grant - belongs to +// Task 11: the roster command does not exist yet and this task must not +// build it. The responder-side surface (fingerprintAgentBlob, +// parseIdentitiesAnswer) is already exported for that task to reuse. +const TODO_PLACEHOLDER = (): void => { + /* Task 11 will drive the roster command here. */ +}; + +test.todo( + "10. (Task 11) ssh_agent_identities is accepted with no grant and answers the filtered roster", + TODO_PLACEHOLDER, +); From 29c0a6f513519fb8d130b3a5e925afdfb0a71941 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:32:38 -0700 Subject: [PATCH 110/170] test(ssh-relay): pin openability so relay refusals cannot pass vacuously --- .../helpers/agent-relay-globals.d.ts | 8 +++- .../__tests__/helpers/ssh-relay-stack.ts | 44 ++++++++++++++++--- .../__tests__/ssh-relay.integration.test.ts | 29 ++++++++++-- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts index 2052fd47c..2808bf3c0 100644 --- a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -10,8 +10,12 @@ * seven server files, measured). Adding only the one name the agent relay * sources actually use keeps the shim to its smallest honest size. * - * Lives under `__tests__` so `tsc -p tsconfig.build.json` (which excludes - * `src/**/ __tests__; /**`) never sees it: the shipped server emit is untouched. + * Lives under `__tests__` so `tsc -p tsconfig.build.json` (whose exclude + * list covers every `__tests__` tree under src) never sees it: the shipped + * server emit is untouched. A double-star glob cannot be quoted here: the + * comment-closing character pair occurs inside every double-star segment, + * so writing one ends this comment mid-string. That is what mangled the + * earlier wording in this file. */ /** The WebCrypto JSON Web Key shape, as lib.dom declares it (agent relay only). */ diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index e39597172..c71552136 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -7,6 +7,7 @@ import { base64UrlNoPad, type NodeCommandBody, openRelayEnvelope, + type RelayEnvelopeOpen, type RelayFrame, sealRelayEnvelope, verifyRelayEnvelope, @@ -320,6 +321,13 @@ export async function openStack(opts: StackOptions = {}): Promise { const brokerLines: string[] = []; const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; let swallowedB2A: RelayFrame[] = []; + // The outage flag lives in a plain closure variable, NOT on `stack`: + // routeFromNode already serves the open handshake (sendCommand's + // sendRelayFrame seam) while `const stack` is still in its TDZ, and a + // handshake-time B2A reading `stack.swallowB2A` would have thrown a + // ReferenceError. The open's frame silence is protocol behavior the + // fixture must not lean on to stay evaluable. + let swallowArmed = false; /** Plane-to-node: serialize like a socket would, re-parse, hand to the node. */ const deliverToNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { @@ -333,7 +341,7 @@ export async function openStack(opts: StackOptions = {}): Promise { const routeFromNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { const raw = JSON.stringify(frame); wire.push({ outbound: false, nodeId, raw }); - if (nodeId === "b-node" && frame.direction === "B2A" && stack.swallowB2A) { + if (nodeId === "b-node" && frame.direction === "B2A" && swallowArmed) { swallowedB2A.push(JSON.parse(raw) as RelayFrame); return; } @@ -427,7 +435,15 @@ export async function openStack(opts: StackOptions = {}): Promise { ...brokerLines, JSON.stringify(broker.sessionInfo(result.ref)), ], - swallowB2A: false, + // Accessor over the closure flag (see swallowArmed above): the property + // stays a plain boolean on the Stack interface while routeFromNode reads + // only the closure, never the not-yet-initialized stack. + get swallowB2A() { + return swallowArmed; + }, + set swallowB2A(value: boolean) { + swallowArmed = value; + }, releaseB2A() { const pending = swallowedB2A; swallowedB2A = []; @@ -468,17 +484,31 @@ export interface DecodedFrame { } /** - * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, - * A's real encryption private half opens the envelope. Throws exactly where - * A's responder threw. + * OPEN a B2A frame at A WITHOUT verifying anything (the confidentiality half + * only): resolves iff the envelope is sealed to A's real encryption key and + * its wrapper agrees with the frame. This is the openability witness the + * refusal cases need: a downstream refusal (verification, the endpoint's gate) + * is then provably a judgment about origin or replay, not a decryption failure + * of a fixture-mis-sealed envelope - that one rejects HERE instead. */ -export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { - const opened = await openRelayEnvelope({ +export async function openB2A(s: Stack, frame: RelayFrame): Promise { + return openRelayEnvelope({ blob: frame.blob, own: { principalId: "node:a-node", publicJwk: s.aId.publicJwk, privateJwk: s.aId.privateJwk }, open: mcpOpen, expect: { ref: frame.ref, direction: frame.direction }, }); +} + +/** + * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, + * A's real encryption private half opens the envelope. Throws exactly where + * A's responder threw. The seq gate is the responder's own check, deliberately + * absent here: an honestly-sealed low-seq envelope DECODES fine, which is what + * makes the responder's refusal nameable as the gate's. + */ +export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { + const opened = await openB2A(s, frame); const message = await verifyRelayEnvelope({ jws: opened.jws, publicJwk: JSON.parse(s.bId.signingPublicJwk) as JsonWebKey, diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index d8fd9cd98..83e6402c8 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -19,6 +19,7 @@ import { framed, KEY_IN, KEY_OUT, + openB2A, openStack, PANE_ID, parseAnswer, @@ -170,6 +171,7 @@ test("2. a non-allow-listed agent method (EXTENSION) is refused at A and never f expect([...p.payloads()[0]]).toEqual([5]); // SSH2_AGENT_FAILURE, never a hang await sleep(30); expect(s.forwarded()).toEqual([]); // A's agent was never asked + expect(producedByA(s)).toBe(1); // the [5] is A's own synthesized refusal: one A2B left A's socket } finally { await s.cleanup(); } @@ -192,6 +194,14 @@ test("3. a B2A envelope signed by a machine key that is not B's pin opens at A b nB: newNonce(), signerPrivateJwk: evil.privateJwk, }); + // Openability witness: the SAME envelope decrypts at A (openB2A resolves), + // so the refusal below is the SIGNATURE stage, not a decryption failure. + // Had sealB2A mis-sealed this to the wrong recipient, openB2A would reject + // here and the case would fail loudly instead of passing vacuously. + await openB2A(s, forged); + // ...and decodeB2A (open THEN verify against B's pin) rejects naming the + // signature, which a wrong-recipient seal would instead die a DecryptError. + await expect(decodeB2A(s, forged)).rejects.toThrow(/signature is invalid/); s.routeFromNode("b-node", forged); await sleep(60); expect(s.forwarded()).toEqual([]); // the key home never saw the request @@ -340,6 +350,12 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), signerPrivateJwk: s.bId.signingPrivateJwk, }); + // Openability witness: the refused envelope opens AND verifies end to end + // (decodeB2A runs A's open+verify checklist with NO gate - the gate is the + // responder's own next step), so the live refusal below can only be the + // SeqGate's. A sealB2A that sealed to the wrong recipient would throw + // right here, failing this case instead of refusing for the wrong reason. + expect((await decodeB2A(s, rewind)).agentBytes).toEqual(request); s.routeFromNode("b-node", rewind); await sleep(60); expect(s.forwarded().length).toBe(1); @@ -398,9 +414,12 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep // (b1) the old relayId, old-nonce, old-seq B2A envelope, replayed into // the session that shares its ref: A's session-2 responder opens it (the - // key home never moved) and the signature bindings refuse it - fresh - // gate and reused ref notwithstanding, the relay id and endpoint nonces - // are what it dies on. + // key home never moved) and EXACTLY one binding refuses it: the signed + // relaySessionId claim (the bytes carry session 1's id, this responder was + // opened under relay-second). The endpoint nonces refuse nothing at this + // point: session 2 has recorded no nB and has not flipped bindNA, so its + // checklist omits both nonce members; and the fresh gate would have + // ACCEPTED seq 0, had the verification ever passed. s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); await sleep(60); expect(s.forwarded().length).toBe(fwdBase); @@ -488,6 +507,10 @@ test("8. a resent envelope and a non-increasing signed seq are refused in BOTH d ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), signerPrivateJwk: s.bId.signingPrivateJwk, }); + // Openability witness (case 6(a2)'s same guard): every check before the + // gate passes for this exact envelope, so the refusal below is the gate's + // alone. A mis-sealed fixture would throw here, not pass vacuously. + expect((await decodeB2A(s, sameSeq)).agentBytes).toEqual(request); s.routeFromNode("b-node", sameSeq); await sleep(50); expect(s.forwarded().length).toBe(1); From 6f1e4ba6defc24b3b415666602525ef38d572597 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:40:51 -0700 Subject: [PATCH 111/170] feat(ssh-grants): migration 0050 - grants, first-use queue, host pins Three tables per the M2 plan's file map (spec 2026-10-08 SS6, SS9): ssh_key_grants (standing authorization with the SHA256: selection as a JSON array), ssh_grant_requests (durable pending queue with a 24 h expiry and the four-state lifecycle), and ssh_host_pins (the T12 TOFU store's shape, one unique pin per owner per resolved destination). Registered in migrate.ts; LATEST_BACKUP_MIGRATION and the backup strip-set fixture move to 0050. --- .../__tests__/backup-cli-subprocess.test.ts | 22 +- apps/server/api/src/db/migrate.ts | 4 + .../api/src/db/migrations/0050-ssh-grants.ts | 92 +++++++ .../__tests__/0050-ssh-grants.test.ts | 238 ++++++++++++++++++ apps/server/api/src/db/types/index.ts | 4 + .../api/src/db/types/ssh-grants.db-types.ts | 122 +++++++++ .../api/src/services/backups/database.ts | 2 +- 7 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0050-ssh-grants.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts create mode 100644 apps/server/api/src/db/types/ssh-grants.db-types.ts diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 27bf37c74..e0ed7c3ee 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049 together: this era's three migrations are the tail of - // the registered ledger, so the prefix runs contiguously through 0046 and - // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0049) means + // WHY 0047+0048+0049+0050 together: this era's four migrations are the tail + // of the registered ledger, so the prefix runs contiguously through 0046 and + // boot re-applies all four. Advancing LATEST_BACKUP_MIGRATION (0050) means // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants')", ) .all(), ).toEqual([]); @@ -430,13 +430,14 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", ) .all(), ).toEqual([ { name: "0047-node-ssh-enabled" }, { name: "0048-ssh-launch-and-saved-hosts" }, { name: "0049-ssh-relay-identity" }, + { name: "0050-ssh-grants" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -456,6 +457,11 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_fingerprint'").get()).toEqual({ name: "ssh_fingerprint", }); + for (const grantTable of ["ssh_key_grants", "ssh_grant_requests", "ssh_host_pins"]) { + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name=?").get(grantTable)).toEqual({ + name: grantTable, + }); + } migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 415466e44..0fed9f7cd 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -49,6 +49,7 @@ import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.j import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; /** * Runs all pending Kysely migrations against the app database. @@ -145,6 +146,9 @@ export async function runMigrations(): Promise { // identities record and the trust-card fingerprint mirror on the // nodes row (spec 2026-10-08 §4, §9). "0049-ssh-relay-identity": sshRelayIdentityMigration, + // The grant tier: standing key grants, the durable first-use approval + // queue, and the M2 host-pin store (spec 2026-10-08 §6, §9). + "0050-ssh-grants": sshGrantsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0050-ssh-grants.ts b/apps/server/api/src/db/migrations/0050-ssh-grants.ts new file mode 100644 index 000000000..2bf1fb51d --- /dev/null +++ b/apps/server/api/src/db/migrations/0050-ssh-grants.ts @@ -0,0 +1,92 @@ +import type { Kysely } from "kysely"; + +/** + * The grant tier (spec 2026-10-08 §6, §9): the three tables the authorization + * layer stands on. + * + * - `ssh_key_grants`: the standing authorization - one owner's permission to + * reach destinations matching a selector by signing with the agent on one + * key-home machine. It carries the operator's fingerprint SELECTION (public + * `SHA256:` identifiers as a JSON array, capped at + * `SSH_MAX_GRANT_FINGERPRINTS` by the service, never by a silent truncation) + * and nothing else about the keys: no key material, ever (§6.1). + * - `ssh_grant_requests`: the DURABLE first-use queue (spec §6.2, borrowing + * the signup pending-approvals shape): the pending row survives a plane + * restart so a re-launch finds it, and the expiry sweep marks stale rows + * `expired` without writing any audit event. + * - `ssh_host_pins`: the M2 TOFU host-key store (§9), keyed by the RESOLVED + * destination, one pin per `user@host:port` per owner, independent of + * grants. The read/write/capture LOGIC lands with T12; the schema ships + * here per the plan's file map so later migrations never re-cut it. + * + * Owner is FK-cascaded like the saved-hosts ledger (0048): a removed account + * takes its grants, requests, and pins with it. Node ids carry no FK - + * matching a grant against a vanished node is refused at the gate, not at the + * schema, the same posture `ssh_saved_hosts.node_id` set. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_key_grants") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("fingerprints", "text", (col) => col.notNull()) + .addColumn("created_via", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // The launch-time match reads one owner's grants for one key home; the list + // screen reads one owner's grants. Both ride this index. + await db.schema + .createIndex("idx_ssh_key_grants_owner_keyhome") + .on("ssh_key_grants") + .columns(["owner_user_id", "key_home_node_id"]) + .execute(); + + await db.schema + .createTable("ssh_grant_requests") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("requested_fingerprints", "text") + .addColumn("pane_id", "text", (col) => col.notNull()) + .addColumn("b_node_id", "text", (col) => col.notNull()) + .addColumn("expires_at", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + // The approvals screen reads one owner's OPEN (pending) queue; the dedup + // read at request time is (owner, key home, selector) newest-first. + await db.schema + .createIndex("idx_ssh_grant_requests_owner_status") + .on("ssh_grant_requests") + .columns(["owner_user_id", "status"]) + .execute(); + + await db.schema + .createTable("ssh_host_pins") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("host_key", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // One pin per owner per resolved destination (§9): the TOFU record, so a + // second capture at a differing key is the §9 hard block, not a second row. + await db.schema + .createIndex("idx_ssh_host_pins_owner_destination") + .unique() + .on("ssh_host_pins") + .columns(["owner_user_id", "destination"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("ssh_host_pins").execute(); + await db.schema.dropTable("ssh_grant_requests").execute(); + await db.schema.dropTable("ssh_key_grants").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts new file mode 100644 index 000000000..37f33e938 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshKeyGrants: Record; + sshGrantRequests: Record; + sshHostPins: Record; +} + +/** + * The grant tier's storage (spec 2026-10-08 §6, §9; migration 0050) creates + * ALL THREE tables in one file per the plan's file map: `ssh_key_grants` (the + * standing authorization), `ssh_grant_requests` (the durable first-use queue + * with an expiry deadline and a four-state lifecycle), and `ssh_host_pins` + * (the M2 TOFU store - one unique pin per owner per resolved `user@host:port`; + * its read/write logic is T12's, the SHAPE is this migration's). + * + * The FK target is the minimal `user` table (same posture as the 0048/0049 + * tests); node ids carry NO fk by design (a vanished node is refused at the + * gate, the saved-hosts precedent). + */ +describe("migration 0050-ssh-grants", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0050-${Math.random().toString(36).slice(2)}.db`; + // The app's own camelCase mapping (db/index.ts), so the typed inserts here + // spell names the same way every repository does. + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_key_grants with the full column set and rejects a null owner", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_key_grants')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + for (const col of [ + "id", + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.has(col)).toBe(true); + } + // EVERY grant column is NOT NULL: the service writes the full row, there is + // no partial vocabulary (a grant with no fingerprints is an empty ARRAY, + // not a NULL - "names no fingerprint serves nothing" is data, not absence). + for (const col of [ + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.get(col)).toBe(1); + } + await db + .insertInto("sshKeyGrants") + .values({ + id: "g1", + ownerUserId: "u1", + name: "work hosts", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + fingerprints: JSON.stringify(["SHA256:aaa"]), + createdVia: "first-use", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // The fingerprints column round-trips the JSON array exactly as stored. + const row = await sql<{ fingerprints: string }>`SELECT fingerprints FROM ssh_key_grants WHERE id = 'g1'`.execute( + db, + ); + expect(JSON.parse(row.rows[0].fingerprints)).toEqual(["SHA256:aaa"]); + // NOT NULL enforced at the door: a grant row without its owner is refused. + await expect( + db + .insertInto("sshKeyGrants") + .values({ + id: "g2", + ownerUserId: null as unknown as string, + name: "n", + keyHomeNodeId: "node-a", + resolvedSelector: "h", + fingerprints: "[]", + createdVia: "manual", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + }); + + it("creates ssh_grant_requests: only requested_fingerprints is nullable, and a durable pending row round-trips", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + // (`id` is deliberately absent: SQLite's TEXT PRIMARY KEY carries no + // NOT NULL flag, and uniqueness is the actual identity rule here.) + for (const col of [ + "owner_user_id", + "key_home_node_id", + "resolved_selector", + "pane_id", + "b_node_id", + "expires_at", + "status", + "created_at", + ]) { + expect(byName.get(col)).toBe(1); + } + // The one representable absence (spec §6.2: the approver, not the pane, + // selects the keys): a request with no pre-selection stores NULL. + expect(byName.get("requested_fingerprints")).toBe(0); + await db + .insertInto("sshGrantRequests") + .values({ + id: "r1", + ownerUserId: "u1", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "pane-1", + bNodeId: "node-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // (Raw SQL identifiers stay snake - the plugin rewrites the Kysely + // builders, not raw text - but the RESULT ROW KEYS come back camel, the + // mapping the app's own queries ride everywhere.) + const back = await sql<{ status: string; requestedFingerprints: string | null; paneId: string }>` + SELECT status, requested_fingerprints, pane_id FROM ssh_grant_requests WHERE id = 'r1' + `.execute(db); + expect(back.rows[0].status).toBe("pending"); + expect(back.rows[0].requestedFingerprints).toBeNull(); + // The requester facts ride the row (the approvals screen shows WHO asked): + // the row survives because the table is durable storage, not process state. + expect(back.rows[0].paneId).toBe("pane-1"); + // A terminal status re-uses the same column vocabulary (no CHECK in this + // schema: the SERVICE validates against SSH_GRANT_REQUEST_STATUSES). + await sql`UPDATE ssh_grant_requests SET status = 'approved' WHERE id = 'r1'`.execute(db); + const after = await sql<{ status: string }>`SELECT status FROM ssh_grant_requests WHERE id = 'r1'`.execute(db); + expect(after.rows[0].status).toBe("approved"); + }); + + it("creates ssh_host_pins with the unique (owner, destination) key - the TOFU record shape", async () => { + await db + .insertInto("sshHostPins") + .values({ + id: "p1", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // One pin per owner per resolved destination: the second insert at the + // same key is the §9 hard block at the STORAGE level (the capture logic + // that reads this refusal is T12's). + await expect( + db + .insertInto("sshHostPins") + .values({ + id: "p2", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3DIFFERENT", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + // Another owner may hold their own pin for the same destination (per-owner + // scoping: one account's first use must not poison or lock another's). + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@test')`.execute(db); + await db + .insertInto("sshHostPins") + .values({ + id: "p3", + ownerUserId: "u2", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const count = await sql<{ n: number }>`SELECT COUNT(*) AS n FROM ssh_host_pins`.execute(db); + expect(count.rows[0].n).toBe(2); + }); + + it("owner deletion cascades every grant tier row (accounts leave no authorization behind)", async () => { + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const orphans = await sql<{ + n: number; + }>`SELECT COUNT(*) AS n FROM ssh_host_pins WHERE owner_user_id = 'u2'`.execute(db); + expect(orphans.rows[0].n).toBe(0); + }); + + it("down drops all three tables", async () => { + await grantsMigration.down(db); + for (const table of ["ssh_key_grants", "ssh_grant_requests", "ssh_host_pins"]) { + const left = await sql<{ n: number }>` + SELECT COUNT(*) AS n FROM sqlite_schema WHERE type = 'table' AND name = ${table} + `.execute(db); + expect(left.rows[0].n).toBe(0); + } + }); +}); diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index d7d997127..c624b06b0 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,7 @@ import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-s import type { PromptTable } from "@/db/types/prompts.db-types.js"; import type { RecentPathTable } from "@/db/types/recent-paths.db-types.js"; import type { SettingTable } from "@/db/types/settings.db-types.js"; +import type { SshGrantRequestTable, SshHostPinTable, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; import type { SubshellShareTable } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; @@ -59,4 +60,7 @@ export interface Database { notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; sshSavedHosts: SshSavedHostTable; + sshKeyGrants: SshKeyGrantTable; + sshGrantRequests: SshGrantRequestTable; + sshHostPins: SshHostPinTable; } diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts new file mode 100644 index 000000000..fb6a2bd4d --- /dev/null +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -0,0 +1,122 @@ +/** + * Storage types for the grant tier (spec 2026-10-08 §6, §9; migration 0050). + * Three records: the standing authorization (`ssh_key_grants`), the durable + * first-use approval queue (`ssh_grant_requests`), and the M2 TOFU host-key + * store (`ssh_host_pins`, schema here per the plan's file map; its read/write + * logic is T12's). + */ + +/** How a grant came to exist; the runtime census is {@link SSH_GRANT_CREATED_VIA}. */ +export type SshGrantCreatedVia = "first-use" | "manual"; + +/** + * The two doors a grant row arrives through (spec §6.2: "Creation via the + * screen and via first use write the same row and the same audit event", the + * `via` field tells them apart after the fact). `first-use` is the standing + * grant an APPROVED request writes; `manual` is the grants screen. + */ +export const SSH_GRANT_CREATED_VIA: readonly SshGrantCreatedVia[] = ["first-use", "manual"]; + +/** + * Lifecycle of a first-use approval request; the runtime census is + * {@link SSH_GRANT_REQUEST_STATUSES}. `pending` is the only OPEN state: + * `approved` wrote a grant, `denied` wrote an audit row only, and `expired` + * was swept at the deadline and wrote NOTHING (spec §6.2). A terminal row + * stays in the table - the queue is small and the trail is the answer. + */ +export type SshGrantRequestStatus = "pending" | "approved" | "denied" | "expired"; + +/** Every grant-request status the schema can hold (validation and sweeps iterate this). */ +export const SSH_GRANT_REQUEST_STATUSES: readonly SshGrantRequestStatus[] = [ + "pending", + "approved", + "denied", + "expired", +]; + +/** + * A standing key grant (spec §6.1): to reach destinations matching the + * selector, sign with the agent on the key-home machine, using exactly the + * selected public identities. No secret and no key bytes live here - the + * fingerprints are OpenSSH `SHA256:` display identifiers over the agent wire + * encoding (public by nature), yet they still never enter a notification body + * or a log line (Global Constraints; docs/security.md §10 names the COUNT). + */ +export interface SshKeyGrantTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) - the account whose launches this authorizes */ + ownerUserId: string; + /** Display name the operator gave the grant (normalized, <= 120 chars at the route) */ + name: string; + /** Machine A: the key home whose ssh-agent signs (no FK - a vanished node is refused at the gate) */ + keyHomeNodeId: string; + /** + * The destination selector, STORED RESOLVED (spec §6.1: an alias typed at + * approval is resolved to the concrete hostname and the alias kept only as + * display metadata elsewhere). A concrete hostname or a hostname pattern + * with `*` wildcards, lowercase, matched against the resolved destination + * hostname at launch; no whitespace, no control characters. + */ + resolvedSelector: string; + /** JSON array of `SHA256:` fingerprint strings (<= SSH_MAX_GRANT_FINGERPRINTS; empty is representable and serves nothing) */ + fingerprints: string; + /** Which door created the row; see {@link SshGrantCreatedVia} */ + createdVia: SshGrantCreatedVia; + /** ISO 8601 creation stamp (the match tie-break: oldest grant wins) */ + createdAt: string; + /** ISO 8601 of the last operator edit (name/selector) */ + updatedAt: string; +} + +/** + * A durable first-use approval request (spec §6.2): recorded BEFORE B's launch + * fails fast, so a plane restart does not forget outstanding approvals and a + * re-launch simply finds the standing pending row. The row is the fact that a + * human must answer it; the audit `node.ssh_grant.request` is the trail. + */ +export interface SshGrantRequestTable { + /** Unique row id (uuid) - the opaque `requestRef` the refusal and the notification name */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE): the key home's owner, the one whose approval is asked */ + ownerUserId: string; + /** Machine A asked to sign (no FK; the approvals gate re-reads the row) */ + keyHomeNodeId: string; + /** The resolved destination hostname the launch would have dialed (stored resolved, like the grant) */ + resolvedSelector: string; + /** JSON array of pre-selected `SHA256:` fingerprints, or null when none rode the request (the approver picks) */ + requestedFingerprints: string | null; + /** B's pane (subshell id) whose launch asked - what the approvals screen names as the requester */ + paneId: string; + /** B's node id: the connecting machine of the asking launch */ + bNodeId: string; + /** ISO 8601 deadline (setup-key scale, 24 h); the sweep marks a passed row `expired` and writes no audit */ + expiresAt: string; + /** Lifecycle state; see {@link SshGrantRequestStatus} - only `pending` is answerable */ + status: SshGrantRequestStatus; + /** ISO 8601 creation stamp */ + createdAt: string; +} + +/** + * One TOFU host-key pin for one owner's resolved destination (spec §9): the + * `user@host:port` -> pinned public host key + timestamps record the M2 relay + * path validates against. Per-owner scoping is deliberate: a global pin would + * let another account's first connection poison (or DoS-lock) a destination + * this owner never touched. The capture/verification LOGIC is T12; this is + * the storage shape it must not re-cut. + */ +export interface SshHostPinTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** Canonical resolved destination `user@host:port` (the `sshCanonicalDestination` spelling) */ + destination: string; + /** The pinned public host key line (` `, OpenSSH known_hosts spelling), never a secret */ + hostKey: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match (a differing key never writes it - that is the §9 hard block) */ + updatedAt: string; +} diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 8b1e25885..3a7fc3b33 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0049-ssh-relay-identity"; +export const LATEST_BACKUP_MIGRATION = "0050-ssh-grants"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { From dd39d40a9621412d86e8e79f565adbe407716dad Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:42:02 -0700 Subject: [PATCH 112/170] feat(ssh-grants): grant repository, five named refusal codes, grant_approval notify Owner-scoped repository for 0050's two readable tables (every read ANDs ownerUserId so a foreign row is absent, the saved-hosts doctrine; the compare-and-set answer and the expiry sweep ride it too). New BackendErrorCodes for the grant tier: SSH_GRANT_APPROVAL_REQUIRED, SSH_GRANT_KEYS_OVER_LIMIT, SSH_GRANT_KEYS_INVALID, SSH_RELAY_OPEN_FAILED, SSH_RELAY_IDENTITY_MISSING. notify.service gains the grant_approval kind and notifyGrantApprovalOwner: the owner-addressed sibling of notifySubshell, with the web-push and device fan-outs extracted to one user-shaped pair both paths use; fixed copy, opaque request id, no secret or fingerprint anywhere. --- .../db/repositories/ssh-grants.repository.ts | 196 ++++++++++++++++++ apps/server/api/src/services/expo-push.ts | 4 + .../server/api/src/services/notify.service.ts | 142 ++++++++++--- packages/backend-errors/src/error-codes.ts | 55 +++++ 4 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 apps/server/api/src/db/repositories/ssh-grants.repository.ts diff --git a/apps/server/api/src/db/repositories/ssh-grants.repository.ts b/apps/server/api/src/db/repositories/ssh-grants.repository.ts new file mode 100644 index 000000000..f88e61bfa --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-grants.repository.ts @@ -0,0 +1,196 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshGrantRequestStatus, SshGrantRequestTable, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; + +/** + * Repository for the grant tier (spec 2026-10-08 §6; migration 0050): the + * standing key grants and the durable first-use approval queue. Every read + * and write here is OWNER-SCOPED at the WHERE clause, mirroring the + * saved-hosts ledger: a foreign row is not "forbidden", it is absent, and the + * route renders absence as the 404 the ownership axis demands (docs/ + * security.md §3). No clock lives in the repository: callers pass their ISO + * stamps, the same posture as `LedgerWrite.at` (0048). + * + * The insert shapes live HERE, beside the repository, not in the db-types + * file (the task-6 note restated): both tables are full rows on every write, + * the repository owns no defaults, and there is no partial vocabulary to + * type. `host_pins` has no reader yet - the shape exists (0050) and T12 + * writes the capture logic; a repository for a table nothing reads yet is a + * second thing to keep in sync. + */ + +/** One full grant row as the two writers (approve, grants screen) compose it. */ +export type NewSshKeyGrant = SshKeyGrantTable; + +/** One full request row as {@link SshGrantsRepository.insertRequest} takes it. */ +export type NewSshGrantRequest = SshGrantRequestTable; + +export class SshGrantsRepository extends BaseRepository { + /** Write a standing grant. The caller validated the row (cap, grammar, ids). */ + async insertGrant(grant: NewSshKeyGrant): Promise { + await this.db.insertInto("sshKeyGrants").values(grant).execute(); + } + + /** One of the owner's grants by id; a foreign id is indistinguishable from an absent one. */ + async getGrant(ownerUserId: string, grantId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", grantId) + .executeTakeFirst(); + } + + /** + * The owner's grants for one key home - the whole launch-time match read. + * The service does the selector matching (the glob policy is not SQL's); + * the row count per owner per key home is small by construction. + */ + async grantsFor(ownerUserId: string, keyHomeNodeId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("keyHomeNodeId", "=", keyHomeNodeId) + .orderBy("createdAt", "asc") + .execute(); + } + + /** The owner's grants for the screen, newest first. */ + async listGrants(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * Apply an operator edit (name and/or selector), returning the row or + * null for foreign-and-absent. `updatedAt` is the caller's stamp. + */ + async updateGrant( + ownerUserId: string, + grantId: string, + patch: { name?: string; resolvedSelector?: string; updatedAt: string }, + ): Promise { + const { name, resolvedSelector, updatedAt } = patch; + await this.db + .updateTable("sshKeyGrants") + .set({ + ...(name === undefined ? {} : { name }), + ...(resolvedSelector === undefined ? {} : { resolvedSelector }), + updatedAt, + }) + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", grantId) + .execute(); + return await this.getGrant(ownerUserId, grantId); + } + + /** + * Delete one of the owner's grants; false for foreign AND absent. The + * caller (the revoke act) reaches the live relay AFTER this returns true - + * the row going first is the ordering that makes a raced relay-open's + * liveness re-check answer "gone". + */ + async deleteGrant(ownerUserId: string, grantId: string): Promise { + const res = await this.db + .deleteFrom("sshKeyGrants") + .where("id", "=", grantId) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } + + /** Record a first-use request. Only `insertRequest` writes `pending`. */ + async insertRequest(request: NewSshGrantRequest): Promise { + await this.db.insertInto("sshGrantRequests").values(request).execute(); + } + + /** One of the owner's requests by id; foreign is absent (the same 404). */ + async getRequest(ownerUserId: string, requestId: string): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", requestId) + .executeTakeFirst(); + } + + /** + * The OPEN question for one (owner, key home, selector): a standing + * pending row whose deadline has not passed. A relaunch finds THIS row + * (spec §6.2: "a re-launch simply finds the pending row") rather than + * stacking a second approval and a second notification. + */ + async findPendingRequest( + ownerUserId: string, + keyHomeNodeId: string, + resolvedSelector: string, + nowIso: string, + ): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("keyHomeNodeId", "=", keyHomeNodeId) + .where("resolvedSelector", "=", resolvedSelector) + .where("status", "=", "pending") + .where("expiresAt", ">", nowIso) + .orderBy("createdAt", "desc") + .executeTakeFirst(); + } + + /** The owner's queue for the approvals screen: pending newest first, plus whatever statuses are asked. */ + async listRequests( + ownerUserId: string, + statuses: readonly SshGrantRequestStatus[] = ["pending"], + ): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("status", "in", statuses) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * The compare-and-set answer: flip a request from `expected` to `next` only + * if it still stands where the approver read it. False is the 409 the + * screen renders (a second approver, or the sweep, beat this answer). + */ + async markRequestStatus( + ownerUserId: string, + requestId: string, + expected: SshGrantRequestStatus, + next: SshGrantRequestStatus, + ): Promise { + const res = await this.db + .updateTable("sshGrantRequests") + .set({ status: next }) + .where("id", "=", requestId) + .where("ownerUserId", "=", ownerUserId) + .where("status", "=", expected) + .executeTakeFirst(); + return Number(res.numUpdatedRows ?? 0n) > 0; + } + + /** + * The expiry sweep: mark every PAST-DEADLINE pending row `expired`. + * Returns the count. Writes no audit row and no grant (spec §6.2: + * "expiry writes nothing at all" - the row is the fact it was asked, the + * status is the fact it went unanswered). Rows already terminal are + * untouched; `nowIso` is the caller's clock. + */ + async sweepExpiredRequests(nowIso: string): Promise { + const res = await this.db + .updateTable("sshGrantRequests") + .set({ status: "expired" }) + .where("status", "=", "pending") + .where("expiresAt", "<=", nowIso) + .executeTakeFirst(); + return Number(res.numUpdatedRows ?? 0n); + } +} diff --git a/apps/server/api/src/services/expo-push.ts b/apps/server/api/src/services/expo-push.ts index 260ed22f0..8c772202c 100644 --- a/apps/server/api/src/services/expo-push.ts +++ b/apps/server/api/src/services/expo-push.ts @@ -72,6 +72,10 @@ const KIND_COPY: Record = { crashed: "A subshell crashed, auto-restarting", crashed_final: "A subshell crashed", maintenance: "A subshell was stopped for node maintenance", + // Not a subshell: a first-use key-grant request waiting on the owner + // (spec 2026-10-08 §6.2). Fixed copy, names nothing - `sid` carries the + // opaque request id, never a destination or a fingerprint. + grant_approval: "A key grant needs your approval", }; /** diff --git a/apps/server/api/src/services/notify.service.ts b/apps/server/api/src/services/notify.service.ts index d015bece3..92d50f20f 100644 --- a/apps/server/api/src/services/notify.service.ts +++ b/apps/server/api/src/services/notify.service.ts @@ -9,7 +9,6 @@ import { NotificationsRepository } from "@/db/repositories/notifications.reposit import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import type { Database } from "@/db/types/index.js"; -import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { beginBackupStateWrite } from "@/services/backup-capture-lock.js"; import { badgeCount, @@ -34,7 +33,20 @@ import { logger } from "@/utils/logger.js"; * `/attention` route's body union — hooks can only report * `turn_complete`/`needs_attention`. */ -export type NotifyKind = "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final" | "maintenance"; +/** + * What happened. `grant_approval` is the ONE kind not attached to a subshell: + * a first-use key-grant request is waiting on the owner's answer (spec + * 2026-10-08 §6.2), delivered through {@link NotifyService.notifyGrantApprovalOwner} + * rather than `notifySubshell`, and it never reads or writes any subshell row. + */ +export type NotifyKind = + | "turn_complete" + | "needs_attention" + | "exited" + | "crashed" + | "crashed_final" + | "maintenance" + | "grant_approval"; const BODY: Record = { turn_complete: "Ready for your next message", @@ -43,6 +55,10 @@ const BODY: Record = { crashed: "Session stopped unexpectedly. Restarting…", crashed_final: "Session stopped unexpectedly", maintenance: "Stopped for node maintenance", + // Fixed text, named per the brief; carries no destination, no key, no + // fingerprint - only the opaque request id rides the tag/url (Global + // Constraints, spec 2026-10-08 §6.2). + grant_approval: "A key grant needs your approval", }; /** @@ -50,6 +66,9 @@ const BODY: Record = { * pane's follow-ups until the owner opens the pane, and only an event that * strictly OUTRANKS the unseen one rings through. `crashed_final` tops it — * the restart loop giving up is news even behind an unseen `crashed`. + * `grant_approval` sits beside `needs_attention`: it is not consulted by the + * grant send (no subshell row carries the ladder state), and the value exists + * to keep the Record total, exactly as the mobile copy table does. */ const PUSH_URGENCY: Record = { turn_complete: 1, @@ -58,6 +77,7 @@ const PUSH_URGENCY: Record = { crashed: 3, maintenance: 3, crashed_final: 4, + grant_approval: 2, }; /** @@ -70,6 +90,17 @@ export function buildNotificationPayload(row: { id: string; name: string }, kind return { title: row.name, body: BODY[kind], url: `/subshells/${row.id}`, tag: row.id }; } +/** + * The web-push payload for a first-use key-grant request (spec 2026-10-08 + * §6.2). FIXED text naming only the act, the app name as title, and the + * opaque request id as the tag - no destination, no key, no fingerprint, and + * nothing derived from the pending row beyond its own id (Global + * Constraints). The URL is the grants/approvals screen under SSH settings. + */ +export function buildGrantApprovalPayload(requestId: string) { + return { title: "Subshell", body: BODY.grant_approval, url: "/settings/ssh", tag: requestId }; +} + /** * Minimal seam over web-push so tests inject a fake. * @@ -136,11 +167,22 @@ export function createNotifyService(deps: NotifyServiceDeps) { * rows pruned only on the relay's DeviceNotRegistered verdict. A service * built without the device transport behaves exactly as it did before the * mobile app existed (invariant 2). + * + * Shaped by USER + SUBJECT id, not by a subshell row, because the one + * non-subshell kind (a first-use `grant_approval`, spec 2026-10-08 §6.2) + * rides the SAME device-token machinery: its subject is the opaque request + * id and it has no waiting-stamp (`waitingSince: null` reads as "not + * stamped", which `badgeCount` already spells). */ /** @returns the number of relay-usable device tokens this fan-out targeted */ - async function notifyDevices(row: SubshellTable, kind: NotifyKind): Promise { + async function fanOutExpo( + userId: string, + subjectId: string, + kind: NotifyKind, + waitingSince: string | null, + ): Promise { if (!deps.devices) return 0; - const enrolled = await deps.devices.listByUser(row.userId); + const enrolled = await deps.devices.listByUser(userId); if (enrolled.length === 0) return 0; // One pass partitions relay-usable rows from junk; the junk rows are ones // the relay can never use, so they go now rather than on every send. @@ -156,11 +198,11 @@ export function createNotifyService(deps: NotifyServiceDeps) { // unreachable — the blessed predicate comes from the registry (importing // it from subshell-manager would cycle: subshell-manager already imports // this module). - const counts = await subshellsRepo.countsByUser(row.userId, isNodeOffline); - const badge = badgeCount(counts.waiting, kind, row.waitingSince); + const counts = await subshellsRepo.countsByUser(userId, isNodeOffline); + const badge = badgeCount(counts.waiting, kind, waitingSince); const messages = buildExpoMessages( live.map((t) => t.token), - row.id, + subjectId, kind, badge, ); @@ -184,6 +226,37 @@ export function createNotifyService(deps: NotifyServiceDeps) { return live.length; } + /** + * Web-push fan-out for one user, the transport the subshell bell has always + * used. Returns the number of subscriptions ATTEMPTED (the unseen gate's + * "an attempt happened" test), pruning only the permanently-dead endpoints + * (403/404/410) the sender contract names. + */ + async function fanOutWebPush(userId: string, payload: object): Promise { + const subs = await deps.subs.listByUser(userId); + if (subs.length === 0) return 0; + const serialized = JSON.stringify(payload); + for (const sub of subs) { + try { + await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, serialized); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode; + // 404/410 = the endpoint is gone. 403 = the gateway rejects our + // VAPID JWT for this binding (Apple's BadJwtToken: the + // subscription was made against a different server key — e.g. + // after vapid.json rotation). Nothing we sign can ever heal it, + // so it joins the prune set; the client re-subscribes on its + // next enablePush (which now re-binds unconditionally). + if (status === 403 || status === 404 || status === 410) { + await deps.subs.deleteByEndpoint(sub.endpoint); + } else { + logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); + } + } + } + return subs.length; + } + return { async vapidPublicKey(): Promise { return (await keys()).publicKey; @@ -217,29 +290,8 @@ export function createNotifyService(deps: NotifyServiceDeps) { // The transports are independent. Start the device fan-out NOW, before // the sequential web-push loop, so a phone never waits behind N HTTPS // round-trips to browser push gateways (review, efficiency #6). - const deviceDelivery = notifyDevices(row, kind); - const subs = await deps.subs.listByUser(row.userId); - if (subs.length > 0) { - const payload = JSON.stringify(buildNotificationPayload(row, kind)); - for (const sub of subs) { - try { - await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, payload); - } catch (err) { - const status = (err as { statusCode?: number }).statusCode; - // 404/410 = the endpoint is gone. 403 = the gateway rejects our - // VAPID JWT for this binding (Apple's BadJwtToken: the - // subscription was made against a different server key — e.g. - // after vapid.json rotation). Nothing we sign can ever heal it, - // so it joins the prune set; the client re-subscribes on its - // next enablePush (which now re-binds unconditionally). - if (status === 403 || status === 404 || status === 410) { - await deps.subs.deleteByEndpoint(sub.endpoint); - } else { - logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); - } - } - } - } + const deviceDelivery = fanOutExpo(row.userId, row.id, kind, row.waitingSince); + const attempted = await fanOutWebPush(row.userId, buildNotificationPayload(row, kind)); const liveDevices = await deviceDelivery; // The urgency sticks only behind an ATTEMPTED delivery: the condition // is targets existing (a send was made to at least one), not a gateway @@ -247,7 +299,7 @@ export function createNotifyService(deps: NotifyServiceDeps) { // received nothing and was attempted nothing — such an owner must not // have the pane's future escalations silenced by an attempt that never // existed. - if (subs.length > 0 || liveDevices > 0) { + if (attempted > 0 || liveDevices > 0) { await subshellsRepo.update(subshellId, { lastPushUrgency: urgency }); publishLive({ kind: "subshell.changed", id: subshellId }); } @@ -256,6 +308,34 @@ export function createNotifyService(deps: NotifyServiceDeps) { logger.withError(err).warn(`notifySubshell(${subshellId}, ${kind}) failed`); } }, + /** + * Ring the OWNER's devices about one outstanding first-use key-grant + * request (spec 2026-10-08 §6.2, the owner-addressed sibling of + * `notifySubshell` whose surface is subshell-row-shaped). The subject is + * the opaque request id; the copy is FIXED text naming the act, and the + * message carries no destination, no key, and no fingerprint (Global + * Constraints). The per-user master switch still gates it (a user who + * muted all pushes stays muted); there is no per-pane bell to consult, + * and the unseen gate does not apply (no subshell row carries the state). + * Best-effort by the same contract as `notifySubshell`: a failed push is + * one log line, the durable pending row is the real record. + */ + async notifyGrantApprovalOwner(ownerUserId: string, requestId: string): Promise { + try { + if (!(await userMetaRepo.getNotifyEnabled(ownerUserId))) return; + // Same parallel-transport posture as notifySubshell (efficiency #6). + const deviceDelivery = fanOutExpo(ownerUserId, requestId, "grant_approval", null); + const attempted = await fanOutWebPush(ownerUserId, buildGrantApprovalPayload(requestId)); + const liveDevices = await deviceDelivery; + if (attempted === 0 && liveDevices === 0) { + // No targets at all: nothing was attempted. The approvals queue is + // the delivery that cannot be missed; the log only notes silence. + logger.debug(`grant_approval notify: no push targets for the owner of request ${requestId}`); + } + } catch (err) { + logger.withError(err).warn(`notifyGrantApprovalOwner(${requestId}) failed`); + } + }, }; } diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 07df9a5f5..8deea36c9 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -227,6 +227,41 @@ export enum BackendErrorCodes { * code, exactly as it drops a `view` grantee's keystrokes. */ SSH_OWNER_INPUT_ONLY = "SSH_OWNER_INPUT_ONLY", + /** + * A relay launch the grant layer refused for lack of a standing grant + * (spec 2026-10-08 §6.2): a first-use approval was RECORDED and asked of + * the key home's owner, and the launch fails fast rather than hang a pane + * on a human. The message names the asking machine and the remedy (answer + * the approval, launch again); never the key or destination beyond what + * the pending row already discloses. + */ + SSH_GRANT_APPROVAL_REQUIRED = "SSH_GRANT_APPROVAL_REQUIRED", + /** + * A grant approval selecting MORE than `SSH_MAX_GRANT_FINGERPRINTS` keys + * (spec 2026-10-08 §5.4): a hard, loud, named refusal on the grants + * surface, NEVER a silent truncation of the operator's selection. The cap + * is law. + */ + SSH_GRANT_KEYS_OVER_LIMIT = "SSH_GRANT_KEYS_OVER_LIMIT", + /** + * A grant fingerprint outside the `SHA256:` display grammar (agent-wire + * base64; `isSshGrantFingerprints`). Refused before any write; the value + * itself is never echoed into the message. + */ + SSH_GRANT_KEYS_INVALID = "SSH_GRANT_KEYS_INVALID", + /** + * The relay broker refused to open (spec 2026-10-08 §5.3): quota, gate, + * handshake, or a refused side. The pane was not launched; the message + * names which door. No grant was consumed and no session exists. + */ + SSH_RELAY_OPEN_FAILED = "SSH_RELAY_OPEN_FAILED", + /** + * A relay launch aimed at a machine that has no registered relay identity + * (spec 2026-10-08 §4.2/§4.3: no `node:` record, or its signing slot is + * still empty). The remedy is on the machine: re-enroll, or let its next + * `ready` fill the slot. + */ + SSH_RELAY_IDENTITY_MISSING = "SSH_RELAY_IDENTITY_MISSING", } /** @@ -493,4 +528,24 @@ export const BackendErrorCodeDefs = { message: "SSH panes accept input from their owner only", statusCode: 403, }, + [BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED]: { + message: "This connection needs a key-grant approval first", + statusCode: 409, + }, + [BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT]: { + message: "Too many keys selected for one grant", + statusCode: 400, + }, + [BackendErrorCodes.SSH_GRANT_KEYS_INVALID]: { + message: "That key fingerprint is not in the SHA256 form grants store", + statusCode: 400, + }, + [BackendErrorCodes.SSH_RELAY_OPEN_FAILED]: { + message: "The relay refused to open; the pane was not launched", + statusCode: 409, + }, + [BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING]: { + message: "That machine has no registered relay identity yet", + statusCode: 409, + }, }; From c4904d7e7eeeca87f33dfdacd7ee9378539b91ec Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:45:09 -0700 Subject: [PATCH 113/170] feat(ssh-grants): the grant layer service - first use, approval, match, revoke The authorization layer (spec 2026-10-08 SS6): requestFirstUse writes the durable pending row + owner notify (a relaunch FINDS the row, no second ask); approve/deny answer on a compare-and-set (the raced approver sees the named 409); matchGrant applies the selector grammar (concrete host or '*' globs, most specific wins, ties to the oldest); revoke deletes the row then cuts every live relay through closeForGrant(grant-revoked) - the T8 hook closes here. prepareRelayLeg gives openRelay its first production caller with decision 3's pre/post liveness re-checks, so a revoke landing mid-open cuts the fresh session instead of orphaning it (T8 NIT1). Audit metadata is ids/hosts/fingerprint-COUNT only, and the suite pins that the whole trail carries no fingerprint VALUE. Two more named codes: SSH_GRANT_SELECTOR_ INVALID, SSH_GRANT_ALREADY_ANSWERED. 21 service tests, all seams injected (clock/broker/notify): nothing waits, nothing dials, nothing real is contacted. --- .../__tests__/ssh-grants.service.test.ts | 674 +++++++++++++++++ .../api/src/services/ssh-grants.service.ts | 709 ++++++++++++++++++ packages/backend-errors/src/error-codes.ts | 24 + 3 files changed, 1407 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/ssh-grants.service.test.ts create mode 100644 apps/server/api/src/services/ssh-grants.service.ts diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts new file mode 100644 index 000000000..4e1510f72 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -0,0 +1,674 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { + approveGrant, + createGrant, + denyGrant, + listGrantRequests, + matchGrant, + normalizeSshGrantSelector, + prepareRelayLeg, + requestFirstUse, + revokeGrant, + type SshGrantView, + setSshGrantsDepsForTests, + sshGrantSelectorMatches, + sweepExpiredGrantRequests, + updateGrant, +} from "@/services/ssh-grants.service.js"; +import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; + +/** + * The grant authorization layer (spec 2026-10-08 §6; Task 10). What this + * suite exists to pin, property by property: + * + * - FIRST USE: a launch with no standing grant records ONE durable pending + * row, notifies the owner once, and a relaunch FINDS that row (no second + * approval, no second notification). The row is DB state: a "restart" + * (deps swapped, nothing else) still finds it. + * - APPROVE writes the grant with EXACTLY the chosen fingerprints, flips the + * request, and audits `approve` + `create` whose metadata names the COUNT, + * never a fingerprint string. A selection over SSH_MAX_GRANT_FINGERPRINTS + * is the named red error with the input untouched, never a truncation. + * - DENY audits only. EXPIRY sweeps the row to `expired` and writes NOTHING. + * - MATCH: owner + key home + selector against the resolved host; globs are + * policy; most specific wins, ties to the OLDEST grant. + * - REVOKE cuts live relays: closeForGrant(grantId, "grant-revoked") is the + * revoke's own act (the T8 hook closing here). + * - THE LAUNCH LEG: match -> openRelay (peers from the identities store) -> + * socket; no grant -> requestFirstUse + the named fail-fast, and the + * openRelay NEVER happens; a grant revoked mid-open is found by the + * post-open liveness re-check and cut, not orphaned (T8 NIT1). + * + * The clock, the broker, and the notification sink are the service's own + * injected seams (the `getNodeWsDeps` pattern) - nothing waits, nothing dials. + */ + +const repo = new SshGrantsRepository(db); +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `grants-svc-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("grants-svc-1"), + role: "user", + }); +} + +let owner: string; +let stranger: string; +const NODE_A = "gsvc-node-a"; +const NODE_B = "gsvc-node-b"; +const nodes = new NodesRepository(db); + +/** The one destination this suite routes through. */ +const HOST = "git.example.test"; + +/** A fake clock the sweep and the expiry windows read; setT() moves it. */ +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); +const nowIso = () => new Date(clockMs).toISOString(); + +interface FakeCall { + grantId: string; + reason: string; +} +let openCalls: unknown[] = []; +let closeGrantCalls: FakeCall[] = []; +let notifyCalls: { ownerUserId: string; requestId: string }[] = []; + +function installFakeDeps(brokerOverrides: Partial = {}) { + const broker = { + openRelay: async (input: unknown) => { + openCalls.push(input); + return { + relayId: "relay-1", + ref: "ref-1", + expiresAt: new Date(clockMs + 30_000).toISOString(), + socketPath: "/home/scripted/.subshell/ssh/pane/agent.sock", + }; + }, + closeForGrant: async (grantId: string, reason: string) => { + closeGrantCalls.push({ grantId, reason }); + return 1; + }, + ...brokerOverrides, + } as unknown as RelayBroker; + setSshGrantsDepsForTests({ + nowIso, + broker: () => broker, + notifyGrantApproval: (ownerUserId, requestId) => { + notifyCalls.push({ ownerUserId, requestId }); + }, + }); +} + +const FP = (n: number) => `SHA256:${"a".repeat(43).slice(0, 43 - String(n).length)}${String(n).padStart(2, "0")}`; +const FPS = [FP(1), FP(2)]; + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); + await nodes.create({ id: NODE_A, ownerUserId: owner, name: "key home", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_A, { on: true, changedAt: nowIso() }); + await nodes.create({ id: NODE_B, ownerUserId: owner, name: "connector", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_B, { on: true, changedAt: nowIso() }); + const identities = new IdentitiesRepository(db); + await identities.register({ + principalId: `node:${NODE_A}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}', + displayName: "A", + }); + await identities.register({ + principalId: `node:${NODE_B}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}', + displayName: "B", + }); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + await db.deleteFrom("identities").execute(); + for (const id of [NODE_A, NODE_B]) await nodes.deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(() => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + openCalls = []; + closeGrantCalls = []; + notifyCalls = []; + installFakeDeps(); +}); + +async function cleanTier() { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); +} + +async function latestAudit(action: string): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", action) + .orderBy("createdAt", "desc") + .limit(1) + .execute(); + return rows[0] ? (JSON.parse(rows[0].metadataJson ?? "{}") as Record) : {}; +} + +describe("ssh-grants.service", () => { + beforeEach(async () => { + await cleanTier(); + }); + + describe("requestFirstUse", () => { + it("creates the pending row, notifies the owner once, and audits the ask (ids + destination, nothing else)", async () => { + const answer = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "pane-ask-1", + }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + const row = await repo.getRequest(owner, answer.value.requestId); + expect(row).toBeDefined(); + expect(row?.status).toBe("pending"); + expect(row?.keyHomeNodeId).toBe(NODE_A); + expect(row?.bNodeId).toBe(NODE_B); + expect(row?.paneId).toBe("pane-ask-1"); + // setup-key scale (24 h): the deadline is a day out from the ask. + if (!row) throw new Error("pending row vanished"); + expect(Date.parse(row.expiresAt) - Date.parse(row.createdAt)).toBe(24 * 60 * 60 * 1000); + expect(notifyCalls).toEqual([{ ownerUserId: owner, requestId: answer.value.requestId }]); + const audit = await latestAudit("node.ssh_grant.request"); + expect(audit.requestId).toBe(answer.value.requestId); + expect(audit.paneId).toBe("pane-ask-1"); + expect(audit.aNodeId).toBe(NODE_A); + expect(audit.bNodeId).toBe(NODE_B); + expect(audit.destination).toBe(HOST); + }); + + it("a relaunch FINDS the pending row: same id, no second row, no second notify, no second audit", async () => { + const first = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + clockMs += 60_000; + const again = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p2", + }); + expect(again.ok).toBe(true); + if (!again.ok) return; + expect(again.value.requestId).toBe(first.value.requestId); + expect(again.value.reused).toBe(true); + expect(notifyCalls).toHaveLength(1); + const rows = await db.selectFrom("sshGrantRequests").selectAll().execute(); + expect(rows).toHaveLength(1); + const asks = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_grant.request") + .execute(); + expect(asks).toHaveLength(1); + }); + + it("survives a simulated restart: fresh deps (nothing else changed) still find the standing pending row", async () => { + const first = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + // "Restart": drop and reinstall the seam - only the DB remains. + setSshGrantsDepsForTests(null); + notifyCalls = []; + installFakeDeps(); + const after = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(after.ok).toBe(true); + if (!after.ok) return; + expect(after.value.requestId).toBe(first.value.requestId); + expect(notifyCalls).toHaveLength(0); + }); + }); + + describe("approval cap and shape", () => { + let requestRef = ""; + beforeEach(async () => { + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + if (asked.ok) requestRef = asked.value.requestId; + }); + + it("selecting more than SSH_MAX_GRANT_FINGERPRINTS keys is the named red error, never a truncation", async () => { + const over = Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => FP(i + 100)); + const refusal = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: over }); + expect(refusal.ok).toBe(false); + if (refusal.ok) return; + expect(refusal.refusal).toMatchObject({ status: 400, code: BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT }); + // Nothing was written: the request still stands pending, no grant row. + expect((await repo.getRequest(owner, requestRef))?.status).toBe("pending"); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + }); + + it("a fingerprint outside the SHA256 grammar is refused by its own code; duplicates do not count twice", async () => { + const bad = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: ["not-a-fingerprint"], + }); + expect(bad.ok).toBe(false); + if (bad.ok) return; + expect(bad.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_INVALID); + // 8 entries where 7 are the same key: the UNIQUE selection fits under the + // cap, and the stored grant carries the deduped set. + const dupes = [...FPS, FP(2), FP(2), FP(2), FP(2), FP(2), FP(2)]; + const eight = [...Array.from({ length: 8 }, (_, i) => FP(i + 200))]; + const deduped = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: dupes.slice(0, 7), + }); + expect(deduped.ok).toBe(true); + if (!deduped.ok) return; + expect(new Set(deduped.value.grant.fingerprints).size).toBe(deduped.value.grant.fingerprints.length); + // exactly 8 fits; 9 does not (recompute on a fresh request). + await repo.markRequestStatus(owner, requestRef, "approved", "pending"); + await db.deleteFrom("sshKeyGrants").execute(); + const exact = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: eight }); + expect(exact.ok).toBe(true); + await db.deleteFrom("sshKeyGrants").execute(); + const one = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: [...eight, FP(299)] }); + expect(one.ok).toBe(false); + }); + + it("approve writes the grant (createdVia first-use), answers the request, and audits COUNTS, never fingerprint strings", async () => { + const approved = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: FPS, + name: "work git", + }); + expect(approved.ok).toBe(true); + if (!approved.ok) return; + expect(approved.value.grant.createdVia).toBe("first-use"); + expect(approved.value.grant.fingerprints).toEqual(FPS); + expect(approved.value.grant.resolvedSelector).toBe(HOST); + expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprintsCount).toBe(2); + expect(create.via).toBe("first-use"); + const approve = await latestAudit("node.ssh_grant.approve"); + expect(approve.grantId).toBe(approved.value.grant.id); + expect(approve.fingerprintsCount).toBe(2); + // The Global Constraint, read off the WHOLE trail: no fingerprint VALUE + // in any audit row this act wrote. + const all = await db.selectFrom("auditEvents").select("metadataJson").execute(); + for (const row of all) { + for (const fp of FPS) expect(row.metadataJson).not.toContain(fp); + } + }); + + it("a second answer to the same request is the named 409 (a raced or repeat approver)", async () => { + const first = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: FPS }); + expect(first.ok).toBe(true); + const second = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: FPS }); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.refusal.status).toBe(409); + }); + + it("a foreign owner's request is absent: 404, and the row stands unanswered", async () => { + const denied = await denyGrant({ ownerUserId: stranger, requestId: requestRef }); + expect(denied.ok).toBe(false); + if (denied.ok) return; + expect(denied.refusal.status).toBe(404); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("pending"); + }); + + it("deny writes the deny audit and NO grant; expiry sweeps pending rows and writes NOTHING", async () => { + const denied = await denyGrant({ ownerUserId: owner, requestId: requestRef }); + expect(denied.ok).toBe(true); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("denied"); + const audit = await latestAudit("node.ssh_grant.deny"); + expect(audit.requestId).toBe(requestRef); + + // Fresh ask, then push the clock past the deadline and sweep. + await cleanTier(); + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p9", + }); + expect(asked.ok).toBe(true); + if (!asked.ok) return; + const beforeSweep = await db.selectFrom("auditEvents").select("id").execute(); + clockMs += 24 * 60 * 60 * 1000 + 1_000; + const swept = await sweepExpiredGrantRequests(); + expect(swept).toBe(1); + expect((await repo.getRequest(owner, asked.value.requestId))?.status).toBe("expired"); + const afterSweep = await db.selectFrom("auditEvents").select("id").execute(); + expect(afterSweep).toHaveLength(beforeSweep.length); // expiry writes NOTHING + // And an expired row no longer dedups: a relaunch asks a NEW question. + notifyCalls = []; + const again = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p9", + }); + expect(again.ok).toBe(true); + if (!again.ok) return; + expect(again.value.requestId).not.toBe(asked.value.requestId); + expect(notifyCalls).toHaveLength(1); + }); + }); + + describe("matchGrant", () => { + async function grant(opts: { + selector: string; + fps?: string[]; + via?: "first-use" | "manual"; + at?: string; + }): Promise { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "g", + selector: opts.selector, + fingerprints: opts.fps ?? FPS, + }); + if (!created.ok) throw new Error("grant fixture refused"); + if (opts.at) { + await db + .updateTable("sshKeyGrants") + .set({ createdAt: opts.at }) + .where("id", "=", created.value.grant.id) + .execute(); + } + return created.value.grant; + } + + it("exact host matches; a glob matches under it; the other owner and the other key home never match", async () => { + const exact = await grant({ selector: HOST }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(exact.id); + expect(await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: "other.example.test" })).toBeNull(); + const globs = await grant({ selector: "*.example.test" }); + await db.deleteFrom("sshKeyGrants").where("id", "=", exact.id).execute(); + expect( + (await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: "deep.git.example.test" }))?.id, + ).toBe(globs.id); + expect(await matchGrant({ ownerUserId: stranger, aNodeId: NODE_A, resolvedHost: "git.example.test" })).toBeNull(); + expect(await matchGrant({ ownerUserId: owner, aNodeId: NODE_B, resolvedHost: HOST })).toBeNull(); + }); + + it("most specific wins, and equal specificity ties to the OLDEST grant", async () => { + await grant({ selector: "*.example.test", at: "2026-01-01T00:00:00.000Z" }); + const concrete = await grant({ selector: HOST, at: "2026-02-01T00:00:00.000Z" }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(concrete.id); + // Two matching selectors of EQUAL specificity (identical globs here): + // the tie breaks to the older grant, §6.3's stated rule. + await db.deleteFrom("sshKeyGrants").execute(); + const older = await grant({ selector: "*.example.test", at: "2026-01-01T00:00:00.000Z" }); + await grant({ selector: "*.example.test", at: "2026-02-01T00:00:00.000Z" }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(older.id); + }); + + it("a matching grant makes the launch leg open the relay with the grant's facts and the STORED peers, and hand back the socket", async () => { + const g = await grant({ selector: HOST }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "pane-1", + }); + expect(leg.ok).toBe(true); + if (!leg.ok) return; + expect(leg.value.grantId).toBe(g.id); + expect(openCalls).toHaveLength(1); + const call = openCalls[0] as Record; + expect(call.grantId).toBe(g.id); + expect(call.fingerprints).toEqual(FPS); + expect(call.paneId).toBe("pane-1"); + expect(call.aNode).toBe(NODE_A); + expect(call.bNode).toBe(NODE_B); + // The peer halves come from the identities store, not from the caller. + expect((call.aPeer as Record).signingPublicKey).toContain("CCC"); + expect((call.bPeer as Record).encryptionPublicJwk).toContain("EEE"); + expect(typeof leg.value.socketPath).toBe("string"); + }); + + it("NO grant: the leg asks (requestFirstUse), refuses with the named code, and NEVER opens", async () => { + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "pane-ask", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED }); + expect(openCalls).toHaveLength(0); + expect(notifyCalls).toHaveLength(1); // the ask happened exactly as first-use prescribes + // The message names the machine and the remedy, never a key or secret. + expect(leg.refusal.message).toContain("key home"); + expect(leg.refusal.message.toLowerCase()).toContain("approve"); + }); + + it("a machine with no registered signing half refuses SSH_RELAY_IDENTITY_MISSING before anything opens", async () => { + await grant({ selector: HOST }); + await db + .updateTable("identities") + .set({ signingPublicKey: null }) + .where("principalId", "=", `node:${NODE_B}`) + .execute(); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING); + expect(openCalls).toHaveLength(0); + await db + .updateTable("identities") + .set({ signingPublicKey: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}' }) + .where("principalId", "=", `node:${NODE_B}`) + .execute(); + }); + + it("the broker's refusal comes back as the named SSH_RELAY_OPEN_FAILED and launches nothing", async () => { + await grant({ selector: HOST }); + installFakeDeps({ + openRelay: (async () => { + throw new SshRelayRefusal("quota", "relay open refused: node already holds 8 live relay sessions (max 8)"); + }) as RelayBroker["openRelay"], + }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + }); + + it("a revoke landing MID-OPEN is caught by the post-open liveness re-check and the fresh session is cut, not orphaned (T8 NIT1)", async () => { + const g = await grant({ selector: HOST }); + installFakeDeps({ + openRelay: (async (input: unknown) => { + // The revoke's DB half lands between the match and the open. + await db + .deleteFrom("sshKeyGrants") + .where("id", "=", (input as { grantId: string }).grantId) + .execute(); + return { + relayId: "r", + ref: "f", + expiresAt: "2026-10-08T00:00:30.000Z", + socketPath: "/x/agent.sock", + }; + }) as RelayBroker["openRelay"], + }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED); + expect(closeGrantCalls).toEqual([{ grantId: g.id, reason: "grant-revoked" }]); + }); + }); + + describe("the grants screen and revoke", () => { + it("create validates the selector and stores it normalized", async () => { + expect(normalizeSshGrantSelector(" Git.Example.TEST ")).toBe("git.example.test"); + expect(normalizeSshGrantSelector("*")).toBeNull(); // names nothing concrete + expect(normalizeSshGrantSelector("a b")).toBeNull(); + expect(normalizeSshGrantSelector("bad\nhost")).toBeNull(); + expect(normalizeSshGrantSelector("**a**")).toBeNull(); // doubled wildcards are not a host pattern + expect(normalizeSshGrantSelector(".example.test")).toBeNull(); + const refused = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "n", + selector: "bad host", + fingerprints: FPS, + }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal.status).toBe(400); + }); + + it("the selector matcher treats * as the only wildcard and lowercases the host", () => { + expect(sshGrantSelectorMatches("*.example.test", "GIT.EXAMPLE.TEST")).toBe(true); + expect(sshGrantSelectorMatches("git.*.test", "git.example.test")).toBe(true); + expect(sshGrantSelectorMatches("git.example.test", "evilgit.example.test")).toBe(false); + expect(sshGrantSelectorMatches("a.b", "axb")).toBe(false); // the DOT is literal, not the regex dot + }); + + it("update edits name and selector and audits the change (no fingerprint rewrite path exists)", async () => { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "one", + selector: HOST, + fingerprints: FPS, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const updated = await updateGrant({ + ownerUserId: owner, + grantId: created.value.grant.id, + selector: "*.example.test", + }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.value.grant.resolvedSelector).toBe("*.example.test"); + expect(updated.value.grant.fingerprints).toEqual(FPS); // selection is immutable through edit + const audit = await latestAudit("node.ssh_grant.update"); + expect(audit.grantId).toBe(created.value.grant.id); + expect(audit.fingerprintsCount).toBe(2); + }); + + it("revoke deletes the row, cuts every live relay through closeForGrant, and audits the count", async () => { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "revoke me", + selector: HOST, + fingerprints: FPS, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const revoked = await revokeGrant({ ownerUserId: owner, grantId: created.value.grant.id }); + expect(revoked.ok).toBe(true); + if (!revoked.ok) return; + expect(closeGrantCalls).toEqual([{ grantId: created.value.grant.id, reason: "grant-revoked" }]); + expect(await repo.getGrant(owner, created.value.grant.id)).toBeUndefined(); + const audit = await latestAudit("node.ssh_grant.delete"); + expect(audit.grantId).toBe(created.value.grant.id); + expect(audit.relaysClosed).toBe(1); + // A foreign id is the same 404 as an absent one, and cuts nothing. + closeGrantCalls = []; + const foreign = await revokeGrant({ ownerUserId: stranger, grantId: created.value.grant.id }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + expect(closeGrantCalls).toHaveLength(0); + }); + + it("the queue read reports only the caller's rows", async () => { + await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p", + }); + expect((await listGrantRequests({ ownerUserId: owner })).length).toBe(1); + expect((await listGrantRequests({ ownerUserId: stranger })).length).toBe(0); + }); + }); +}); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts new file mode 100644 index 000000000..e61e2318c --- /dev/null +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -0,0 +1,709 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshGrantFingerprints, SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { type NewSshGrantRequest, SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { getNotifyService } from "@/services/notify.service.js"; +import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The key-grant authorization layer (spec 2026-10-08 §6; migration 0050). + * This module is the ONLY place a grant is decided, asked, answered, edited, + * or revoked; the launch path and the routes are its callers. + * + * **The flow the layer implements** (§6.2/§6.3): a relay launch asks + * {@link matchGrant} first. A standing grant answers with the selection; + * nothing else does, so {@link prepareRelayLeg} opens a broker session only + * under a row that existed at the match read and still exists after the open + * (the liveness re-check is decision 3's answer to T8 NIT1: a revoke landing + * between the match and `openRelay` finds no orphan, because the fresh + * session is cut with the named reason and the launch refuses). No grant: + * {@link requestFirstUse} records the durable pending row, notifies the + * owner, and the launch fails fast with the named refusal - never a pane + * hanging on a human. + * + * **The owner model** (decision 5, spec §6.2): a grant and its requests are + * owned by the key home's owner, and the launch gates A through `nodeCanSsh` + * before this layer is ever consulted - SSH is owner-reserved on agent nodes, + * so the asking user, the row's owner, and the approving operator are one + * account. The routes render foreign ids as the 404 the ownership axis + * demands; a foreign row is absent here, never "forbidden". + * + * **What never leaves this layer's row** (Global Constraints): fingerprint + * VALUES. They are public identifiers and they live in the grant column, but + * they appear in NO audit metadata (the COUNT is the law), NO notification + * (fixed copy + the opaque request id), and NO log line this file writes. + * + * Everything outside the module is an injected seam (the `getNodeWsDeps` + * pattern): the clock, the relay broker, and the notification sink. The + * production defaults sit at the bottom; tests replace the whole trio. + */ + +/** How long an unanswered first-use request stays answerable: setup-key scale (24 h). */ +export const GRANT_REQUEST_TTL_MS = 24 * 60 * 60 * 1000; + +const repo = new SshGrantsRepository(db); + +/** The service's world. Production at {@link defaultGrantsDeps}; tests install a fake. */ +export interface SshGrantsDeps { + /** The one clock: row stamps, dedup cuts, the expiry sweep. */ + nowIso(): string; + /** The relay broker the revoke hook and the launch leg reach (the module singleton). */ + broker(): RelayBroker; + /** Ring the owner about one pending request (fire-and-forget; the sink swallows its own failures). */ + notifyGrantApproval(ownerUserId: string, requestId: string): void; +} + +let depsOverride: SshGrantsDeps | null = null; + +function grantsDeps(): SshGrantsDeps { + if (depsOverride) return depsOverride; + return { + nowIso: () => new Date().toISOString(), + broker: () => getRelayBroker(), + notifyGrantApproval: (ownerUserId, requestId) => { + // The notify surface is best-effort by its own contract; the durable + // pending row is the real record, so this call is never awaited. + getNotifyService() + .notifyGrantApprovalOwner(ownerUserId, requestId) + .catch((err: unknown) => logger.warn(`grant_approval notify dispatch failed: ${String(err)}`)); + }, + }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins first-use/notify, the broker hooks, and + * the clock without a live plane, socket, or real 24 h wait. + */ +export function setSshGrantsDepsForTests(deps: SshGrantsDeps | null): void { + depsOverride = deps; +} + +/* ------------------------------------------------------------------ */ +/* selector grammar: concrete host or `*` pattern, matched on hostnames */ +/* ------------------------------------------------------------------ */ + +/** + * Normalize one destination selector: trim, lowercase (hostnames are + * case-insensitive; the stored selector is compared against lowercased + * resolved hosts), and enforce the pattern grammar - `[a-z0-9*._-]`, must + * hold at least one literal character ("*" alone names nothing), no leading + * `-`/`.`, no doubled `*`, no `..`, no whitespace or control characters, at + * most {@link SSH_NAME_MAX_CHARS}. Parent §5.1: the selector is a concrete + * host or a host glob matched against the resolved destination hostname, as + * policy only, unrelated to the wildcard patterns discovery drops. + * + * @returns the normalized selector, or null for anything the grammar refuses. + */ +export function normalizeSshGrantSelector(value: string): string | null { + const s = value.trim().toLowerCase(); + if (s.length === 0 || s.length > SSH_NAME_MAX_CHARS) return null; + if (/\s/.test(s) || /\p{Cc}/u.test(s)) return null; + if (s.startsWith("-") || s.startsWith(".") || s.endsWith(".") || s.includes("..") || s.includes("**")) return null; + if (!/^[a-z0-9*][a-z0-9.*_-]*$/.test(s)) return null; + if (!/[a-z0-9]/.test(s)) return null; + return s; +} + +/** Escape one glob segment: `*` is the only wildcard, the dot stays a dot. */ +function escapeSegment(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Match a normalized selector against one resolved destination hostname + * (§6.1: matched against the resolved hostname, not the alias the human + * typed). A selector with no `*` is an exact string equality; with wildcards, + * every `*` spans any run of characters and everything else is literal. + */ +export function sshGrantSelectorMatches(normalizedSelector: string, resolvedHost: string): boolean { + const host = resolvedHost.trim().toLowerCase(); + if (!normalizedSelector.includes("*")) return normalizedSelector === host; + const pattern = new RegExp(`^${normalizedSelector.split("*").map(escapeSegment).join(".*")}$`); + return pattern.test(host); +} + +/** Specificity for the §6.3 tie-break: fewer wildcards first, then more literal characters. */ +function selectorSpecificity(selector: string): { wildcards: number; literals: number } { + return { + wildcards: (selector.match(/\*/g) ?? []).length, + literals: selector.replace(/\*/g, "").length, + }; +} + +/* ------------------------------------------------------------------ */ +/* views: the row with its JSON columns parsed */ +/* ------------------------------------------------------------------ */ + +/** One grant row as the API and the launch leg read it: fingerprints parsed, not a JSON string. */ +export interface SshGrantView { + id: string; + name: string; + keyHomeNodeId: string; + resolvedSelector: string; + fingerprints: string[]; + createdVia: SshGrantCreatedVia; + createdAt: string; + updatedAt: string; +} + +/** One first-use request row as the approvals screen reads it. */ +export interface SshGrantRequestView { + id: string; + keyHomeNodeId: string; + resolvedSelector: string; + requestedFingerprints: string[] | null; + paneId: string; + bNodeId: string; + expiresAt: string; + status: SshGrantRequestStatus; + createdAt: string; +} + +function parseFingerprintArray(stored: string): string[] { + try { + const parsed: unknown = JSON.parse(stored); + return Array.isArray(parsed) && parsed.every((f) => typeof f === "string") ? (parsed as string[]) : []; + } catch { + return []; + } +} + +function grantView(row: SshKeyGrantTable): SshGrantView { + return { ...row, fingerprints: parseFingerprintArray(row.fingerprints) }; +} + +/* ------------------------------------------------------------------ */ +/* fingerprint selection: cap and grammar, law first */ +/* ------------------------------------------------------------------ */ + +/** The refusal union the grant surface returns (a subset of the ssh-launch one; same render path). */ +type SshRefusalNarrow = { status: 400 | 404 | 409; code: BackendErrorCodes; message: string }; + +/** The grant surface's answer union: a value, or the refusal the route returns as-is. */ +export type SshGrantAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusalNarrow }; + +function granted(value: T): SshGrantAnswer { + return { ok: true, value }; +} + +function refused(refusal: SshRefusalNarrow): SshGrantAnswer { + return { ok: false, refusal }; +} + +const NOT_FOUND_REQUEST = { + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "Grant request not found", +} as const; + +const NOT_FOUND_GRANT = { + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "Grant not found", +} as const; + +const ALREADY_ANSWERED = { + status: 409, + code: BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED, + message: "That grant request has already been answered.", +} as const; + +/* ------------------------------------------------------------------ */ +/* audit: ids, hosts, COUNTS - the §9 shape, never a fingerprint */ +/* ------------------------------------------------------------------ */ + +function grantAudit( + actorUserId: string, + action: string, + aNodeId: string, + metadata: Record, +): Promise { + const event: AuditEventInput = { + actorUserId, + action, + targetType: "node", + targetId: aNodeId, + metadataJson: JSON.stringify(metadata), + }; + return audit(event); +} + +/* ------------------------------------------------------------------ */ +/* first use */ +/* ------------------------------------------------------------------ */ + +/** + * Record the first-use approval question (spec §6.2): the DURABLE pending row + * first, then the owner-addressed notify, then the launch's caller fails it + * fast. A standing pending row for the same (owner, key home, selector) is + * FOUND, not duplicated (§6.2: "a re-launch simply finds the pending row"), + * and the found path sends no second notification and no second audit. + * Does NOT open a relay - that is {@link prepareRelayLeg} on the next launch. + */ +export async function requestFirstUse(args: { + ownerUserId: string; + aNodeId: string; + bNodeId: string; + resolvedSelector: string; + paneId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const existing = await repo.findPendingRequest(args.ownerUserId, args.aNodeId, args.resolvedSelector, now); + if (existing) return granted({ requestId: existing.id, reused: true }); + const request: NewSshGrantRequest = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + keyHomeNodeId: args.aNodeId, + resolvedSelector: args.resolvedSelector, + requestedFingerprints: null, // the approver selects; the asking pane proposes nothing (spec §6.2) + paneId: args.paneId, + bNodeId: args.bNodeId, + expiresAt: new Date(Date.parse(now) + GRANT_REQUEST_TTL_MS).toISOString(), + status: "pending", + createdAt: now, + }; + await repo.insertRequest(request); + await grantAudit(args.ownerUserId, "node.ssh_grant.request", args.aNodeId, { + requestId: request.id, + paneId: args.paneId, + aNodeId: args.aNodeId, + bNodeId: args.bNodeId, + destination: args.resolvedSelector, + }); + grantsDeps().notifyGrantApproval(args.ownerUserId, request.id); + return granted({ requestId: request.id, reused: false }); +} + +/** + * The expiry sweep (spec §6.2: "expiry writes nothing at all"): mark every + * past-deadline pending row `expired`. No audit row, no grant, no + * notification; the boot/hourly hook in `index.ts` runs it on the same + * cadence as the signup queue it borrows, and the read paths call it lazily + * so an unanswered question flips state before anyone answers it. + * + * @returns the number of rows swept + */ +export async function sweepExpiredGrantRequests(): Promise { + return await repo.sweepExpiredRequests(grantsDeps().nowIso()); +} + +/* ------------------------------------------------------------------ */ +/* answering: approve / deny */ +/* ------------------------------------------------------------------ */ + +/** + * Answer a pending request YES (spec §6.2): validate the selection against + * the cap and the grammar FIRST, then flip the row `pending -> approved` on a + * compare-and-set (a raced approver or the sweep loses here and sees the 409, + * never a double grant), then write the standing grant with EXACTLY the + * chosen fingerprints, created via first-use. Audits BOTH facts: the answer + * (`approve`) and the row's birth (`create`, the same event the screen's own + * create writes, per §6.2), naming ids, destination, and the fingerprint + * COUNT only. + */ +export async function approveGrant(args: { + ownerUserId: string; + requestId: string; + fingerprints: readonly string[]; + name?: string; +}): Promise> { + const keys = await validateSelectionOf(args.fingerprints); + if (!keys.ok) return keys.answer; + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "approved"); + if (!flipped) return refused(ALREADY_ANSWERED); + const grant: SshKeyGrantTable = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + name: args.name?.trim() || row.resolvedSelector, + keyHomeNodeId: row.keyHomeNodeId, + resolvedSelector: row.resolvedSelector, + fingerprints: JSON.stringify(keys.value), + createdVia: "first-use", + createdAt: now, + updatedAt: now, + }; + await repo.insertGrant(grant); + await grantAudit(args.ownerUserId, "node.ssh_grant.approve", row.keyHomeNodeId, { + requestId: row.id, + grantId: grant.id, + destination: row.resolvedSelector, + fingerprintsCount: keys.value.length, // the COUNT; §10 law + }); + await grantAudit(args.ownerUserId, "node.ssh_grant.create", row.keyHomeNodeId, { + grantId: grant.id, + destination: row.resolvedSelector, + fingerprintsCount: keys.value.length, + via: "first-use", + }); + return granted({ grant: grantView(grant) }); +} + +/** + * Answer a pending request NO (spec §6.2): the audit row and nothing else - + * no grant, no pin, and the requesting pane's next relaunch simply asks + * again (a fresh question, since denial is an answer, not a standing state). + */ +export async function denyGrant(args: { + ownerUserId: string; + requestId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "denied"); + if (!flipped) return refused(ALREADY_ANSWERED); + await grantAudit(args.ownerUserId, "node.ssh_grant.deny", row.keyHomeNodeId, { + requestId: row.id, + paneId: row.paneId, + bNodeId: row.bNodeId, + destination: row.resolvedSelector, + }); + return granted({ requestId: row.id }); +} + +/* ------------------------------------------------------------------ */ +/* matching */ +/* ------------------------------------------------------------------ */ + +/** + * The §6.3 match: the owner's standing grants for the key home chosen at + * launch, selector-matched against the resolved destination hostname. Most + * specific selector wins; equal specificity ties to the OLDEST grant. B is + * not part of the predicate (a grant authorizes the owner's own connecting + * machines, §6.3), and a foreign or deleted grant is invisible here exactly + * as on the screen. + */ +export async function matchGrant(args: { + ownerUserId: string; + aNodeId: string; + resolvedHost: string; +}): Promise { + const rows = await repo.grantsFor(args.ownerUserId, args.aNodeId); + const host = args.resolvedHost.trim().toLowerCase(); + const matches = rows + .map((row) => ({ row, spec: selectorSpecificity(row.resolvedSelector) })) + .filter((m) => sshGrantSelectorMatches(m.row.resolvedSelector, host)); + if (matches.length === 0) return null; + matches.sort((a, b) => { + if (a.spec.wildcards !== b.spec.wildcards) return a.spec.wildcards - b.spec.wildcards; + if (a.spec.literals !== b.spec.literals) return b.spec.literals - a.spec.literals; + return a.row.createdAt < b.row.createdAt ? -1 : a.row.createdAt > b.row.createdAt ? 1 : 0; + }); + const first = matches[0]; + return first ? grantView(first.row) : null; +} + +/* ------------------------------------------------------------------ */ +/* the grants screen: create / edit / revoke */ +/* ------------------------------------------------------------------ */ + +/** Create a grant from the screen (spec §8; §6.2: the same row and event the approval writes). */ +export async function createGrant(args: { + ownerUserId: string; + aNodeId: string; + name: string; + selector: string; + fingerprints: readonly string[]; +}): Promise> { + const selector = normalizeSshGrantSelector(args.selector); + if (selector === null) return refused(SELECTOR_INVALID); + const keys = await validateSelectionOf(args.fingerprints); + if (!keys.ok) return keys.answer; + const now = grantsDeps().nowIso(); + const grant: SshKeyGrantTable = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + name: args.name.trim() || selector, + keyHomeNodeId: args.aNodeId, + resolvedSelector: selector, + fingerprints: JSON.stringify(keys.value), + createdVia: "manual", + createdAt: now, + updatedAt: now, + }; + await repo.insertGrant(grant); + await grantAudit(args.ownerUserId, "node.ssh_grant.create", args.aNodeId, { + grantId: grant.id, + destination: selector, + fingerprintsCount: keys.value.length, + via: "manual", + }); + return granted({ grant: grantView(grant) }); +} + +const SELECTOR_INVALID: SshRefusalNarrow = { + status: 400, + code: BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID, + message: "That grant selector is not a hostname pattern.", +}; + +/** + * Edit the screen's own fields: name and/or selector. The fingerprint + * SELECTION is immutable through edit - changing which keys serve is a + * revoke plus a fresh grant, so the standing selection always has exactly the + * audit trail that chose it (§6.2's posture extended to edits by the + * revoke-is-the-only-widening rule). + */ +export async function updateGrant(args: { + ownerUserId: string; + grantId: string; + name?: string; + selector?: string; +}): Promise> { + let selector: string | undefined; + if (args.selector !== undefined) { + const normalized = normalizeSshGrantSelector(args.selector); + if (normalized === null) return refused(SELECTOR_INVALID); + selector = normalized; + } + const existing = await repo.getGrant(args.ownerUserId, args.grantId); + if (!existing) return refused(NOT_FOUND_GRANT); + const updated = await repo.updateGrant(args.ownerUserId, args.grantId, { + name: args.name?.trim(), + resolvedSelector: selector, + updatedAt: grantsDeps().nowIso(), + }); + if (!updated) return refused(NOT_FOUND_GRANT); + await grantAudit(args.ownerUserId, "node.ssh_grant.update", updated.keyHomeNodeId, { + grantId: updated.id, + destination: updated.resolvedSelector, + fingerprintsCount: parseFingerprintArray(updated.fingerprints).length, + changed: [args.name !== undefined ? "name" : null, selector !== undefined ? "selector" : null].filter(Boolean), + }); + return granted({ grant: grantView(updated) }); +} + +/** + * Revoke, the instant both-ways cut (§6.3): the row goes FIRST (the raced + * launch's post-open liveness re-check then answers "gone"), then the broker + * tears down every live session that ran under this grant, with the named + * `grant-revoked` reason. This is the hook T8 left standing; without the + * second call a signing session would ride on after the operator said stop. + */ +export async function revokeGrant(args: { + ownerUserId: string; + grantId: string; +}): Promise> { + const existing = await repo.getGrant(args.ownerUserId, args.grantId); + if (!existing) return refused(NOT_FOUND_GRANT); + const deleted = await repo.deleteGrant(args.ownerUserId, args.grantId); + if (!deleted) return refused(NOT_FOUND_GRANT); // raced with itself; the row is gone either way + const relaysClosed = await grantsDeps().broker().closeForGrant(args.grantId, "grant-revoked"); + await grantAudit(args.ownerUserId, "node.ssh_grant.delete", existing.keyHomeNodeId, { + grantId: args.grantId, + destination: existing.resolvedSelector, + relaysClosed, + }); + return granted({ relaysClosed }); +} + +/** The grants screen list. */ +export async function listGrants(args: { ownerUserId: string }): Promise { + return (await repo.listGrants(args.ownerUserId)).map(grantView); +} + +/** The approvals queue (pending by default); the lazy sweep runs first. */ +export async function listGrantRequests(args: { + ownerUserId: string; + statuses?: readonly SshGrantRequestStatus[]; +}): Promise { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + return (await repo.listRequests(args.ownerUserId, args.statuses)).map((row) => ({ + id: row.id, + keyHomeNodeId: row.keyHomeNodeId, + resolvedSelector: row.resolvedSelector, + requestedFingerprints: row.requestedFingerprints === null ? null : parseFingerprintArray(row.requestedFingerprints), + paneId: row.paneId, + bNodeId: row.bNodeId, + expiresAt: row.expiresAt, + status: row.status, + createdAt: row.createdAt, + })); +} + +/* ------------------------------------------------------------------ */ +/* the launch leg: match -> open (or ask -> refuse) */ +/* ------------------------------------------------------------------ */ + +/** + * The relay launch's authorization leg, run by `sshLaunch` once B is gated, + * the destination resolved, and the pane id minted (decision 2: this is + * where `openRelay` finally gets its production caller): + * + * 1. {@link matchGrant} - a standing grant answers. + * 2. No grant: {@link requestFirstUse} + the named 409 fail-fast. Nothing + * was opened, nothing will hang; the pane never exists. + * 3. A grant: read BOTH machines' registered halves from the identities + * store (a slot still empty is the named identity refusal, with the + * §4.3 `ready` bootstrap as the remedy), re-check grant liveness, + * `openRelay`, then re-check liveness AGAIN: a revoke landing across the + * open cuts the fresh session with the named reason and refuses the + * launch, so no orphan rides under a dead grant (decision 3 / T8 NIT1). + * + * @returns the broker's byte-checked `socketPath` (the launch exports it as + * the scoped `SSH_AUTH_SOCK` through `sshRelayPaneEnv`) and the grant id. + */ +export async function prepareRelayLeg(args: { + viewerId: string; + aNode: { id: string; name: string }; + bNodeId: string; + resolvedHost: string; + paneId: string; +}): Promise> { + const grant = await matchGrant({ + ownerUserId: args.viewerId, + aNodeId: args.aNode.id, + resolvedHost: args.resolvedHost, + }); + if (!grant) { + const asked = await requestFirstUse({ + ownerUserId: args.viewerId, + aNodeId: args.aNode.id, + bNodeId: args.bNodeId, + resolvedSelector: args.resolvedHost, + paneId: args.paneId, + }); + const ref = asked.ok ? asked.value.requestId : ""; + return refused({ + status: 409, + code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED, + message: `Asked ${args.aNode.name} to approve signing with its keys${ref ? ` (request ${ref})` : ""}. Launch again once it is approved.`, + }); + } + const identities = new IdentitiesRepository(db); + const [rowA, rowB] = await Promise.all([ + identities.findByPrincipal(`node:${args.aNode.id}`), + identities.findByPrincipal(`node:${args.bNodeId}`), + ]); + // The two halves the broker pairs: the registered ES256 signing JWK and the + // ECDH-ES encryption JWK, both read from the identities store, never from + // the caller. A row without either half is the named refusal - the §4.3 + // `ready` bootstrap or a re-enroll is the only thing that fills a slot. + const peerHalf = (row: typeof rowA): RelayPeerKeys | null => + row && row.signingPublicKey !== null + ? { signingPublicKey: row.signingPublicKey, encryptionPublicJwk: row.publicKey } + : null; + const aPeer = peerHalf(rowA); + const bPeer = peerHalf(rowB); + if (!aPeer || !bPeer) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "That key home or connecting machine has not registered its relay identity yet. Re-enroll it, or wait for its next check-in to deliver the key.", + }); + } + // Liveness immediately BEFORE the open: the cheap read that makes the + // window this call cannot close (between match and here) at least start + // from a row that still exists. + const standing = await repo.getGrant(args.viewerId, grant.id); + if (!standing) { + return refused(GRANT_REVOKED_DURING_LAUNCH); + } + const broker = grantsDeps().broker(); + let socketPath: string; + try { + const opened = await broker.openRelay({ + grantId: grant.id, + fingerprints: grant.fingerprints, + paneId: args.paneId, + aNode: args.aNode.id, + bNode: args.bNodeId, + aPeer, + bPeer, + }); + socketPath = opened.socketPath; + } catch (err) { + if (err instanceof SshRelayRefusal) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + message: relayRefusalCopy(err.code), + }); + } + throw err; + } + // Liveness immediately AFTER the open (decision 3): a revoke that landed + // between the reads found an empty session map and cut nothing, so THIS + // call cuts the fresh session itself and the launch refuses. + if (!(await repo.getGrant(args.viewerId, grant.id))) { + await broker.closeForGrant(grant.id, "grant-revoked"); + return refused(GRANT_REVOKED_DURING_LAUNCH); + } + return granted({ socketPath, grantId: grant.id }); +} + +const GRANT_REVOKED_DURING_LAUNCH: SshRefusalNarrow = { + status: 409, + code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED, + message: "The key grant was revoked while this launch was opening. Launch again to ask for approval.", +}; + +/** Human copy per broker refusal code; ids only, never key material or sockets. */ +function relayRefusalCopy(code: SshRelayRefusal["code"]): string { + switch (code) { + case "quota": + return "That machine already carries its full share of live relay sessions. Wait for one to close and retry."; + case "handshake": + return "The key home or the connecting machine did not complete the relay handshake. Check both machines are online and retry."; + case "node-off": + return "SSH was switched off on one of the machines between the gate and the relay open."; + case "no-node": + case "no-datadir": + return "One of the machines is not fully connected; retry once it reports in."; + case "local-node": + return "The server host itself can be neither key home nor connecting machine for a relay."; + case "same-node": + return "The key home and the connecting machine are the same machine; this connection needs no relay."; + case "bad-socket-path": + return "The connecting machine answered a socket path the server could not verify. Nothing was launched."; + case "bad-pane-id": + case "bad-fingerprints": + return "The relay refused the session's identifiers as malformed; the launch was refused."; + } +} + +/* ------------------------------------------------------------------ */ +/* selection validation shared by approve and create */ +/* ------------------------------------------------------------------ */ + +type SelectionResult = { ok: true; value: string[] } | { ok: false; answer: SshGrantAnswer }; + +async function validateSelectionOf(fingerprints: readonly string[]): Promise { + if (!Array.isArray(fingerprints) || fingerprints.some((f) => typeof f !== "string")) + return { ok: false, answer: refused(invalidRefusal()) }; + const unique = [...new Set(fingerprints)]; + if (unique.length > SSH_MAX_GRANT_FINGERPRINTS) return { ok: false, answer: refused(overRefusal()) }; + if (!isSshGrantFingerprints(unique)) return { ok: false, answer: refused(invalidRefusal()) }; + return { ok: true, value: unique }; +} + +function overRefusal(): SshRefusalNarrow { + return { + status: 400, + code: BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT, + message: `A grant can carry at most ${SSH_MAX_GRANT_FINGERPRINTS} keys. Deselect some and try again; nothing was truncated.`, + }; +} + +function invalidRefusal(): SshRefusalNarrow { + return { + status: 400, + code: BackendErrorCodes.SSH_GRANT_KEYS_INVALID, + message: "Every selected key must be a SHA256 fingerprint exactly as the roster reports it.", + }; +} diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 8deea36c9..c01c7c28c 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -262,6 +262,22 @@ export enum BackendErrorCodes { * `ready` fill the slot. */ SSH_RELAY_IDENTITY_MISSING = "SSH_RELAY_IDENTITY_MISSING", + /** + * A grant destination selector the grammar refuses: blank, over 253 + * characters, leading `-`/`.`, doubled `*`, control characters or + * whitespace. A selector is a hostname pattern (concrete host or `*` + * globs) matched against resolved destination hostnames; it is refused + * before anything is stored. + */ + SSH_GRANT_SELECTOR_INVALID = "SSH_GRANT_SELECTOR_INVALID", + /** + * An approval question already answered (or already expired) when someone + * tried to answer it: the queue is compare-and-set, so a second approver or + * the sweep wins and this is the loud no the screen shows (spec 2026-10-08 + * §6.2 - the row records the whole lifecycle, the answer does not rewrite + * history). + */ + SSH_GRANT_ALREADY_ANSWERED = "SSH_GRANT_ALREADY_ANSWERED", } /** @@ -548,4 +564,12 @@ export const BackendErrorCodeDefs = { message: "That machine has no registered relay identity yet", statusCode: 409, }, + [BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID]: { + message: "That grant selector is not a hostname pattern", + statusCode: 400, + }, + [BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED]: { + message: "That grant request has already been answered", + statusCode: 409, + }, }; From 507aa9625631f01ddc28a9a31e59cae423b2cba9 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:49:51 -0700 Subject: [PATCH 114/170] feat(ssh-grants): grants + approvals routes, the relay launch leg, the expiry sweep /api/ssh gains GET/POST /grants, PATCH/DELETE /grants/:id, and the grant-requests queue (GET, /:id/approve, /:id/deny) - mounted INSIDE the existing ssh group (the App-type-depth rule), with the siblings' cookie doctrine verbatim and foreign rows rendering as the 404 the ownership axis demands. Create runs the key home's gate (gateSshNode + agent kind) so a grant can never be stored pointed at a machine the caller may not SSH through. sshLaunch gains keyHome: the relay leg runs AFTER B's resolve and BEFORE the pane exists - matchGrant -> openRelay (peers from the identities store, decision 3's pre/post liveness re-checks) -> the broker's socket becomes the scoped SSH_AUTH_SOCK via sshRelayPaneEnv, overriding the snapshot's own agent socket; no grant -> requestFirstUse + the named 409 fail-fast, no pane, broker never opened. A create that throws after an open cuts the session (child-exit). The boot+hourly approval sweep now also expires stale grant requests (spec 6.2: expiry writes nothing). Route suite: cookie doctrine (empty cookie 401, subshell token 403), owner scoping, the named 400/409 arms, DELETE 204 + closeForGrant(grant-revoked), and the relay launch pinned both ways including direct-mode untouched. 33 tests across the ssh suites, all green. --- .../api/ssh/__tests__/grants-route.test.ts | 570 ++++++++++++++++++ .../server/api/src/api/ssh/approvals.route.ts | 116 ++++ apps/server/api/src/api/ssh/grants.route.ts | 170 ++++++ apps/server/api/src/api/ssh/index.ts | 10 +- apps/server/api/src/api/ssh/launch.route.ts | 12 + apps/server/api/src/api/ssh/ssh-views.ts | 44 ++ apps/server/api/src/index.ts | 6 + .../api/src/services/ssh-grants.service.ts | 10 +- .../api/src/services/ssh-launch.service.ts | 156 ++++- 9 files changed, 1073 insertions(+), 21 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/grants-route.test.ts create mode 100644 apps/server/api/src/api/ssh/approvals.route.ts create mode 100644 apps/server/api/src/api/ssh/grants.route.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts new file mode 100644 index 000000000..1b5e70973 --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -0,0 +1,570 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; +import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, statDirEcho } from "@/test-helpers/scripted-node.js"; + +/** + * The grant tier's HTTP surface (spec 2026-10-08 §6/§8; Task 10 routes + + * the relay launch leg). The properties pinned here: + * + * - The COOKIE doctrine verbatim from the siblings: empty cookie 401, a real + * subshell token 403 on every grants/approvals/relay-launch door (decision + * 5: these routes match `launch`/`resolve`/`saved-hosts`, which refuse + * machine credentials at the door). + * - Owner scoping: lists never carry another owner's rows; a foreign id is + * the SAME 404 an absent one gets (grants, requests, and their answers). + * - The named refusals: over-cap selection and off-grammar fingerprints are + * the 400 codes, an already-answered request is the named 409, a key home + * the caller may not SSH through is the gate's own 403/404. + * - REVOKE reaches live relays over HTTP too: the delete answers 204 with no + * body and the broker's closeForGrant has seen exactly this grant id with + * reason "grant-revoked". + * - The RELAY LAUNCH: no standing grant -> the pane is NOT created, the + * broker never opens, a durable pending row + notification stand, and the + * answer is 409 SSH_GRANT_APPROVAL_REQUIRED. A standing grant -> 201, the + * broker opened for (grant, A, B, pane) with the grant's fingerprints, and + * the launch frame carries the BROKER's socket as the scoped SSH_AUTH_SOCK, + * overriding the snapshot's own agent socket. + * + * The clock/broker/notify trio is the service's injected seam: every case + * drives the REAL route stack (real gate, real resolve over a scripted node, + * real create path) while the relay itself is a recorder. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "grants-api-1"; +const ownerEmail = `grantsapi-owner-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `grantsapi-other-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let ownerCookie: string; +let otherCookie: string; + +const NODE_A = `n-grants-a-${crypto.randomUUID()}`; +const NODE_B = `n-grants-b-${crypto.randomUUID()}`; +/** SSH switched OFF on this one: the key-home gate's negative fixture. */ +const NODE_OFF = `n-grants-off-${crypto.randomUUID()}`; +const nodes = new NodesRepository(db); +const createdSubshellIds: string[] = []; +const emails: string[] = []; + +/** The broker's stand-in: it records the pairing and answers a fixed socket. */ +const FAKE_SOCK = "/home/scripted/.subshell/ssh/fake-pane/agent.sock"; +let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }[] = []; +let closeGrantCalls: { grantId: string; reason: string }[] = []; +let notifyCalls: string[] = []; +let openRelayThrows: ((input: unknown) => never) | null = null; + +function installFakeDeps() { + const broker = { + async openRelay(input: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }) { + if (openRelayThrows) openRelayThrows(input); + openCalls.push(input); + return { relayId: "relay-x", ref: "ref-x", expiresAt: "2026-10-08T00:00:30.000Z", socketPath: FAKE_SOCK }; + }, + async closeForGrant(grantId: string, reason: string) { + closeGrantCalls.push({ grantId, reason }); + return 0; + }, + } as unknown as RelayBroker; + setSshGrantsDepsForTests({ + nowIso: () => new Date().toISOString(), + broker: () => broker, + notifyGrantApproval: (_owner, requestId) => notifyCalls.push(requestId), + }); +} + +async function mkNode(id: string, sshEnabled: boolean): Promise { + await nodes.create({ id, ownerUserId: ownerId, name: `grantsapi-${id.slice(-8)}`, kind: "agent", status: "offline" }); + if (sshEnabled) await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-08T09:00:00.000Z" }); + // The ssh-launch usability gate reads the machine's own detection (the + // sibling suite's mkNode does the same): no inventory, no ssh pane. + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); +} + +/** An approved snapshot whose OWN agent socket must never ride a relay launch. */ +function snapshot(over: Record = {}): Record { + return { + alias: "git", + host: "git.example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: "/run/user/501/ssh-agent.sock", + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} +const ACCEPTED = snapshot(); + +function sshFetch(path: string, init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +const FP = (n: number) => `SHA256:${("b".repeat(43) + n).slice(-42)}${n % 10}`; + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(otherEmail); + emails.push(ownerEmail, otherEmail); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + await mkNode(NODE_A, true); + await mkNode(NODE_B, true); + await mkNode(NODE_OFF, false); + const identities = new IdentitiesRepository(db); + await identities.register({ + principalId: `node:${NODE_A}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"A1","y":"A2"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"A3","y":"A4"}', + displayName: "A", + }); + await identities.register({ + principalId: `node:${NODE_B}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"B1","y":"B2"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"B3","y":"B4"}', + displayName: "B", + }); + installFakeDeps(); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshSavedHosts").execute(); + await db.deleteFrom("auditEvents").execute(); + await db.deleteFrom("identities").execute(); + for (const id of [NODE_A, NODE_B, NODE_OFF]) await nodes.deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("POST /api/ssh/grants + the list", () => { + it("a bare agent node with SSH off is the gate's 403; a vanished node is the 404; an invalid selector is the named 400", async () => { + const off = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_OFF, name: "no", selector: "git.example.test", fingerprints: [FP(1)] }, + }); + expect(off.status).toBe(403); + expect(((await off.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + + const ghost = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: "node-does-not-exist", name: "no", selector: "git.example.test", fingerprints: [FP(1)] }, + }); + expect(ghost.status).toBe(404); + + const bad = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_A, name: "no", selector: "bad host", fingerprints: [FP(1)] }, + }); + expect(bad.status).toBe(400); + expect(((await bad.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID); + }); + + it("create stores the grant and the owner's list reads it back (fingerprints as an array); a foreign list never shows it", async () => { + const created = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_A, name: "git push", selector: "*.example.test", fingerprints: [FP(1), FP(2)] }, + }); + expect(created.status).toBe(201); + const { grant } = (await created.json()) as { grant: Record }; + expect(grant.createdVia).toBe("manual"); + expect(grant.fingerprints).toEqual([FP(1), FP(2)]); + + const mine = (await (await sshFetch("/api/ssh/grants", { cookie: ownerCookie })).json()) as { + grants: { id: string }[]; + }; + expect(mine.grants.map((g) => g.id)).toContain(grant.id as string); + const theirs = (await (await sshFetch("/api/ssh/grants", { cookie: otherCookie })).json()) as { + grants: unknown[]; + }; + expect(theirs.grants).toHaveLength(0); + + // An over-cap selection is the hard named error, never a truncation. + const over = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { + node: NODE_A, + name: "too many", + selector: "git.example.test", + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => FP(i + 50)), + }, + }); + expect(over.status).toBe(400); + expect(((await over.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT); + const afterOver = (await (await sshFetch("/api/ssh/grants", { cookie: ownerCookie })).json()) as { + grants: unknown[]; + }; + expect(afterOver.grants).toHaveLength(mine.grants.length); // nothing stored, nothing truncated + }); + + it("PATCH edits the selector, a foreign id is the 404, and DELETE revokes through the broker's grant hook", async () => { + const created = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "ed", + selector: "git.example.test", + fingerprints: [FP(3)], + }); + if (!created.ok) throw new Error("fixture grant refused"); + const id = created.value.grant.id; + + const foreign = await sshFetch(`/api/ssh/grants/${id}`, { + method: "PATCH", + cookie: otherCookie, + body: { name: "hijack" }, + }); + expect(foreign.status).toBe(404); + + const patched = await sshFetch(`/api/ssh/grants/${id}`, { + method: "PATCH", + cookie: ownerCookie, + body: { selector: "*.example.test" }, + }); + expect(patched.status).toBe(200); + expect(((await patched.json()) as { grant: { resolvedSelector: string } }).grant.resolvedSelector).toBe( + "*.example.test", + ); + + closeGrantCalls = []; + const gone = await sshFetch(`/api/ssh/grants/${id}`, { method: "DELETE", cookie: ownerCookie }); + expect(gone.status).toBe(204); + expect(await gone.text()).toBe(""); // 204 carries NO body + expect(closeGrantCalls).toEqual([{ grantId: id, reason: "grant-revoked" }]); + const again = await sshFetch(`/api/ssh/grants/${id}`, { method: "DELETE", cookie: ownerCookie }); + expect(again.status).toBe(404); + }); +}); + +describe("GET/POST /api/ssh/grant-requests", () => { + let requestId = ""; + + beforeAll(async () => { + const asked = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "git.example.test", + paneId: "pane-queue-probe", + }); + if (!asked.ok) throw new Error("fixture ask refused"); + requestId = asked.value.requestId; + }); + + it("the owner's queue names the requester facts; a foreign queue is empty", async () => { + const mine = (await (await sshFetch("/api/ssh/grant-requests", { cookie: ownerCookie })).json()) as { + requests: { id: string; status: string; paneId: string; bNodeId: string; resolvedSelector: string }[]; + }; + const row = mine.requests.find((r) => r.id === requestId); + expect(row).toBeDefined(); + expect(row?.status).toBe("pending"); + expect(row?.paneId).toBe("pane-queue-probe"); + expect(row?.bNodeId).toBe(NODE_B); + expect(row?.resolvedSelector).toBe("git.example.test"); + const theirs = (await (await sshFetch("/api/ssh/grant-requests", { cookie: otherCookie })).json()) as { + requests: unknown[]; + }; + expect(theirs.requests).toHaveLength(0); + }); + + it("approve refuses an off-grammar fingerprint with the named 400, then answers: grant row + approved request; a second answer is the named 409", async () => { + const badShape = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: ["fingerprint-not-in-SHA256-form"] }, + }); + expect(badShape.status).toBe(400); + expect(((await badShape.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_INVALID); + + const foreign = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: otherCookie, + body: { fingerprints: [FP(7)] }, + }); + expect(foreign.status).toBe(404); + + const approved = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [FP(7)], name: "by queue" }, + }); + expect(approved.status).toBe(200); + const grant = (await approved.json()) as { grant: { id: string; createdVia: string } }; + expect(grant.grant.createdVia).toBe("first-use"); + + const twice = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [FP(7)] }, + }); + expect(twice.status).toBe(409); + expect(((await twice.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + + await db.deleteFrom("sshKeyGrants").where("id", "=", grant.grant.id).execute(); + }); + + it("deny answers the row (audit-only, no grant); a second answer is the named 409", async () => { + await db.deleteFrom("sshKeyGrants").execute(); // "no grant exists for a denial" reads off a clean tier + const second = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "second.example.test", + paneId: "pane-deny-probe", + }); + if (!second.ok) throw new Error("fixture ask refused"); + const denied = await sshFetch(`/api/ssh/grant-requests/${second.value.requestId}/deny`, { + method: "POST", + cookie: ownerCookie, + body: {}, + }); + expect(denied.status).toBe(200); + expect((await denied.json()) as { requestId: string }).toEqual({ requestId: second.value.requestId }); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + const twice = await sshFetch(`/api/ssh/grant-requests/${second.value.requestId}/deny`, { + method: "POST", + cookie: ownerCookie, + body: {}, + }); + expect(twice.status).toBe(409); + await db.deleteFrom("sshGrantRequests").where("id", "=", second.value.requestId).execute(); + }); +}); + +describe("the cookie doctrine over the grant doors", () => { + it("an empty cookie is a 401 and a real subshell token is a 403 on grants, approvals, and launch", async () => { + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "bearer-probe", + workingDir: mkdtempSync(`${tmpdir()}/grants-bearer-`), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + for (const res of [ + await sshFetch("/api/ssh/grants", { cookie: "" }), + await sshFetch("/api/ssh/grant-requests", { cookie: "" }), + await sshFetch("/api/ssh/grants", { bearer: key }), + await sshFetch("/api/ssh/grant-requests", { bearer: key }), + await sshFetch("/api/ssh/launch", { + method: "POST", + bearer: key, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }), + ]) { + expect(res.status).toBeLessThanOrEqual(403); + } + const grantBearer = await sshFetch("/api/ssh/grants", { bearer: key }); + expect(((await grantBearer.json()) as { message: string }).message).toContain("browser sessions"); + const launchBearer = await sshFetch("/api/ssh/launch", { + method: "POST", + bearer: key, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(launchBearer.status).toBe(403); + }); +}); + +describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => { + function bScript() { + return attachScriptedNode(NODE_B, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + } + + /** Panes currently on B - negative launch assertions read against THIS baseline. */ + async function bPaneCount(): Promise { + return (await db.selectFrom("subshells").select("id").where("nodeId", "=", NODE_B).execute()).length; + } + + it("no standing grant: a pending row stands, the pane is NOT created, and the answer is the named 409", async () => { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + const scripted = bScript(); + notifyCalls = []; + openCalls = []; + const panesBefore = await bPaneCount(); + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED); + // The copy names the key home (row name) and the remedy; two sentences, no secrets. + expect(body.message).toContain(`grantsapi-${NODE_A.slice(-8)}`); + expect(body.message.toLowerCase()).toContain("approve"); + expect(openCalls).toHaveLength(0); + expect(notifyCalls).toHaveLength(1); + expect(await bPaneCount()).toBe(panesBefore); + expect(scripted.cmdsOf("launch")).toHaveLength(0); + // The ask went through exactly once, durable, pending. + const rows = await db.selectFrom("sshGrantRequests").selectAll().execute(); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + } finally { + scripted.detach(); + await db.deleteFrom("sshGrantRequests").execute(); + } + }); + + it("a standing grant: the broker opens for (grant, A, B, pane) and the pane's SSH_AUTH_SOCK is the BROKER's socket, not the snapshot's", async () => { + await db.deleteFrom("sshKeyGrants").execute(); + const grant = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "launch leg", + selector: "git.example.test", + fingerprints: [FP(11), FP(12)], + }); + if (!grant.ok) throw new Error("fixture grant refused"); + const scripted = bScript(); + openCalls = []; + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + + expect(openCalls).toHaveLength(1); + expect(openCalls[0]).toMatchObject({ + grantId: grant.value.grant.id, + fingerprints: [FP(11), FP(12)], + paneId: subshell.id, // the PRE-MINTED id: the broker byte-checks B's socket against it + aNode: NODE_A, + bNode: NODE_B, + }); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + // The relay override: the snapshot names /run/user/501/ssh-agent.sock; + // the pane gets the proxy socket the broker verified (spec §5.2). + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe(FAKE_SOCK); + } finally { + scripted.detach(); + } + }); + + it("a broker refusal (quota) comes back as the named 409 and no pane is created", async () => { + await db.deleteFrom("sshKeyGrants").execute(); + const grant = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "quota leg", + selector: "git.example.test", + fingerprints: [FP(11)], + }); + if (!grant.ok) throw new Error("fixture grant refused"); + const scripted = bScript(); + openRelayThrows = () => { + throw new SshRelayRefusal("quota", "node already holds 8 live relay sessions (max 8)", NODE_B); + }; + const panesBefore = await bPaneCount(); + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(await bPaneCount()).toBe(panesBefore); + expect(scripted.cmdsOf("launch")).toHaveLength(0); + } finally { + openRelayThrows = null; + scripted.detach(); + } + }); + + it("direct mode is untouched: no keyHome means no grant read, no relay, and the snapshot socket still rides", async () => { + const scripted = bScript(); + openCalls = []; + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git" }, + }); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + expect(openCalls).toHaveLength(0); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + } finally { + scripted.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts new file mode 100644 index 000000000..96721a8cc --- /dev/null +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -0,0 +1,116 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshGrantRequestViewSchema, SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { approveGrant, denyGrant, listGrantRequests } from "@/services/ssh-grants.service.js"; + +/** + * `/api/ssh/grant-requests` - the first-use approval queue (spec 2026-10-08 + * §6.2, modeled on the signup pending-approvals queue): GET the owner's + * pending questions, POST `/:id/approve` (selects the fingerprints and writes + * the standing grant), POST `/:id/deny` (an answer, no grant). + * + * The actor model is decision 5 stated concretely: rows are owned by the key + * home's owner, the asking launch could only have gated A through + * `nodeCanSsh` (SSH is owner-reserved on agent machines), and these routes + * are cookie-scoped to the row's owner - so the answering human IS the key + * home's owner, always, and a foreign id is the same 404 an absent row is. + * The selection cap and grammar live in the service; a second answer to an + * answered row is the named 409, because the row records the whole lifecycle + * rather than being rewritten by it. + * + * GET sweeps lazily first: a queue read never shows a question the deadline + * has already closed as still open. + */ +export const sshGrantRequestsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/grant-requests", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + return { requests: await listGrantRequests({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + requests: t.Array(SshGrantRequestViewSchema, { description: "The caller's pending approvals, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshGrantRequests", + tags: ["ssh"], + description: + "Lists the caller's outstanding first-use key-grant approvals, each naming the asking pane, the connecting machine, and the resolved destination", + }, + }, + ) + .post( + "/grant-requests/:id/approve", + async ({ body, params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await approveGrant({ + ownerUserId: user.id, + requestId: params.id, + fingerprints: body.fingerprints, + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { grant: answer.value.grant }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + body: t.Object({ + fingerprints: t.Array(t.String({ minLength: 1, maxLength: 253, description: "SHA256: fingerprint" }), { + description: + "The public agent identities this grant may sign with (from the roster fetch); more than SSH_MAX_GRANT_FINGERPRINTS is a hard error, never a truncation", + }), + name: t.Optional(t.String({ minLength: 1, maxLength: 253, description: "Display name for the new grant" })), + }), + response: { + 200: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "approveSshGrantRequest", + tags: ["ssh"], + description: + "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer with the fingerprint COUNT, never the values", + }, + }, + ) + .post( + "/grant-requests/:id/deny", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await denyGrant({ ownerUserId: user.id, requestId: params.id }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { requestId: answer.value.requestId }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + body: t.Object({}), + response: { + 200: t.Object({ + requestId: t.String({ description: "The request that was answered (now standing denied)" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "denySshGrantRequest", + tags: ["ssh"], + description: + "Answers a pending first-use approval NO: the audit row is the only writing, no grant and no pin exists for a denial, and a later relaunch simply asks again", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/grants.route.ts b/apps/server/api/src/api/ssh/grants.route.ts new file mode 100644 index 000000000..16c4ae06f --- /dev/null +++ b/apps/server/api/src/api/ssh/grants.route.ts @@ -0,0 +1,170 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { listGrants, revokeGrant, updateGrant } from "@/services/ssh-grants.service.js"; +import { sshCreateGrant } from "@/services/ssh-launch.service.js"; + +/** + * `GET|POST /api/ssh/grants`, `PATCH|DELETE /api/ssh/grants/:id` - the grants + * screen (spec 2026-10-08 §6, §8): list, create, edit the selector/name, and + * revoke. The rows are per-owner exactly like the saved-hosts ledger they sit + * beside: a foreign id answers the SAME 404 an absent one does (ids are never + * existence oracles, docs/security.md §3), no list carries another owner's + * rows, and the cookie doctrine is the siblings' verbatim (`requireCookieActor` + * - these routes decide which of the OWNER's keys may sign from which + * machine, which is a browser-session act). + * + * Create runs the key home's gate (only a machine you may SSH through can be + * your key home); the fingerprint SELECTION is immutable through PATCH by + * design - widening which keys serve is a revoke plus a fresh grant, so + * every standing selection has exactly the audit trail that chose it. + * DELETE is the instant both-ways cut: the row goes and every live relay + * that ran under it is torn down inside the service (spec §6.3). + * + * What the audit rows carry (written by the service, not here): ids, hosts, + * and the fingerprint COUNT - never a fingerprint string, which is the + * screen's to show and the trail's never to hold (docs/security.md §10). + */ +export const sshGrantsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/grants", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + return { grants: await listGrants({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + grants: t.Array(SshGrantViewSchema, { description: "The caller's standing grants, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshKeyGrants", + tags: ["ssh"], + description: + "Lists the caller's key grants: which key home may sign for which destinations, and which selected agent identities carry that authorization", + }, + }, + ) + .post( + "/grants", + async ({ body, user, actor, status }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await sshCreateGrant({ + viewerId: user.id, + aNodeId: body.node, + name: normalizeLabel(body.name, 120), + selector: body.selector, + fingerprints: body.fingerprints, + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return status(201, { grant: answer.value.grant }); + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Key home machine whose agent will sign (an agent node you own)" }), + name: t.String({ minLength: 1, maxLength: 253, description: "Display name for the grant" }), + selector: t.String({ + minLength: 1, + maxLength: 253, + description: "Destination selector: a concrete hostname or a '*' host pattern, stored resolved", + }), + fingerprints: t.Array(t.String({ minLength: 1, maxLength: 253, description: "SHA256: fingerprint" }), { + description: "The selected public agent identities; more than SSH_MAX_GRANT_FINGERPRINTS is refused outright", + }), + }), + response: { + 201: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "createSshKeyGrant", + tags: ["ssh"], + description: + "Creates a standing key grant (the same row an approved first use writes). Over-cap selections are a hard error, never a truncation", + }, + }, + ) + .patch( + "/grants/:id", + async ({ body, params, user, actor }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await updateGrant({ + ownerUserId: user.id, + grantId: params.id, + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + selector: body.selector, + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { grant: answer.value.grant }; + }, + { + params: t.Object({ id: t.String({ description: "Grant row id" }) }), + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 253, description: "New display name" })), + selector: t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "New destination selector (hostname or host pattern)", + }), + ), + }), + response: { + 200: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "updateSshKeyGrant", + tags: ["ssh"], + description: + "Edits a grant's display name and/or destination selector. Which keys serve is immutable: change that by revoking and granting again", + }, + }, + ) + .delete( + "/grants/:id", + async ({ params, user, actor, status, set }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await revokeGrant({ ownerUserId: user.id, grantId: params.id }); + if (!answer.ok) { + // Foreign AND absent, one 404 (the saved-hosts posture), rendered via + // apiErrorBody since this arm RETURNS rather than throws; revoke's + // only refusal arm is the 404, and the declared response agrees. + return status(404, apiErrorBody({ code: answer.refusal.code, message: answer.refusal.message })); + } + // 204 with NO body at all (the 422 Response-constructor trap the + // saved-hosts delete documents: a declared 204 schema would validate + // an absent body and answer 400 for a successful revoke). + set.status = 204; + }, + { + params: t.Object({ id: t.String({ description: "Grant row id" }) }), + response: { + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "revokeSshKeyGrant", + tags: ["ssh"], + description: + "Revokes a grant instantly: the row goes and every live relay session running under it is cut (204 no body on success; a foreign row is the same 404 as an absent one)", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 0b4e086de..67262bc05 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -1,5 +1,7 @@ import { Elysia } from "elysia"; import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; +import { sshGrantRequestsRoutes } from "@/api/ssh/approvals.route.js"; +import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; @@ -17,4 +19,10 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) .use(sshAliasesRoute) .use(sshResolveRoute) .use(sshLaunchRoute) - .use(sshSavedHostsRoutes); + .use(sshSavedHostsRoutes) + // The grant tier (spec 2026-10-08 §6-§8, Task 10): the standing grants and + // the first-use approval queue. Mounted INSIDE this group on purpose: a new + // top-level `.route()` tier is the Elysia App-type-depth trap, and these + // are the launcher's own authorization surface. + .use(sshGrantsRoutes) + .use(sshGrantRequestsRoutes); diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts index 9e4cca3b8..0221b9fe2 100644 --- a/apps/server/api/src/api/ssh/launch.route.ts +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -37,6 +37,11 @@ export const sshLaunchRoute = new Elysia() // The same label rule every human-chosen name takes (idempotent // through the manager's choke-point re-normalization). name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + // RELAY MODE (spec 2026-10-08 §6): the key home whose agent signs, + // gated and grant-matched inside the service. Absent = the M1 direct + // launch. A first-use refusal answers 409 SSH_GRANT_APPROVAL_REQUIRED + // and launches nothing (a durable approval row now stands). + keyHomeNodeId: body.keyHome, subshells: ctx.services.subshells, }); if (!answer.ok) { @@ -55,6 +60,13 @@ export const sshLaunchRoute = new Elysia() node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), destination: SshDestinationField, name: t.Optional(t.String({ minLength: 1, maxLength: 120, description: "Pane display name" })), + keyHome: t.Optional( + t.String({ + minLength: 1, + description: + "RELAY MODE: the key home machine whose agent signs for this connection; absent uses the connecting machine's own keys", + }), + ), }), response: { 201: t.Object({ subshell: SubshellSchema }), diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts index 2489168cb..96a110f88 100644 --- a/apps/server/api/src/api/ssh/ssh-views.ts +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -127,3 +127,47 @@ export function throwCodedRefusal(refusal: SshRefusal): never { } throwApiError({ code: refusal.code, message: refusal.message, doNotLog: true }); } + +/** + * One standing key grant as the grants screen reads it (spec 2026-10-08 §6.1, + * §8). The fingerprint SET is public key identifiers (the `SHA256:` display + * notation) and it is deliberately ON this response: the screen is the + * owner's own, and "which keys serve" is exactly what the owner manages. The + * values never appear in an audit row, a log line, or a push body (the + * Global Constraint lives there, not at the owner's screen). + */ +export const SshGrantViewSchema = t.Object({ + id: t.String({ description: "Grant row id (uuid)" }), + name: t.String({ description: "Display name the operator gave the grant" }), + keyHomeNodeId: t.String({ description: "Key home machine: its ssh-agent signs for this grant" }), + resolvedSelector: t.String({ + description: "Destination selector, stored RESOLVED: a concrete hostname or a '*' host pattern", + }), + fingerprints: t.Array(t.String({ description: "OpenSSH SHA256: fingerprint of a selected agent identity" }), { + description: "The selected public identities (at most SSH_MAX_GRANT_FINGERPRINTS); an empty set serves nothing", + }), + createdVia: t.Union([t.Literal("first-use"), t.Literal("manual")], { + description: "Which door created the row: an approved first use, or the grants screen", + }), + createdAt: t.String({ description: "ISO 8601 creation stamp (the match tie-break: oldest wins)" }), + updatedAt: t.String({ description: "ISO 8601 of the last operator edit" }), +}); + +/** One first-use approval request as the queue reads it (spec 2026-10-08 §6.2). */ +export const SshGrantRequestViewSchema = t.Object({ + id: t.String({ description: "Request row id (uuid) - the opaque ref the refusal and the notification name" }), + keyHomeNodeId: t.String({ description: "The key home whose approval is asked" }), + resolvedSelector: t.String({ description: "The resolved destination hostname the launch would have dialed" }), + requestedFingerprints: t.Nullable( + t.Array(t.String({ description: "Pre-selected SHA256: fingerprint" }), { + description: "Pre-selection riding the request, or null (the approver picks)", + }), + ), + paneId: t.String({ description: "The asking pane's id (the requester the screen names)" }), + bNodeId: t.String({ description: "The connecting machine of the asking launch" }), + expiresAt: t.String({ description: "ISO 8601 deadline (24 h); past it the sweep answers the row expired" }), + status: t.Union([t.Literal("pending"), t.Literal("approved"), t.Literal("denied"), t.Literal("expired")], { + description: "Lifecycle state; only pending can be answered", + }), + createdAt: t.String({ description: "ISO 8601 creation stamp" }), +}); diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index ec5c826ba..58efb5f37 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -63,6 +63,7 @@ import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/serv import { getNotifyService } from "@/services/notify.service.js"; import { sweepExpiredPaneLogs, tightenPaneLogModes } from "@/services/pane-log-hygiene.js"; import { expirePendingApprovals, remarkUnmarkedArrivals } from "@/services/pending-approvals.js"; +import { sweepExpiredGrantRequests } from "@/services/ssh-grants.service.js"; import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { hasAnyUser } from "@/services/registration-gate.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; @@ -366,6 +367,11 @@ async function bootServer(): Promise { const sweepPendingApprovals = async (): Promise => { await expirePendingApprovals(db); await remarkUnmarkedArrivals(db); + // The FIRST-USE grant queue rides the same cadence and the same boot-pass + // doctrine (spec 2026-10-08 §6.2): unanswered grant requests expire at a + // day's scale, the sweep writes no audit row, and the durable pending + // rows are what make a plane restart forget nothing. + await sweepExpiredGrantRequests(); }; try { await sweepPendingApprovals(); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index e61e2318c..f15a21cfd 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -181,8 +181,14 @@ function grantView(row: SshKeyGrantTable): SshGrantView { /* fingerprint selection: cap and grammar, law first */ /* ------------------------------------------------------------------ */ -/** The refusal union the grant surface returns (a subset of the ssh-launch one; same render path). */ -type SshRefusalNarrow = { status: 400 | 404 | 409; code: BackendErrorCodes; message: string }; +/** + * The refusal union the grant surface returns. The status set matches the + * ssh-launch one's coded arms (no 422 - the outcome-in-data grammar is the + * resolve surface's), so the SAME `throwCodedRefusal` renders both. The gate + * arms (403 gate-off, 502 machine-refused) reach it through the launcher's + * `gateSshNode` when a create names a machine the caller may not use. + */ +type SshRefusalNarrow = { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string }; /** The grant surface's answer union: a value, or the refusal the route returns as-is. */ export type SshGrantAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusalNarrow }; diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index ca2d9840d..adf62b7e7 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -35,6 +35,8 @@ import { getHeld, getLive } from "@/services/nodes/node-registry.js"; import { SshRpcError, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { createGrant, prepareRelayLeg, type SshGrantAnswer, type SshGrantView } from "@/services/ssh-grants.service.js"; +import { closeRelayForPaneExit, sshRelayPaneEnv } from "@/services/ssh-relay.service.js"; import type { SubshellsService } from "@/services/subshells.service.js"; import { logger } from "@/utils/logger.js"; @@ -377,6 +379,19 @@ export async function sshLaunch(args: { nodeId: string; destination: string; name?: string; + /** + * RELAY MODE (spec 2026-10-08 §5-§6, T10): the key home A whose agent signs + * for B. Absent is the M1 direct launch (B's own keys, snapshot agent + * socket as before). Present: after B's resolve, the grant layer decides - + * a standing grant opens a brokered relay session and the pane's scoped + * `SSH_AUTH_SOCK` points at B's proxy socket (the broker's byte-checked + * path); NO grant records the first-use request and FAILS the launch fast + * with `SSH_GRANT_APPROVAL_REQUIRED` - the pane never exists, and a + * re-launch after approval simply proceeds (or finds the standing pending + * row). A is gated exactly like B (its own row, its own owner); `local` + * can be neither side. + */ + keyHomeNodeId?: string; /** The REQUEST-scoped subshells service (the create path is its own). */ subshells: Pick; }): Promise> { @@ -402,29 +417,74 @@ export async function sshLaunch(args: { ); } const subshellId = crypto.randomUUID(); + // The relay leg runs BEFORE the pane exists (decision 2): the grant match + // answers from stored facts, `openRelay` verifies B's proxy socket path + // against the pane id minted here, and the refused launch writes no pane. + // A refusal (no grant -> asked, or a broker refusal) returns here; the + // only thing that ever existed was a durable pending row, which is the + // point of it. + let relay: { socketPath: string; grantId: string } | null = null; + if (args.keyHomeNodeId !== undefined) { + const gateA = await gateSshNode(args.viewerId, args.keyHomeNodeId); + if (!gateA.ok) return gateA; + const aRow = gateA.value.row; + if (aRow.kind !== "agent") { + // Acceptance (d) restated at the door that can name it: the server host + // is never a key home, whatever its own gate says about SSH. + return codedRefusal( + 400, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + "The server host itself cannot hold a relay key home; pick a machine running the Subshell app.", + ); + } + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: aRow.id, name: aRow.name }, + bNodeId: row.id, + resolvedHost: snapshot.host, + paneId: subshellId, + }); + if (!leg.ok) return leg; + relay = leg.value; + } const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the + // pane's ssh authenticates through is B's proxy the broker just verified, + // never the snapshot's agent socket even if the config names one. Still + // one key, still the ssh invocation only, still never the whole-pane env. + if (relay) composed.extraPaneEnv = sshRelayPaneEnv(relay.socketPath); // The create can still refuse (node switched off, harness gone, a // lockdown, a maintenance window) — those ride the global error handler // with their own codes. They land here, AFTER the resolve: the honest // order, since this act's gate (SSH use) is strictly narrower than the // launch's and every refusal below writes no pane. - const created = await args.subshells.createSubshell({ - userId: args.viewerId, - harnessId: "ssh", - presetId: null, - // The presetless-terminal default (spec 2026-10-01 §2): the launch node's - // home, resolved by the create path itself — exactly what the wizard's - // "connect from this machine" journey wants as the pane's starting cwd. - workingDir: undefined, - name: args.name, - nodeId: row.id, - machineActor: false, // the routes are cookie-only (requireCookieActor) - crossAgent: false, - subshellId, - ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, - presetFlags: composed.presetFlags, - ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), - }); + let created; + try { + created = await args.subshells.createSubshell({ + userId: args.viewerId, + harnessId: "ssh", + presetId: null, + // The presetless-terminal default (spec 2026-10-01 §2): the launch node's + // home, resolved by the create path itself — exactly what the wizard's + // "connect from this machine" journey wants as the pane's starting cwd. + workingDir: undefined, + name: args.name, + nodeId: row.id, + machineActor: false, // the routes are cookie-only (requireCookieActor) + crossAgent: false, + subshellId, + ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, + presetFlags: composed.presetFlags, + ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), + }); + } catch (err) { + // A session was opened under a pane id that will never have a child: + // the child-exit word is the honest cut (the process is gone at t=0), + // and the sweep is the same best-effort helper every death site uses. + // The refusal the caller sees is the create's own, untouched. + if (relay) closeRelayForPaneExit(subshellId); + throw err; + } // Recency FIRST (a bookkeeping refresh the human already sees as part of the // launch), then the audit — both AFTER the pane exists. The ledger is // best-effort (a failed touch must not turn a live pane into an error, the @@ -444,7 +504,14 @@ export async function sshLaunch(args: { action: "ssh.launch", targetType: "node", targetId: row.id, - metadataJson: JSON.stringify({ nodeId: row.id, destination, subshellId: created.id }), + // The relay's grant id rides the success row: ids only, and it lets the + // trail read which authorization carried the launch (spec §9's audit + // posture; the fingerprint set lives on the grant, not here). + metadataJson: JSON.stringify( + relay + ? { nodeId: row.id, destination, subshellId: created.id, grantId: relay.grantId } + : { nodeId: row.id, destination, subshellId: created.id }, + ), }); return ok({ subshellId: created.id }); } @@ -486,6 +553,59 @@ export async function sshRemoveSavedHost(viewerId: string, id: string): Promise< return await new SshSavedHostsRepository(db).remove(viewerId, id); } +/** + * `POST /api/ssh/grants` — create a standing grant from the screen (spec + * 2026-10-08 §8). The gate here is the key home's: a grant authorizes AN's + * agent, so A must be an agent node this caller may SSH through, switched on + * and unheld - the same `gateSshNode` a relay launch runs, run at creation + * so the screen cannot store an authorization pointed at a machine the + * caller has no say over. `local` is never a key home (acceptance (d)); the + * refusal names that door with the open-failure code because it is exactly + * what the broker would have refused, refused before anything was stored. + */ +export async function sshCreateGrant(args: { + viewerId: string; + aNodeId: string; + name: string; + selector: string; + fingerprints: readonly string[]; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.aNodeId); + if (!gate.ok) { + // gateSshNode only ever carries the CODED arm (its refusal is a gate + // verdict, never a resolve outcome); the 422 type-arm is unreachable, + // and an unreachable arm is rendered loudly, not swallowed. + if (gate.refusal.status === 422) { + return { + ok: false, + refusal: { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The grant surface cannot answer that gate check.", + }, + }; + } + return { ok: false, refusal: gate.refusal }; + } + if (gate.value.row.kind !== "agent") { + return { + ok: false, + refusal: { + status: 400, + code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + message: "The server host itself cannot hold a grant key home; pick a machine running the Subshell app.", + }, + }; + } + return await createGrant({ + ownerUserId: args.viewerId, + aNodeId: args.aNodeId, + name: args.name, + selector: args.selector, + fingerprints: args.fingerprints, + }); +} + /** The GET payload of the launcher screen: the owner's rows plus their default machine. */ export async function sshSavedHostsView(viewerId: string): Promise<{ saved: SshSavedHostTable[]; From 6394a548a97424276f3b0add63eca4a637b2bd89 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:50:04 -0700 Subject: [PATCH 115/170] docs(security): SS10 gains the grant + relay audit families node.ssh_grant.request|create|update|delete|approve|deny (ids/hosts/ fingerprintsCount only; deny audit-only; expiry writes nothing; delete carries relaysClosed) and node.ssh_relay.open|close (plane rows, named reason) - the latter the pair T8 deferred to this task. ssh.launch names grantId on a relay launch. The working summary's chain updated to match. --- .claude/rules/security-context.md | 2 +- docs/security.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 78cee0db1..052777891 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; fingerprintsCount only, NEVER a fingerprint value)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/docs/security.md b/docs/security.md index d65262b28..24aeee28d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2282,6 +2282,18 @@ Audit events are written, grouped by family: and nothing else in the metadata - never the key value; the idempotent re-report of the same bytes and the refused different-bytes report write no row), + `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9, + migration 0050: the ask names the pane, the two machines, and the resolved + destination; approve/create name the grant, the destination, and the + selected-key COUNT (`fingerprintsCount`) - the fingerprint VALUES are + public identifiers but they never enter an audit row, a log line, or a + notification body; deny is audit-only and expiry writes NOTHING; delete + carries `relaysClosed`, the revoke's live-session cut made real; create + carries `via: "first-use" | "manual"`), + `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, + the routing ref, and the fingerprint COUNT; close names the one + `reason` word from the grammar - the plane's broker rows, `actorUserId: + null`, because the human's act is the launch/grant trail), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart From 81216761191d20c9c0433ae5228d3ff8e23b98d5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:56:53 -0700 Subject: [PATCH 116/170] docs(ssh-relay): approve audit names the chosen fingerprints (spec 9/10 governs over the brief); ratify owner-scoped host pins --- .claude/rules/security-context.md | 2 +- docs/security.md | 17 ++++++++++------- .../specs/2026-10-08-ssh-agent-relay-design.md | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 052777891..0de45ab7d 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; fingerprintsCount only, NEVER a fingerprint value)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/docs/security.md b/docs/security.md index 24aeee28d..f7877408d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2282,14 +2282,17 @@ Audit events are written, grouped by family: and nothing else in the metadata - never the key value; the idempotent re-report of the same bytes and the refused different-bytes report write no row), - `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9, + `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9/§10, migration 0050: the ask names the pane, the two machines, and the resolved - destination; approve/create name the grant, the destination, and the - selected-key COUNT (`fingerprintsCount`) - the fingerprint VALUES are - public identifiers but they never enter an audit row, a log line, or a - notification body; deny is audit-only and expiry writes NOTHING; delete - carries `relaysClosed`, the revoke's live-session cut made real; create - carries `via: "first-use" | "manual"`), + destination; approve/create name the grant, the destination, and the CHOSEN + key FINGERPRINTS (the `SHA256:` public identifiers - spec §10 makes the + approve row the only durable record of the operator's selection, since a + plane could widen the set it hands A and no plane-independent surface shows + what A enforced); those fingerprint values appear ONLY in the grant + approve/create audit rows - never in a log line or a notification body; deny + is audit-only and expiry writes NOTHING; delete carries `relaysClosed`, the + revoke's live-session cut made real; create carries `via: "first-use" | + "manual"`; no key material, challenge, or signature rides any row), `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 9ef4d77ef..94101f1a9 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -117,7 +117,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. Ratified deviation from parent §7's "one pin per resolved destination": the row key is scoped per **owner** as well as destination, `(owner, user@host:port)`, so one account's TOFU decision can never be poisoned by another; under the single-operator posture this is equal to parent §7's one-per-destination, but the shape is owner-scoped on purpose (T12 builds on it). ### 6.2 First use creates the grant From 2a0a8af8986f1115a53f88293efa5dfa07f7197b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:05:12 -0700 Subject: [PATCH 117/170] fix(ssh-relay): approve audit names the chosen fingerprints (spec 9/10); clear relay-grant NITs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approve/create audit rows carry the chosen SHA256: fingerprint VALUES and the destination (spec 2026-10-08 §9/§10, docs/security.md §10: the approve row is the durable record of the operator's selection). The values still appear nowhere else: an edit names the count, no other audit row carries a value, and the retargeted suite proves no fingerprint reaches a log line or a notification and no key material rides any row. NITs: the local-as-key-home refusals declare the 409 their code canonically renders (pinned by a new /api/ssh/grants case); the DELETE /grants/:id decorative 409 arm is gone; the sshCreateGrant JSDoc says the key home's agent signs; T10's two em dashes cleared; SshGrantView/SshGrantRequestView gain per-property docs. --- .../src/api/ssh/__tests__/ssh-routes.test.ts | 20 ++++ .../server/api/src/api/ssh/approvals.route.ts | 2 +- apps/server/api/src/api/ssh/grants.route.ts | 10 +- .../api/src/db/types/ssh-grants.db-types.ts | 6 +- .../__tests__/ssh-grants.service.test.ts | 113 +++++++++++++----- .../api/src/services/ssh-grants.service.ts | 42 +++++-- .../api/src/services/ssh-launch.service.ts | 25 ++-- 7 files changed, 162 insertions(+), 56 deletions(-) diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts index b2c7056b3..9b9aefbb6 100644 --- a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -672,6 +672,26 @@ describe("the local node answers in-process (no agent socket exists to reach)", expect((await aliases(LOCAL_NODE_ID, otherCookie)).status).toBe(403); }); + it("`local` is never a key home (acceptance (d)): the create names the door with the code's canonical 409, and stores nothing", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: adminCookie, + body: { + node: LOCAL_NODE_ID, + name: "server keys", + selector: "git.example.test", + fingerprints: [`SHA256:${"c".repeat(43)}`], + }, + }); + // 409 is what the OPEN-FAILURE code renders (the declared arm rides the + // code, not the service's own number); the server host is refused exactly + // as the broker would refuse it, BEFORE anything is stored. + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + }); + it("local resolve runs the same engine in-process: the -G answer becomes an approved snapshot", async () => { await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); const res = await resolve(LOCAL_NODE_ID, "verify.example", adminCookie); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts index 96721a8cc..4fc30ee96 100644 --- a/apps/server/api/src/api/ssh/approvals.route.ts +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -82,7 +82,7 @@ export const sshGrantRequestsRoutes = new Elysia() operationId: "approveSshGrantRequest", tags: ["ssh"], description: - "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer with the fingerprint COUNT, never the values", + "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer naming the chosen fingerprints (public SHA256: identifiers) and the destination, per the durable selection record in docs/security.md §10", }, }, ) diff --git a/apps/server/api/src/api/ssh/grants.route.ts b/apps/server/api/src/api/ssh/grants.route.ts index 16c4ae06f..958d65e5e 100644 --- a/apps/server/api/src/api/ssh/grants.route.ts +++ b/apps/server/api/src/api/ssh/grants.route.ts @@ -25,8 +25,11 @@ import { sshCreateGrant } from "@/services/ssh-launch.service.js"; * that ran under it is torn down inside the service (spec §6.3). * * What the audit rows carry (written by the service, not here): ids, hosts, - * and the fingerprint COUNT - never a fingerprint string, which is the - * screen's to show and the trail's never to hold (docs/security.md §10). + * and on the grant approve/create rows the CHOSEN fingerprint VALUES - the + * public `SHA256:` identifiers, named because spec §10 makes those rows the + * durable record of the operator's selection. Everywhere else the trail names + * the COUNT only, no fingerprint value rides a log line or a notification, + * and no row carries key material (docs/security.md §10). */ export const sshGrantsRoutes = new Elysia() .use(authGuard) @@ -155,10 +158,11 @@ export const sshGrantsRoutes = new Elysia() { params: t.Object({ id: t.String({ description: "Grant row id" }) }), response: { + // The comment above is the law now: revoke's ONLY refusal arm is the + // 404, so no decorative 409 is declared. 401: "ApiErrorResponse", 403: "ApiErrorResponse", 404: "ApiErrorResponse", - 409: "ApiErrorResponse", }, detail: { operationId: "revokeSshKeyGrant", diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts index fb6a2bd4d..e5781b9ea 100644 --- a/apps/server/api/src/db/types/ssh-grants.db-types.ts +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -39,8 +39,10 @@ export const SSH_GRANT_REQUEST_STATUSES: readonly SshGrantRequestStatus[] = [ * selector, sign with the agent on the key-home machine, using exactly the * selected public identities. No secret and no key bytes live here - the * fingerprints are OpenSSH `SHA256:` display identifiers over the agent wire - * encoding (public by nature), yet they still never enter a notification body - * or a log line (Global Constraints; docs/security.md §10 names the COUNT). + * encoding (public by nature): spec 2026-10-08 §10 makes the grant's + * approve/create audit rows NAME the chosen values (the durable selection + * record), while they enter no other audit row, no notification body, and no + * log line (docs/security.md §10). */ export interface SshKeyGrantTable { /** Unique row id (uuid) */ diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts index 4e1510f72..6ed51c1dc 100644 --- a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { BackendErrorCodes } from "@internal/backend-errors"; import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; @@ -26,6 +26,7 @@ import { updateGrant, } from "@/services/ssh-grants.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; /** * The grant authorization layer (spec 2026-10-08 §6; Task 10). What this @@ -36,9 +37,13 @@ import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service. * approval, no second notification). The row is DB state: a "restart" * (deps swapped, nothing else) still finds it. * - APPROVE writes the grant with EXACTLY the chosen fingerprints, flips the - * request, and audits `approve` + `create` whose metadata names the COUNT, - * never a fingerprint string. A selection over SSH_MAX_GRANT_FINGERPRINTS - * is the named red error with the input untouched, never a truncation. + * request, and audits `approve` + `create` naming the CHOSEN fingerprint + * VALUES + destination (spec 2026-10-08 §9/§10: the approve row is the + * durable record of the operator's selection, and a count records no + * selection). The values stay OUT of every other audit row, every log line, + * and every notification, and no key material, challenge, or signature + * rides any row. A selection over SSH_MAX_GRANT_FINGERPRINTS is the named + * red error with the input untouched, never a truncation. * - DENY audits only. EXPIRY sweeps the row to `expired` and writes NOTHING. * - MATCH: owner + key home + selector against the resolved host; globs are * policy; most specific wins, ties to the OLDEST grant. @@ -326,31 +331,73 @@ describe("ssh-grants.service", () => { expect(one.ok).toBe(false); }); - it("approve writes the grant (createdVia first-use), answers the request, and audits COUNTS, never fingerprint strings", async () => { - const approved = await approveGrant({ - ownerUserId: owner, - requestId: requestRef, - fingerprints: FPS, - name: "work git", - }); - expect(approved.ok).toBe(true); - if (!approved.ok) return; - expect(approved.value.grant.createdVia).toBe("first-use"); - expect(approved.value.grant.fingerprints).toEqual(FPS); - expect(approved.value.grant.resolvedSelector).toBe(HOST); - expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); - expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); - const create = await latestAudit("node.ssh_grant.create"); - expect(create.fingerprintsCount).toBe(2); - expect(create.via).toBe("first-use"); - const approve = await latestAudit("node.ssh_grant.approve"); - expect(approve.grantId).toBe(approved.value.grant.id); - expect(approve.fingerprintsCount).toBe(2); - // The Global Constraint, read off the WHOLE trail: no fingerprint VALUE - // in any audit row this act wrote. - const all = await db.selectFrom("auditEvents").select("metadataJson").execute(); - for (const row of all) { - for (const fp of FPS) expect(row.metadataJson).not.toContain(fp); + it("approve writes the grant (createdVia first-use), answers the request, and its approve+create rows NAME the chosen fingerprints", async () => { + // The spec's split (2026-10-08 §9/§10, docs/security.md §10): the + // approve row is the DURABLE RECORD OF THE SELECTION, so it carries the + // chosen `SHA256:` values (public identifiers) + destination; the same + // values must appear in NO log line and NO notification this act writes. + const logged: string[] = []; + const levels = ["debug", "info", "warn", "error"] as const; + const originals = levels.map((level) => + (logger[level] as unknown as (...args: unknown[]) => unknown).bind(logger), + ); + const spies = levels.map((level, i) => + spyOn(logger, level).mockImplementation(((...args: unknown[]) => { + logged.push(args.filter((a) => typeof a === "string").join(" ")); + return originals[i](...args); + }) as never), + ); + try { + const approved = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: FPS, + name: "work git", + }); + expect(approved.ok).toBe(true); + if (!approved.ok) return; + expect(approved.value.grant.createdVia).toBe("first-use"); + expect(approved.value.grant.fingerprints).toEqual(FPS); + expect(approved.value.grant.resolvedSelector).toBe(HOST); + expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); + // The selection record: approve and create carry the VALUES. + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprints).toEqual(FPS); + expect(create.destination).toBe(HOST); + expect(create.via).toBe("first-use"); + expect(create.fingerprintsCount).toBeUndefined(); // replaced by the values, not paired with a count + const approve = await latestAudit("node.ssh_grant.approve"); + expect(approve.grantId).toBe(approved.value.grant.id); + expect(approve.fingerprints).toEqual(FPS); + expect(approve.destination).toBe(HOST); + expect(approve.fingerprintsCount).toBeUndefined(); + // The notification seam carries only opaque ids (fixed copy + the + // request id) - not one fingerprint reaches the owner's push. + const notified = JSON.stringify(notifyCalls); + for (const fp of FPS) expect(notified).not.toContain(fp); + // The WHOLE trail, re-targeted: the values appear EXACTLY in the + // grant approve/create rows (spec §10 names them nowhere else), and + // NO row anywhere carries key material, a challenge, or a signature. + const all = await db.selectFrom("auditEvents").select(["action", "metadataJson"]).execute(); + const valueBearing = new Set(["node.ssh_grant.approve", "node.ssh_grant.create"]); + for (const row of all) { + const meta = row.metadataJson ?? ""; + for (const fp of FPS) { + if (valueBearing.has(row.action)) expect(meta).toContain(fp); + else expect(meta).not.toContain(fp); + } + for (const marker of ['"kty"', "PRIVATE KEY", "signingPublicKey", "challenge", "signature"]) { + expect(meta).not.toContain(marker); + } + } + } finally { + for (const spy of spies) spy.mockRestore(); + } + // The log-line half of the guard: no fingerprint VALUE in anything this + // act handed the logger (the act is silent; a logging regression trips here). + for (const line of logged) { + for (const fp of FPS) expect(line).not.toContain(fp); } }); @@ -627,9 +674,15 @@ describe("ssh-grants.service", () => { if (!updated.ok) return; expect(updated.value.grant.resolvedSelector).toBe("*.example.test"); expect(updated.value.grant.fingerprints).toEqual(FPS); // selection is immutable through edit + // The manual create is the SAME selection record (spec §10): it names + // the chosen values, exactly like an approved first use does. + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprints).toEqual(FPS); + expect(create.via).toBe("manual"); const audit = await latestAudit("node.ssh_grant.update"); expect(audit.grantId).toBe(created.value.grant.id); - expect(audit.fingerprintsCount).toBe(2); + expect(audit.fingerprintsCount).toBe(2); // an EDIT names the count... + expect(audit.fingerprints).toBeUndefined(); // ...only approve/create rows carry the values }); it("revoke deletes the row, cuts every live relay through closeForGrant, and audits the count", async () => { diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index f15a21cfd..be8bbcf24 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -32,10 +32,13 @@ import { logger } from "@/utils/logger.js"; * account. The routes render foreign ids as the 404 the ownership axis * demands; a foreign row is absent here, never "forbidden". * - * **What never leaves this layer's row** (Global Constraints): fingerprint - * VALUES. They are public identifiers and they live in the grant column, but - * they appear in NO audit metadata (the COUNT is the law), NO notification - * (fixed copy + the opaque request id), and NO log line this file writes. + * **What may and may not leave this layer's row** (Global Constraints; spec + * §9/§10): the fingerprint VALUES are public `SHA256:` identifiers, and the + * `approve` and `create` audit rows NAME them - that row pair is the durable + * record of the operator's selection, and a count records no selection. The + * values appear in NO OTHER audit row (an edit names the count), NO + * notification (fixed copy + the opaque request id), and NO log line this + * file writes; no key material, challenge, or signature ever rides any row. * * Everything outside the module is an injected seam (the `getNodeWsDeps` * pattern): the clock, the relay broker, and the notification sink. The @@ -141,26 +144,43 @@ function selectorSpecificity(selector: string): { wildcards: number; literals: n /** One grant row as the API and the launch leg read it: fingerprints parsed, not a JSON string. */ export interface SshGrantView { + /** Unique grant row id (uuid) */ id: string; + /** Display name the operator gave the grant (defaults to the selector when unnamed) */ name: string; + /** Machine A: the key home whose ssh-agent signs under this grant */ keyHomeNodeId: string; + /** The destination selector, stored resolved (concrete hostname or `*` host pattern, lowercase) */ resolvedSelector: string; + /** The chosen public agent identities (`SHA256:` strings, parsed from the stored JSON array) - exactly which keys may sign */ fingerprints: string[]; + /** Which door created the row: an approved first use or the grants screen */ createdVia: SshGrantCreatedVia; + /** ISO 8601 creation stamp (the match tie-break: oldest grant wins) */ createdAt: string; + /** ISO 8601 of the last operator edit (name/selector) */ updatedAt: string; } /** One first-use request row as the approvals screen reads it. */ export interface SshGrantRequestView { + /** Request row id (uuid) - the opaque ref the launch refusal and the notification name */ id: string; + /** Machine A asked to sign (the key home whose owner must answer) */ keyHomeNodeId: string; + /** The resolved destination hostname the asking launch would have dialed */ resolvedSelector: string; + /** Fingerprints that rode the request; null when none did (the approver selects, the asking pane proposes nothing) */ requestedFingerprints: string[] | null; + /** B's pane (subshell id) whose launch asked - what the screen names as the requester */ paneId: string; + /** B's node id: the connecting machine of the asking launch */ bNodeId: string; + /** ISO 8601 answer deadline (24 h); past it the lazy sweep marks the row expired and writes nothing */ expiresAt: string; + /** Lifecycle state - only `pending` is answerable */ status: SshGrantRequestStatus; + /** ISO 8601 creation stamp */ createdAt: string; } @@ -220,7 +240,8 @@ const ALREADY_ANSWERED = { } as const; /* ------------------------------------------------------------------ */ -/* audit: ids, hosts, COUNTS - the §9 shape, never a fingerprint */ +/* audit: ids, hosts, the CHOSEN fingerprints on approve/create (§10), */ +/* counts elsewhere - never key material, challenge, or signature */ /* ------------------------------------------------------------------ */ function grantAudit( @@ -310,8 +331,9 @@ export async function sweepExpiredGrantRequests(): Promise { * never a double grant), then write the standing grant with EXACTLY the * chosen fingerprints, created via first-use. Audits BOTH facts: the answer * (`approve`) and the row's birth (`create`, the same event the screen's own - * create writes, per §6.2), naming ids, destination, and the fingerprint - * COUNT only. + * create writes, per §6.2), naming ids, destination, and the CHOSEN + * fingerprint VALUES - §10 makes the approve row the only durable record of + * the operator's selection, so a count here would record no selection. */ export async function approveGrant(args: { ownerUserId: string; @@ -344,12 +366,12 @@ export async function approveGrant(args: { requestId: row.id, grantId: grant.id, destination: row.resolvedSelector, - fingerprintsCount: keys.value.length, // the COUNT; §10 law + fingerprints: keys.value, // the CHOSEN VALUES; §10's durable selection record }); await grantAudit(args.ownerUserId, "node.ssh_grant.create", row.keyHomeNodeId, { grantId: grant.id, destination: row.resolvedSelector, - fingerprintsCount: keys.value.length, + fingerprints: keys.value, via: "first-use", }); return granted({ grant: grantView(grant) }); @@ -444,7 +466,7 @@ export async function createGrant(args: { await grantAudit(args.ownerUserId, "node.ssh_grant.create", args.aNodeId, { grantId: grant.id, destination: selector, - fingerprintsCount: keys.value.length, + fingerprints: keys.value, // the same selection record the approved first use writes (§10) via: "manual", }); return granted({ grant: grantView(grant) }); diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index adf62b7e7..738dd2720 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -430,9 +430,11 @@ export async function sshLaunch(args: { const aRow = gateA.value.row; if (aRow.kind !== "agent") { // Acceptance (d) restated at the door that can name it: the server host - // is never a key home, whatever its own gate says about SSH. + // is never a key home, whatever its own gate says about SSH. The 409 + // is the code's canonical render (`throwCodedRefusal` rides the code's + // own status), matching what the broker's own `local-node` refusal is. return codedRefusal( - 400, + 409, BackendErrorCodes.SSH_RELAY_OPEN_FAILED, "The server host itself cannot hold a relay key home; pick a machine running the Subshell app.", ); @@ -465,7 +467,7 @@ export async function sshLaunch(args: { harnessId: "ssh", presetId: null, // The presetless-terminal default (spec 2026-10-01 §2): the launch node's - // home, resolved by the create path itself — exactly what the wizard's + // home, resolved by the create path itself - exactly what the wizard's // "connect from this machine" journey wants as the pane's starting cwd. workingDir: undefined, name: args.name, @@ -554,12 +556,12 @@ export async function sshRemoveSavedHost(viewerId: string, id: string): Promise< } /** - * `POST /api/ssh/grants` — create a standing grant from the screen (spec - * 2026-10-08 §8). The gate here is the key home's: a grant authorizes AN's - * agent, so A must be an agent node this caller may SSH through, switched on - * and unheld - the same `gateSshNode` a relay launch runs, run at creation - * so the screen cannot store an authorization pointed at a machine the - * caller has no say over. `local` is never a key home (acceptance (d)); the + * `POST /api/ssh/grants` - create a standing grant from the screen (spec + * 2026-10-08 §8). The gate here is the key home's: a grant authorizes the key + * home's agent to sign, so A must be an agent node this caller may SSH + * through, switched on and unheld - the same `gateSshNode` a relay launch + * runs, run at creation so the screen cannot store an authorization pointed + * at a machine the caller has no say over. `local` is never a key home (acceptance (d)); the * refusal names that door with the open-failure code because it is exactly * what the broker would have refused, refused before anything was stored. */ @@ -591,7 +593,10 @@ export async function sshCreateGrant(args: { return { ok: false, refusal: { - status: 400, + // 409 is this code's canonical render (`throwCodedRefusal` rides the + // code's own status), and it is what the broker's own `local-node` + // refusal gives: the same door, refused before anything is stored. + status: 409, code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, message: "The server host itself cannot hold a grant key home; pick a machine running the Subshell app.", }, From 0f664bcc6c3450dacd62ee4f77fc73fa23bc2b0b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:14:21 -0700 Subject: [PATCH 118/170] feat(ssh-relay): arm ssh_agent_identities in the node-link grammar with a blobs-withheld roster result Task 11 (spec 2026-10-08 SS5.4): the command is its type alone (it asks the whole roster); the result validator rebuilds each entry as { fingerprint, comment } so blob bytes are unrepresentable, and the fingerprint grammar is one exported predicate shared by the grant selection and the roster read. --- .../src/__tests__/node-frames.test.ts | 17 +++++- .../src/__tests__/node-results-ssh.test.ts | 55 ++++++++++++++++++- packages/subshell-protocol/src/index.ts | 5 ++ packages/subshell-protocol/src/node-frames.ts | 1 + .../subshell-protocol/src/node-results.ts | 34 +++++++++++- packages/subshell-protocol/src/ssh-frames.ts | 43 +++++++++++++-- packages/subshell-protocol/src/ssh-results.ts | 37 +++++++++++-- 7 files changed, 177 insertions(+), 15 deletions(-) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 7a1feb1bc..b232427eb 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -963,16 +963,17 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these five types, and the count is the + // The family's census: exactly these six types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ + "ssh_agent_identities", "ssh_discover_aliases", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(5); + expect(SSH_COMMAND_TYPES).toHaveLength(6); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -987,6 +988,18 @@ describe("ssh command arms and the launch ssh block", () => { } }); + it("the ssh_agent_identities arm is its type alone (spec 2026-10-08 §5.4: the roster command asks the WHOLE roster)", () => { + expect(parseNodeCommandBody({ type: "ssh_agent_identities" })).toEqual({ type: "ssh_agent_identities" }); + // Nothing beyond the type travels: the command enumerates A's entire public + // roster, so a plane-sent selection could only be a widening attempt. + expect(parseNodeCommandBody({ type: "ssh_agent_identities", fingerprints: ["x"] })).toEqual({ + type: "ssh_agent_identities", + }); + expect(parseNodeCommandBody({ type: "ssh_agent_identities", grantId: "g" })).toEqual({ + type: "ssh_agent_identities", + }); + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index f6f46859a..d98fea2df 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome } from "../node-results.js"; +import { + parseNodeSshAgentIdentities, + parseNodeSshAliasList, + parseNodeSshIdentity, + parseNodeSshResolveOutcome, +} from "../node-results.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -49,6 +54,54 @@ describe("parseNodeSshResolveOutcome", () => { }); }); +describe("parseNodeSshAgentIdentities", () => { + /** A canonical grant-grammar fingerprint (SHA256: + base64url digest text). */ + const FP_A = `SHA256:${"A".repeat(20)}${"b".repeat(20)}cd`; + const _FP_B = `SHA256:${"-_9".repeat(14)}x`; + + test("narrows a well-formed roster answer", () => { + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "laptop key" }] })).toEqual({ + identities: [{ fingerprint: FP_A, comment: "laptop key" }], + }); + }); + test("an EMPTY roster is a legal answer (a live agent may carry zero keys)", () => { + expect(parseNodeSshAgentIdentities({ identities: [] })).toEqual({ identities: [] }); + }); + test("blob bytes are NOT representable: a wire `blob` member is dropped from the narrowed answer", () => { + // The grammar has no slot for key material, so even a (buggy or hostile) + // node that tried to ship one cannot get it past the rebuild. + const blob = Buffer.from("PRIVATE-WIRE-MATERIAL").toString("base64"); + const parsed = parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "c", blob }] }); + expect(parsed).toEqual({ identities: [{ fingerprint: FP_A, comment: "c" }] }); + expect(JSON.stringify(parsed)).not.toContain("PRIVATE-WIRE-MATERIAL"); + expect(JSON.stringify(parsed)).not.toContain(blob); + }); + test("refuses fingerprints outside the canonical SHA256: grammar", () => { + // The roster's fingerprint must be a valid grant selection, the SAME + // grammar both directions (the approve error copy promises "exactly as + // the roster reports it"); anything else is a malformed answer, refused. + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: "MD5:aa:bb", comment: "c" }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:", comment: "c" }] })).toBeNull(); + expect( + parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:with space", comment: "c" }] }), + ).toBeNull(); + expect( + parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:with/slash+plus=", comment: "c" }] }), + ).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: 7, comment: "c" }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A }] })).toBeNull(); + }); + test("refuses malformed entries, non-string comments, and non-array shapes", () => { + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: 4 }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "x".repeat(254) }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: "not an array" })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: ["bare string"] })).toBeNull(); + expect(parseNodeSshAgentIdentities({})).toBeNull(); + expect(parseNodeSshAgentIdentities(null)).toBeNull(); + expect(parseNodeSshAgentIdentities([])).toBeNull(); + }); +}); + describe("parseNodeSshIdentity", () => { test("narrows a well-formed signing-public-key answer", () => { expect(parseNodeSshIdentity({ signingPublicKey: SIGNING_JWK })).toEqual({ signingPublicKey: SIGNING_JWK }); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 32f30ed35..87f4f78b5 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -151,6 +151,7 @@ export { parseNodePathExistsResult, parseNodeProbeEntries, parseNodePromptDeliver, + parseNodeSshAgentIdentities, parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -251,11 +252,13 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + isSshGrantFingerprint, isSshGrantFingerprints, isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, + type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, @@ -307,6 +310,8 @@ export { verifyRelayEnvelope, } from "./ssh-relay.js"; export type { + NodeSshAgentIdentitiesResult, + NodeSshAgentIdentity, NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index d5bf04075..45910346a 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1889,6 +1889,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_discover_aliases": case "ssh_resolve_config": case "ssh_register_identity": + case "ssh_agent_identities": case "ssh_relay_open": case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index c8a30440a..1271e1141 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,8 +23,15 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; -import type { NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +import { isSshGrantFingerprint } from "./ssh-frames.js"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import type { + NodeSshAgentIdentitiesResult, + NodeSshAgentIdentity, + NodeSshAliasListResult, + NodeSshIdentityResult, + NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -585,6 +592,29 @@ export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcome return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; } +/** + * Validates and narrows an `ssh_agent_identities` command's `result{data}` + * (spec 2026-10-08 §5.4, Task 11). The BLOBS are unrepresentable by + * construction: each entry is REBUILT from its two checked fields, so a + * `blob` member a buggy or hostile node tried to ship drops here and the + * narrowed answer has nowhere to hold key material. Fingerprints must be in + * the grant grammar's own spelling (one predicate, both directions: the + * approve surface takes roster values verbatim); comments are OpenSSH's + * labels, passed through as bounded text, never parsed. + * @param data - the `data` member of a successful result frame + * @returns the narrowed roster, or null when malformed + */ +export function parseNodeSshAgentIdentities(data: unknown): NodeSshAgentIdentitiesResult | null { + if (!isRecord(data) || !Array.isArray(data.identities)) return null; + const identities: NodeSshAgentIdentity[] = []; + for (const entry of data.identities as unknown[]) { + if (!isRecord(entry) || !isSshGrantFingerprint(entry.fingerprint)) return null; + if (!isStr(entry.comment) || entry.comment.length > SSH_NAME_MAX_CHARS) return null; + identities.push({ fingerprint: entry.fingerprint, comment: entry.comment }); + } + return { identities }; +} + /** * Validates and narrows an `ssh_register_identity` command's `result{data}` * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index c2731f96a..857213128 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,9 +9,11 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds three commands here: + * refused. The sealed agent-relay milestone (M2) adds four commands here: * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches - * the plane inside a signed command on the existing node link), and the + * the plane inside a signed command on the existing node link), the roster + * read `ssh_agent_identities` (§5.4: the approval screen asks A's agent for + * its public identities, fingerprints plus comments, blobs withheld), and the * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN * hands one machine the whole pairing, the CLOSE names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a @@ -64,6 +66,20 @@ export interface SshRegisterIdentityCommand { type: "ssh_register_identity"; } +/** + * Enumerate the key home's LIVE agent identities for the first-use approval + * screen (spec 2026-10-08 §5.4, Task 11): the plane asks A's agent for its + * WHOLE public roster, and the answer is fingerprints plus comments with the + * blobs withheld. No input beyond the type: the command asks the roster, so a + * plane-sent selection field could only be a narrowing or widening attempt at + * what A's own agent reports. It is answered with no grant outstanding and no + * relay session, like `detect` and `ssh_register_identity`, and it writes no + * audit row on either side. + */ +export interface SshAgentIdentitiesCommand { + type: "ssh_agent_identities"; +} + /** * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the * key home whose agent signs, B is the connecting machine that holds none. @@ -170,6 +186,7 @@ export type SshNodeCommandBody = | SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand + | SshAgentIdentitiesCommand | SshRelayOpenCommand | SshRelayCloseCommand; @@ -178,6 +195,7 @@ export const SSH_COMMAND_TYPES = [ "ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity", + "ssh_agent_identities", "ssh_relay_open", "ssh_relay_close", ] as const; @@ -212,6 +230,18 @@ function isIdStr(value: unknown): value is string { */ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; +/** + * Whether `value` is ONE fingerprint in the canonical display grammar. This is + * the roster answer's grammar too, deliberately the SAME predicate: the + * approve surface promises selections "exactly as the roster reports them", + * so a fingerprint the roster may carry and a grant may not select would be + * one broken round trip invented by drift. Exported for `node-results.ts`'s + * roster validator. + */ +export function isSshGrantFingerprint(value: unknown): value is string { + return isStr(value) && GRANT_FINGERPRINT_RE.test(value); +} + /** * The pane id the relay-open command names: exactly the shape pane-runtime's * socket-path composition accepts (`buildSshConfigPath`'s own guard - the @@ -242,9 +272,7 @@ export function isSshPaneId(value: unknown): value is string { * (one definition of the grant-selection law, both directions). */ export function isSshGrantFingerprints(value: unknown): value is string[] { - return ( - isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS - ); + return isStrArray(value) && value.every(isSshGrantFingerprint) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; } /** @@ -268,6 +296,11 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // The whole command is its type: the machine answers about ITSELF, and // the answer's shape is `node-results.ts`'s business, not this grammar's. return { type: "ssh_register_identity" }; + case "ssh_agent_identities": + // The roster read is its type alone, for the same reason: the command + // asks the WHOLE roster, so a plane-sent field could only aim at + // narrowing or widening what A's agent actually reports. + return { type: "ssh_agent_identities" }; case "ssh_relay_open": { // EVERY pairing field is required (§5.1): this command IS the pairing, // and an executor guessing a missing half would be guessing trust diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index f86027ea5..daef58083 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -2,11 +2,12 @@ import type { SshConnectionSnapshotWire } from "./ssh-config.js"; import type { SshErrorCode } from "./ssh-errors.js"; /** - * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery - * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; - * exported NAMES unchanged). The test/run/control envelopes retired with the - * destination product (design 2026-10-05 §7). Their `parse*` validators - * (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`) live in + * The SSH result ENVELOPE types - what the `ssh_*` commands' `result{data}` + * carries (the Gate A split from `ssh-frames.ts`; exported NAMES unchanged). + * The test/run/control envelopes retired with the destination product (design + * 2026-10-05 §7); M2 added the identity report and the §5.4 roster. Their + * `parse*` validators (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`, + * `parseNodeSshIdentity`, `parseNodeSshAgentIdentities`) live in * `node-results.ts` beside every other result validator. * * Imports no `node:` builtin; lives in the Metro-safe barrel. @@ -71,3 +72,29 @@ export interface NodeSshIdentityResult { /** JSON-serialized public JWK (P-256 / ES256) - never the private half. */ signingPublicKey: string; } + +/** + * One entry of the `ssh_agent_identities` answer (spec 2026-10-08 §5.4, + * Task 11): the public identity A's agent carries, spelled so the operator's + * selection can round trip into a grant unchanged. + */ +export interface NodeSshAgentIdentity { + /** The OpenSSH display fingerprint: `SHA256:` + base64url over the agent WIRE encoding of the public blob (the grant grammar's shape, {@link isSshGrantFingerprint}). */ + fingerprint: string; + /** OpenSSH's label for the key, passed through as text; the empty comment is legal. */ + comment: string; +} + +/** + * `ssh_agent_identities` answer (spec 2026-10-08 §5.4): A's agent's WHOLE + * public roster as fingerprints plus comments, with the key BLOBS withheld. + * The shape has no slot for key material by construction: the validator + * rebuilds each entry from its two checked fields, so a `blob` member a buggy + * or hostile node tried to ship is dropped at the grammar, and an empty + * roster from a live agent is a legal answer (distinct from the named error + * an offline or unparseable roster produces, which never reaches this shape). + */ +export interface NodeSshAgentIdentitiesResult { + /** Every public identity A's live agent carries; the approval screen's choice list. */ + identities: NodeSshAgentIdentity[]; +} From db89b0463d3ec6b7ec908ad0eb1f3b8f30b45ff3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:16:02 -0700 Subject: [PATCH 119/170] feat(ssh-relay): ssh_agent_identities enumerates A's public roster for grant approval Task 11 (spec 2026-10-08 SS5.4): the A-side handler gates on the ssh-enabled mirror, probes the live agent's numbering (never a guessed codepoint), asks the identities request in the resolved scheme, and answers fingerprints + comments with the blob bytes discarded before the validator sees them. No audit row; no agent socket, no resolved scheme, and an unparsable roster each name their refusal so the plane's approval question stays pending. --- .../__tests__/commands-ssh-identity.test.ts | 207 +++++++++++++++++- apps/node/agent/src/commands/index.ts | 8 +- apps/node/agent/src/commands/ssh-identity.ts | 91 +++++++- 3 files changed, 303 insertions(+), 3 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts index 9d0c5c4fb..234da3d4d 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -1,11 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { parseNodeSshAgentIdentities, parseNodeSshIdentity } from "@internal/subshell-protocol"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; +import { execSshAgentIdentities } from "../commands/ssh-identity.js"; import { loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; /** * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine answers its @@ -99,3 +103,204 @@ describe("ssh_register_identity", () => { expect(readdirSync(dir)).not.toContain("node-signing-identity.json"); }); }); + +/* ------------------------------------------------------------------ */ +/* ssh_agent_identities (spec 2026-10-08 §5.4, Task 11) */ +/* ------------------------------------------------------------------ */ + +/** + * The roster command: probe A's agent scheme, ask the identities request, + * and answer `{ identities: [{ fingerprint, comment }] }` with the blobs + * WITHHELD. Driven with a scripted requestAgent (the relay-agent-scheme + * test's fixture posture): the probe is real (candidate order and + * positive-confirmation are what the 2026-10-08 ruling pins), only the + * socket is a stand-in. + */ + +const GATE_STAMP = "2026-10-08T10:00:00.000Z"; + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** Independent oracle: SHA-256 over the wire blob, base64url (never the production helper). */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_LAPTOP = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("LAPTOP-BLOB"))]); +const KEY_BACKUP = Buffer.concat([sshStr("ssh-rsa"), sshStr(Buffer.from("BACKUP-BLOB"))]); + +function identitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +const ROSTER = [ + { blob: KEY_LAPTOP, comment: "laptop key" }, + { blob: KEY_BACKUP, comment: "backup key" }, +]; + +/** A scripted agent: records every payload, answers per the script. */ +function scriptedAgent(script: (payload: Buffer) => Buffer): { + sent: Buffer[]; + requestAgent: (path: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_path: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + return script(payload); + }, + }; +} + +/** Gate-ON context in a fresh dir (the mirror written by its own writer). */ +function gatedDir(tag: string): string { + const dir = join(base, `${tag}-gated`, "data"); + mkdirSync(dir, { recursive: true }); + writeSshEnabled(dir, { on: true, changedAt: GATE_STAMP }); + return dir; +} + +describe("ssh_agent_identities", () => { + it("answers the roster as fingerprints + comments in BOTH schemes, and the blob bytes never ride out", async () => { + for (const [scheme, probeScript] of [ + // A 10.x agent: the classic candidate answers FAILURE(5), byte 11 resolves 10.x. + [ + OPENSSH_10X_SCHEME, + (p: Buffer): Buffer | null => + p.equals(Buffer.from([13])) ? Buffer.from([5]) : identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER), + ], + // A classic agent resolves on the FIRST probe, no second round trip. + [ + CLASSIC_SCHEME, + (p: Buffer): Buffer | null => (p.equals(Buffer.from([13])) ? identitiesAnswer(CLASSIC_SCHEME, ROSTER) : null), + ], + ] as const) { + const agent = scriptedAgent((p) => { + const a = probeScript(p); + if (a === null) throw new Error(`unexpected roster request byte ${p[0]} for ${scheme.name}`); + return a; + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir(scheme.name)), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(true); + const parsed = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(parsed).toEqual({ + identities: [ + { fingerprint: fp(KEY_LAPTOP), comment: "laptop key" }, + { fingerprint: fp(KEY_BACKUP), comment: "backup key" }, + ], + }); + // The roster request went ONLY as the RESOLVED scheme's identities byte. + const identitiesRequests = agent.sent.filter((p) => p.length === 1 && p[0] === scheme.identities); + expect(identitiesRequests.length).toBe(2); // probe (positive confirmation) + roster ask + if (scheme === CLASSIC_SCHEME) expect(agent.sent.length).toBe(2); // no second candidate was ever sent + // Blobs withheld: no spelling of either blob appears in the serialized frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_LAPTOP, KEY_BACKUP]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + expect(wire).not.toContain("LAPTOP-BLOB"); + expect(wire).not.toContain("BACKUP-BLOB"); + } + }); + + it("an empty roster from a live agent is the honest answer, not an error", async () => { + const agent = scriptedAgent((p) => + p.equals(Buffer.from([13])) + ? identitiesAnswer(CLASSIC_SCHEME, []) + : (() => { + throw new Error("never"); + })(), + ); + const result = await execSshAgentIdentities(makeCtx(gatedDir("empty")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok && result.data).toEqual({ identities: [] }); + }); + + it("the gate speaks FIRST: an unmirrored machine refuses before the socket or the agent", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const dir = join(base, "roster-gate-off", "data"); + mkdirSync(dir, { recursive: true }); // deliberately no mirror: fail-closed OFF + const result = await execSshAgentIdentities(makeCtx(dir), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result).toEqual({ ok: false, error: "ssh disabled on this node" }); + expect(agent.sent).toEqual([]); + }); + + it("no live agent socket is a named error and the agent is never asked", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-sock")), { + resolveAgentSocket: () => null, + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("agent socket"); + expect(agent.sent).toEqual([]); + }); + + it("an agent that resolves NO scheme is refused by name; nothing is ever asked under a guess", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-scheme")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("numbering"); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates probed, then refusal + }); + + it("a roster answer that does not parse is refused, never partially answered", async () => { + // The probe (first byte-11 ask) resolves 10.x positively; the SECOND + // byte-11 ask is the roster read, and THAT answer comes back truncated. + let elevenAsks = 0; + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); // the classic candidate fails + elevenAsks += 1; + if (elevenAsks === 1) return identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER); // probe: valid, resolves + return Buffer.from([12, 0, 0, 0, 5, ...sshStr(KEY_LAPTOP).subarray(0, 3)]); // roster: truncated + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir("malformed")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("did not parse"); + }); + + it("dispatch routes the arm: a gate-ON machine with no SSH_AUTH_SOCK answers the handler's own named error, not `unsupported`", async () => { + const saved = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = ""; // the honest no-agent spelling liveAgentSocketPath must refuse + try { + const result = await dispatchCommand(makeCtx(gatedDir("route")), { type: "ssh_agent_identities" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toInclude("agent socket"); + expect(result.error).not.toBe("unsupported"); + } + } finally { + if (saved === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = saved; + } + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 82df78cf6..ed4a45ca3 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,7 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; -import { execSshRegisterIdentity } from "./ssh-identity.js"; +import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; @@ -58,6 +58,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act), + * the roster read `ssh_agent_identities` (spec 2026-10-08 §5.4 - the live + * agent's public identities as fingerprints plus comments, blobs withheld; + * gated like the config arms, because reading the account's agent IS an SSH + * act, and it probes the agent's numbering before asking, never guessing), * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, @@ -145,6 +149,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshResolveConfig(ctx, cmd); case "ssh_register_identity": return await execSshRegisterIdentity(ctx); + case "ssh_agent_identities": + return await execSshAgentIdentities(ctx); case "ssh_relay_open": return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": diff --git a/apps/node/agent/src/commands/ssh-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts index 4290a55de..d1d27616a 100644 --- a/apps/node/agent/src/commands/ssh-identity.ts +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -1,6 +1,11 @@ -import { type JsonValue, parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { type JsonValue, parseNodeSshAgentIdentities, parseNodeSshIdentity } from "@internal/subshell-protocol"; import { loadOrCreateIdentity } from "../identity.js"; +import type { AgentIdentity, AgentRequestFn } from "../relay-agent-scheme.js"; +import { fingerprintAgentBlob, parseIdentitiesAnswer, probeAgentScheme } from "../relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; import type { CommandContext, CommandResult } from "./context.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; /** * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine reports its @@ -31,3 +36,87 @@ export async function execSshRegisterIdentity(ctx: CommandContext): Promise string | null; + /** One framed agent round trip; defaults to the real socket client. */ + requestAgent?: AgentRequestFn; +} + +/** + * `ssh_agent_identities` (spec 2026-10-08 §5.4, Task 11): enumerate this + * machine's LIVE agent public identities for the first-use approval screen, + * as `{ identities: [{ fingerprint, comment }] }` with the key BLOBS + * withheld - the fingerprints are the grant grammar's own `SHA256:` spelling + * (over the agent WIRE encoding, {@link fingerprintAgentBlob}), so whatever + * the operator selects round trips into a grant verbatim. + * + * The three rules this arm keeps: + * - **The gate speaks first** (the config arms' doctrine): this reads the + * connecting account's SSH agent, so it is an SSH act, and a machine whose + * mirror is not ON refuses {@link SSH_GATE_REFUSAL} before any socket is + * consulted. Unlike {@link execSshRegisterIdentity} - the machine's own + * registration - there is no pre-SSH reason to ask a roster. + * - **The scheme is probed, never assumed** (ruling 2026-10-08): the + * identities codepoint depends on which numbering the live agent speaks, + * so {@link probeAgentScheme} runs against the same socket with the same + * positive-confirmation rule as the relay responder, and an agent that + * resolves nothing is refused by name rather than asked under a guess. + * - **Fail closed, never fabricate**: no socket, an unresolvable scheme, a + * refused or unparsable roster each answer `ok:false` with a named error. + * An EMPTY roster from a live agent is the honest answer (`{identities:[]}`) + * - it is A reporting zero keys, which is a fact; the plane's offline path + * keeps the grant request pending precisely so a fabrication cannot read as + * one. No audit row is written here, on either side, and no blob byte + * reaches the result frame, a log line, or the answer's JSON. + * + * @param ctx - the per-daemon execution context (data dir for the gate mirror) + * @param seams - test-only socket/round-trip seams + * @returns `{ ok: true, data: { identities } }`, or the named refusal + */ +export async function execSshAgentIdentities( + ctx: CommandContext, + seams?: AgentIdentitiesSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const socketPath = (seams?.resolveAgentSocket ?? liveAgentSocketPath)(); + if (socketPath === null) { + return { ok: false, error: "no live agent socket (SSH_AUTH_SOCK unset): cannot read the roster" }; + } + const requestAgent = seams?.requestAgent ?? requestLiveAgent; + const scheme = await probeAgentScheme(socketPath, requestAgent); + if (scheme === null) { + return { + ok: false, + error: "cannot resolve the agent's identities numbering: the roster read refuses rather than guess", + }; + } + let answer: Buffer; + try { + answer = await requestAgent(socketPath, Buffer.from([scheme.identities])); + } catch (err) { + return { ok: false, error: `the agent roster request failed: ${err instanceof Error ? err.message : String(err)}` }; + } + let entries: AgentIdentity[]; + try { + entries = parseIdentitiesAnswer(answer, scheme); + } catch { + // A FAILURE answer, a foreign scheme's byte, or a truncated roster: the + // partial-parse risk §5.4 refuses. The plane's caller keeps the approval + // question pending on this error, which is the fail-closed half. + return { ok: false, error: "the agent's IDENTITIES_ANSWER did not parse; refusing to answer a partial roster" }; + } + // The blobs are discarded HERE, before any validator sees the answer: the + // result frame structurally cannot carry one. + const validated = parseNodeSshAgentIdentities({ + identities: entries.map((entry) => ({ + fingerprint: fingerprintAgentBlob(entry.blob), + comment: entry.comment.toString("utf8"), + })), + }); + if (validated === null) return { ok: false, error: "malformed agent roster" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} From 1810b4bdb2e428793fe419af2a5daf56abafa093 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:25:10 -0700 Subject: [PATCH 120/170] feat(ssh-grants): the approvals roster fetch - ssh_agent_identities over the signed RPC Task 11 plane half: sshAgentIdentities wraps the frozen roster parser on the existing ssh-rpc path, listRequestAgentIdentities reads a pending request's key home through it (seam-injected, production default the RPC), and GET /api/ssh/grant-requests/:id/identities renders the choice list for the approval screen. The read writes no audit row and touches no row: offline, outdated, timeout, or refusal answers the named error with the request left pending; an answered request never reaches the machine. --- .../api/ssh/__tests__/grants-route.test.ts | 133 +++++++++- .../server/api/src/api/ssh/approvals.route.ts | 52 +++- .../__tests__/ssh-agent-identities.test.ts | 249 ++++++++++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 32 ++- .../api/src/services/ssh-grants.service.ts | 97 ++++++- 5 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts index 1b5e70973..c6c44b589 100644 --- a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -1,8 +1,8 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { BackendErrorCodes } from "@internal/backend-errors"; -import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { type NodeSshAgentIdentitiesResult, SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; @@ -16,6 +16,7 @@ import { UsersRepository } from "@/db/repositories/users.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; @@ -72,6 +73,11 @@ let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: let closeGrantCalls: { grantId: string; reason: string }[] = []; let notifyCalls: string[] = []; let openRelayThrows: ((input: unknown) => never) | null = null; +/** Scripted roster RPC (Task 11): a canned answer or a canned failure, plus the call log. */ +let rosterAnswer: () => NodeSshAgentIdentitiesResult = () => ({ + identities: [{ fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }], +}); +let rosterCalls: string[] = []; function installFakeDeps() { const broker = { @@ -89,6 +95,10 @@ function installFakeDeps() { nowIso: () => new Date().toISOString(), broker: () => broker, notifyGrantApproval: (_owner, requestId) => notifyCalls.push(requestId), + fetchAgentIdentities: async (nodeId: string) => { + rosterCalls.push(nodeId); + return rosterAnswer(); + }, }); } @@ -568,3 +578,122 @@ describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => } }); }); + +/* ------------------------------------------------------------------ */ +/* GET /api/ssh/grant-requests/:id/identities (Task 11 roster fetch) */ +/* ------------------------------------------------------------------ */ + +describe("GET /api/ssh/grant-requests/:id/identities (the roster behind the approval screen)", () => { + async function mkAsk(paneId: string): Promise { + const asked = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "git.example.test", + paneId, + }); + if (!asked.ok) throw new Error("fixture ask refused"); + return asked.value.requestId; + } + async function queueStatus(id: string): Promise { + const body = (await (await sshFetch("/api/ssh/grant-requests", { cookie: ownerCookie })).json()) as { + requests: { id: string; status: string }[]; + }; + return body.requests.find((r) => r.id === id)?.status; + } + + beforeEach(() => { + installFakeDeps(); // reset the scripted roster RPC and its call log between cases + rosterCalls = []; + }); + + it("answers the key home's public roster, blobs withheld, and the queue row is untouched", async () => { + const requestId = await mkAsk("pane-roster-1"); + rosterAnswer = () => ({ + identities: [ + { fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }, + { fingerprint: `SHA256:${"E".repeat(43)}`, comment: "" }, + ], + }); + const res = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = (await res.json()) as { identities: Record[] }; + expect(body.identities.map((e) => e.fingerprint)).toEqual([`SHA256:${"D".repeat(43)}`, `SHA256:${"E".repeat(43)}`]); + expect(body.identities[0]?.comment).toBe("work laptop"); + expect(body.identities[1]?.comment).toBe(""); // the agent's empty comment is data, not a gap + // The entry shape is exactly the two public fields: nowhere for a blob to ride. + for (const entry of body.identities) expect(Object.keys(entry).sort()).toEqual(["comment", "fingerprint"]); + expect(JSON.stringify(body)).not.toContain("blob"); + expect(rosterCalls).toEqual([NODE_A]); // asked the KEY HOME, exactly once + expect(await queueStatus(requestId)).toBe("pending"); // the read changed nothing + }); + + it("the roster rides the REAL signed RPC: scripted A answers over the link and the command that left was its type alone", async () => { + // Deps WITHOUT the roster seam: production wiring is under test, so the + // command passes through signing, the RPC correlator, and the frozen + // result parser exactly as it does between the shipped processes. + const scripted = attachScriptedNode(NODE_A, { + ssh_agent_identities: () => ({ + identities: [{ fingerprint: `SHA256:${"F".repeat(43)}`, comment: "real path key" }], + }), + }); + setSshGrantsDepsForTests({ + nowIso: () => new Date().toISOString(), + broker: () => + ({ + openRelay: async () => { + throw new Error("the roster fetch opens no relay"); + }, + closeForGrant: async () => 0, + }) as unknown as RelayBroker, + notifyGrantApproval: () => {}, + }); + const requestId = await mkAsk("pane-roster-e2e"); + try { + const res = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = (await res.json()) as { identities: { fingerprint: string; comment: string }[] }; + expect(body.identities).toEqual([{ fingerprint: `SHA256:${"F".repeat(43)}`, comment: "real path key" }]); + // Its type ALONE travelled the link: the roster command asks the whole + // roster, and no plane-side selection field reached the machine. + expect(scripted.cmdsOf("ssh_agent_identities")).toEqual([{ type: "ssh_agent_identities" }]); + } finally { + scripted.detach(); + installFakeDeps(); + } + }); + + it("an unreachable key home answers the named refusal with the request PENDING; an answered one is never re-asked", async () => { + const requestId = await mkAsk("pane-roster-offline"); + rosterAnswer = () => { + throw new SshRpcError("offline", "node has no live connection", NODE_A); + }; + const offline = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(offline.status).toBe(409); + expect(((await offline.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + expect(await queueStatus(requestId)).toBe("pending"); // §5.4: stays pending until A is reachable + const approved = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [`SHA256:${"D".repeat(43)}`] }, + }); + expect(approved.status).toBe(200); + rosterCalls = []; + rosterAnswer = () => { + throw new Error("the machine must not be asked after the answer"); + }; + const after = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(after.status).toBe(409); + expect(((await after.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(rosterCalls).toEqual([]); + }); + + it("cookie doctrine and ownership: an empty cookie is a 401, a stranger's cookie sees the absent-row 404, and no machine is asked", async () => { + const requestId = await mkAsk("pane-roster-stranger"); + rosterCalls = []; + expect((await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: "" })).status).toBe(401); + const foreign = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + expect(rosterCalls).toEqual([]); + }); +}); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts index 4fc30ee96..f7bba50f5 100644 --- a/apps/server/api/src/api/ssh/approvals.route.ts +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -3,13 +3,19 @@ import { Elysia, t } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { SshGrantRequestViewSchema, SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; import { apiModels } from "@/schema/index.js"; -import { approveGrant, denyGrant, listGrantRequests } from "@/services/ssh-grants.service.js"; +import { + approveGrant, + denyGrant, + listGrantRequests, + listRequestAgentIdentities, +} from "@/services/ssh-grants.service.js"; /** * `/api/ssh/grant-requests` - the first-use approval queue (spec 2026-10-08 * §6.2, modeled on the signup pending-approvals queue): GET the owner's - * pending questions, POST `/:id/approve` (selects the fingerprints and writes - * the standing grant), POST `/:id/deny` (an answer, no grant). + * pending questions, GET `/:id/identities` (the key home's public agent + * roster to choose FROM, §5.4), POST `/:id/approve` (selects the fingerprints + * and writes the standing grant), POST `/:id/deny` (an answer, no grant). * * The actor model is decision 5 stated concretely: rows are owned by the key * home's owner, the asking launch could only have gated A through @@ -48,6 +54,46 @@ export const sshGrantRequestsRoutes = new Elysia() }, }, ) + .get( + "/grant-requests/:id/identities", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await listRequestAgentIdentities({ ownerUserId: user.id, requestId: params.id }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { identities: answer.value.identities }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + response: { + 200: t.Object({ + identities: t.Array( + t.Object({ + fingerprint: t.String({ + description: + "Public agent identity in the canonical SHA256: notation (the approval body sends these back verbatim)", + }), + comment: t.String({ description: "OpenSSH's label for the key, as the agent reports it (display only)" }), + }), + { + description: + "The key home's WHOLE public roster with the key blobs withheld; the operator selects the grant's subset (cap enforced at approval, never by truncating this list)", + }, + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "listSshGrantRequestAgentIdentities", + tags: ["ssh"], + description: + "Enumerates the key home's live agent identities for the approval screen (fingerprints plus comments, blobs withheld); an offline or refusing key home answers a named error and the request stays pending, never a fabricated empty roster", + }, + }, + ) .post( "/grant-requests/:id/approve", async ({ body, params, user, actor }) => { diff --git a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts new file mode 100644 index 000000000..40e29dc26 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts @@ -0,0 +1,249 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeSshAgentIdentitiesResult } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + approveGrant, + listRequestAgentIdentities, + requestFirstUse, + setSshGrantsDepsForTests, +} from "@/services/ssh-grants.service.js"; +import type { RelayBroker } from "@/services/ssh-relay.service.js"; + +/** + * The roster fetch behind the approvals screen (spec 2026-10-08 §5.4, Task + * 11): `listRequestAgentIdentities` sends A the signed `ssh_agent_identities` + * command over the existing node-link RPC and hands the operator's screen + * the WHOLE public roster - fingerprints plus comments, blobs withheld (the + * protocol validator makes them unrepresentable; this suite pins the pass- + * through adds nothing). What the approval screen must never lose: + * + * - NOTHING rides a roster read: no audit row, no row change, no grant. The + * fetch is a question to the machine, and the durable record of an answer + * stays the approve/deny pair. + * - A is offline / refused / too old / silent: the named refusal answers and + * the grant request STAYS PENDING. Never an empty fabricated roster, which + * is exactly the fiction §5.4 forbids - a real empty roster from a live + * agent is data (last case), the offline case is an error. + * - Ownership: a foreign request is the 404 the grants tier already answers; + * an answered request never reaches the machine (no roster read after the + * operator has said yes or no); expiry sweeps lazily first, like every + * other read on this table. + * + * The roster RPC is the service's injected seam (the clock/broker/notify + * trio's fourth member, the `??` at the call site is the production wiring); + * nothing here dials a socket. + */ + +const repo = new SshGrantsRepository(db); +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `roster-svc-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("roster-svc-1"), + role: "user", + }); +} + +let owner: string; +let stranger: string; +const NODE_A = "roster-node-a"; +const NODE_B = "roster-node-b"; + +const HOST = "git.example.test"; +const PANE = "11111111-2222-4333-8444-555555555555"; + +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); +const nowIso = () => new Date(clockMs).toISOString(); + +/** The scripted roster RPC: a canned answer, or a canned transport failure. */ +let rosterResult: NodeSshAgentIdentitiesResult = { identities: [] }; +let rosterFailure: (() => Error) | null = null; +let rosterCalls: string[] = []; + +function installRosterDeps(): void { + rosterResult = { + identities: [{ fingerprint: `SHA256:${"A".repeat(43)}`, comment: "laptop key" }], + }; + rosterFailure = null; + rosterCalls = []; + setSshGrantsDepsForTests({ + nowIso, + broker: () => + ({ + openRelay: async () => { + throw new Error("the roster fetch opens no relay"); + }, + closeForGrant: async () => 0, + }) as unknown as RelayBroker, + notifyGrantApproval: () => {}, + fetchAgentIdentities: async (nodeId: string): Promise => { + rosterCalls.push(nodeId); + if (rosterFailure) throw rosterFailure(); + return rosterResult; + }, + }); +} + +async function mkPendingRequest(): Promise { + const answer = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: PANE, + }); + if (!answer.ok) throw new Error("the pending row was not created"); + return answer.value.requestId; +} + +async function auditCount(): Promise { + return (await db.selectFrom("auditEvents").select("id").execute()).length; +} + +async function requestStatus(requestId: string): Promise { + return (await repo.getRequest(owner, requestId))?.status; +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); + const nodes = new NodesRepository(db); + await nodes.create({ id: NODE_A, ownerUserId: owner, name: "key home", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_A, { on: true, changedAt: nowIso() }); + await nodes.create({ id: NODE_B, ownerUserId: owner, name: "connector", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_B, { on: true, changedAt: nowIso() }); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const id of [NODE_A, NODE_B]) await new NodesRepository(db).deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + installRosterDeps(); +}); + +describe("listRequestAgentIdentities", () => { + it("hands the screen the roster verbatim, asked the KEY HOME once, and writes nothing", async () => { + const requestId = await mkPendingRequest(); + const auditsBefore = await auditCount(); // the request itself audited the ask + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual(rosterResult.identities); + // The entry shape is EXACTLY fingerprint + comment: no blob, no extra field, + // nowhere for key material to ride (the protocol grammar refuses one, and + // this is the pass-through that must not re-add it). + for (const entry of answer.value.identities) { + expect(Object.keys(entry).sort()).toEqual(["comment", "fingerprint"]); + } + expect(JSON.stringify(answer.value)).not.toContain("blob"); + expect(rosterCalls).toEqual([NODE_A]); + // No audit row, no row change: the fetch is a question, not an answer. + expect(await auditCount()).toBe(auditsBefore); + expect(await requestStatus(requestId)).toBe("pending"); + }); + + it("an offline A is the named 409 NODE_OFFLINE and the request STAYS PENDING", async () => { + const requestId = await mkPendingRequest(); + const auditsBefore = await auditCount(); + rosterFailure = () => new SshRpcError("offline", "node has no live connection", NODE_A); + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(409); + expect(answer.refusal.code).toBe(BackendErrorCodes.NODE_OFFLINE); + expect(rosterCalls).toEqual([NODE_A]); + expect(await auditCount()).toBe(auditsBefore); + expect(await requestStatus(requestId)).toBe("pending"); + }); + + it("the failure families: unsupported is the update remedy, timeout the retry, refusal the 502, and none fabricates a roster", async () => { + for (const [kind, status, code] of [ + ["unsupported", 409, BackendErrorCodes.NODE_OUTDATED], + ["timeout", 409, BackendErrorCodes.NODE_UNREACHABLE], + ["refused", 502, BackendErrorCodes.SSH_NODE_REFUSED], + ["malformed", 502, BackendErrorCodes.SSH_NODE_REFUSED], + ] as const) { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("auditEvents").execute(); + const requestId = await mkPendingRequest(); + rosterFailure = () => new SshRpcError(kind, `machine said no (${kind})`, NODE_A, "ssh disabled on this node"); + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(status); + expect(answer.refusal.code).toBe(code); + // The agent's own text is never echoed into a response body. + expect(answer.refusal.message).not.toContain("ssh disabled"); + expect(await requestStatus(requestId)).toBe("pending"); + expect(await auditCount()).toBe(1); // the request's own ask row, and nothing from the fetch + } + }); + + it("an already-approved request is the named 409 and the machine is never asked again", async () => { + const requestId = await mkPendingRequest(); + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: [`SHA256:${"C".repeat(43)}`] }); + expect(approved.ok).toBe(true); + rosterCalls = []; + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(rosterCalls).toEqual([]); + }); + + it("a foreign or absent request id is the same 404 the grants tier answers, and no machine is asked", async () => { + const requestId = await mkPendingRequest(); + const foreign = await listRequestAgentIdentities({ ownerUserId: stranger, requestId }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + const absent = await listRequestAgentIdentities({ ownerUserId: owner, requestId: crypto.randomUUID() }); + expect(absent.ok).toBe(false); + expect(rosterCalls).toEqual([]); + }); + + it("a past-deadline pending row is swept before the read: the question is expired, the roster never fetched", async () => { + const requestId = await mkPendingRequest(); + clockMs += 24 * 60 * 60 * 1000 + 1; // past the 24 h answer window + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(await requestStatus(requestId)).toBe("expired"); + expect(rosterCalls).toEqual([]); + }); + + it("an empty roster from a live agent is an honest answer, not an error", async () => { + const requestId = await mkPendingRequest(); + rosterResult = { identities: [] }; + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual([]); + expect(rosterCalls).toEqual([NODE_A]); + }); +}); diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index 5e8f63264..b00499089 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,6 +1,8 @@ import { + type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, + parseNodeSshAgentIdentities, parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -9,8 +11,9 @@ import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; /** * The ssh commands as plane-side RPC wrappers (spec 2026-10-07 §5 for the - * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap). - * Each is `sendCommand` plus ITS frozen result parser: + * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap and + * the §5.4 agent roster the approval screen enumerates). Each is `sendCommand` + * plus ITS frozen result parser: * the value these return is a validated plain object (or the function throws), * so no caller can branch on a machine answer it has not parsed. * @@ -122,3 +125,28 @@ export async function sshRegisterIdentity(nodeId: string): Promise { } return parsed.signingPublicKey; } + +/** + * Ask one machine for its LIVE agent's public roster (spec 2026-10-08 §5.4, + * Task 11): the approval screen's choice list, as the frozen validator + * narrows it - fingerprints plus comments, the blobs already withheld on the + * machine (the grammar cannot carry them). The command asks the WHOLE + * roster, so this wrapper has no input beyond the target: a selection sent + * from the plane would be a decision about A's keys made where A's agent is + * the only witness. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshAgentIdentities(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_agent_identities" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAgentIdentities(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed agent roster`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index be8bbcf24..0711e251b 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -1,10 +1,17 @@ import { BackendErrorCodes } from "@internal/backend-errors"; -import { isSshGrantFingerprints, SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; +import { + isSshGrantFingerprints, + type NodeSshAgentIdentitiesResult, + type NodeSshAgentIdentity, + SSH_MAX_GRANT_FINGERPRINTS, + SSH_NAME_MAX_CHARS, +} from "@internal/subshell-protocol"; import { db } from "@/db/index.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { type NewSshGrantRequest, SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshAgentIdentities } from "@/services/nodes/ssh-rpc.js"; import { getNotifyService } from "@/services/notify.service.js"; import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -41,8 +48,9 @@ import { logger } from "@/utils/logger.js"; * file writes; no key material, challenge, or signature ever rides any row. * * Everything outside the module is an injected seam (the `getNodeWsDeps` - * pattern): the clock, the relay broker, and the notification sink. The - * production defaults sit at the bottom; tests replace the whole trio. + * pattern): the clock, the relay broker, the notification sink, and the + * roster RPC the approval screen reads. The production defaults sit at the + * bottom; tests replace the whole set. */ /** How long an unanswered first-use request stays answerable: setup-key scale (24 h). */ @@ -58,6 +66,13 @@ export interface SshGrantsDeps { broker(): RelayBroker; /** Ring the owner about one pending request (fire-and-forget; the sink swallows its own failures). */ notifyGrantApproval(ownerUserId: string, requestId: string): void; + /** + * Ask one machine for its live agent's public roster over the node link + * (default: `ssh-rpc.sshAgentIdentities`, the `??` at the call site being + * the production wiring - the NodeWsDeps.detect shape). A test seam so the + * approval surface can pin fail-closed behavior without a live socket. + */ + fetchAgentIdentities?(nodeId: string): Promise; } let depsOverride: SshGrantsDeps | null = null; @@ -563,6 +578,82 @@ export async function listGrantRequests(args: { })); } +/* ------------------------------------------------------------------ */ +/* the roster fetch: the approval screen's choice list (§5.4, Task 11) */ +/* ------------------------------------------------------------------ */ + +/** + * Read the key home's public agent roster for the approval screen (spec + * 2026-10-08 §5.4): the signed `ssh_agent_identities` command goes to A over + * the existing node-link RPC, and the answer is A's WHOLE roster - the + * operator selects the grant's subset from it, capped at approval, never + * here. The read is a question, not an answer: it writes NO audit row and + * changes no row, and every failure (offline, outdated, timeout, refusal, + * malformed answer) answers the named error with the request left PENDING. + * Fabricating an empty roster would read to the operator as "A holds no + * keys"; an empty roster that is the truth comes from a live agent and rides + * the answer untouched. + */ +export async function listRequestAgentIdentities(args: { + ownerUserId: string; + requestId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + try { + const fetcher = grantsDeps().fetchAgentIdentities ?? sshAgentIdentities; // the `??` is the production wiring + const roster = await fetcher(row.keyHomeNodeId); + return granted({ identities: roster.identities }); + } catch (err) { + if (err instanceof SshRpcError) return refused(rosterRpcRefusal(err)); + throw err; + } +} + +/** + * The roster RPC's failure as a grant-surface refusal: the same code family + * `ssh-launch`'s `rpcRefusal` uses (they are the same doors on the same + * link), with the KEY HOME named in the copy and the agent's own text kept + * out of the response (it reaches the log on the refused/malformed arm, per + * the files-remote-browse posture). The pending row rides untouched through + * every arm - §5.4's "stays pending until A is reachable" is exactly the + * absence of a write here. + */ +function rosterRpcRefusal(err: SshRpcError): SshRefusalNarrow { + if (err.kind === "offline") { + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "The key home has no live connection right now; bring its Subshell app online and ask again.", + }; + } + if (err.kind === "unsupported") { + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "The Subshell app on the key home is too old to report its agent keys. Update it from its machine page.", + }; + } + if (err.kind === "timeout") { + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "The key home did not answer the roster request in time; check its connection and ask again.", + }; + } + logger + .withError(err) + .warn(`ssh roster rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The key home refused the roster request. Check that SSH is switched on there and an agent is running.", + }; +} + /* ------------------------------------------------------------------ */ /* the launch leg: match -> open (or ask -> refuse) */ /* ------------------------------------------------------------------ */ From 998eacc1dbbe24f1f2c142a3a39e32aa55186e3c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:26:48 -0700 Subject: [PATCH 121/170] test(ssh-relay): flip matrix case 10 - ssh_agent_identities answered with no grant Task 9's deferred todo, now real: the roster read runs WITHOUT the stack - no broker, no pairing, no grant - because A's live agent and gate mirror are its entire world. Asserts the WHOLE roster (not grant-filtered) answers with fingerprints + comments, both blob encodings absent from the frame, the probe sent only one-byte candidate requests, and the data dir gained nothing. --- .../__tests__/helpers/ssh-relay-stack.ts | 8 +- .../__tests__/ssh-relay.integration.test.ts | 75 +++++++++++++++---- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index c71552136..733080600 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -156,7 +156,13 @@ export interface StubAgent { close(): Promise; } -async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { +/** + * Start the scheme-behavior stub agent on its own socket. Exported for the + * roster-command case (Task 11): the `ssh_agent_identities` read needs A's + * live agent and NOTHING ELSE - no broker, no grant, no pairing - so it + * drives the stub without the full stack. + */ +export async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-stub-")); const path = join(dir, "agent.sock"); const received: Buffer[] = []; diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index 83e6402c8..20ee64bac 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -1,16 +1,24 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildAgentSocketPath } from "@internal/pane-runtime"; import { base64UrlNoPad, newNonce, + parseNodeSshAgentIdentities, parseRelayFrame, type RelayFrame, SSH_RELAY_LIFETIME_MS, type SshRelayOpenCommand, } from "@internal/subshell-protocol"; import { exportJWK, generateKeyPair } from "jose"; +import type { CommandContext } from "../../../../../node/agent/src/commands/context.js"; +import { execSshAgentIdentities } from "../../../../../node/agent/src/commands/ssh-identity.js"; import { openARelaySession, openBRelaySession } from "../../../../../node/agent/src/commands/ssh-relay.js"; +import { OPENSSH_10X_SCHEME } from "../../../../../node/agent/src/relay-agent-scheme.js"; +import { writeSshEnabled } from "../../../../../node/agent/src/ssh-enabled.js"; import { buildSignRequestTenX, decodeA2B, @@ -23,11 +31,13 @@ import { openStack, PANE_ID, parseAnswer, + ROSTER, type Stack, sealA2B, sealB2A, sleep, sshStr, + startStubAgent, waitUntil, } from "./helpers/ssh-relay-stack.js"; @@ -50,8 +60,9 @@ import { * A's agent, roster answer first or not. * * Matrix case 10 (the `ssh_agent_identities` roster command accepted with no - * grant) is DEFERRED to Task 11, which builds that command; the todo at the - * bottom names it. + * grant) shipped with Task 11, which built the command; it is the one case + * that runs WITHOUT the stack, because the roster read consults nothing but + * A's live agent and A's own gate mirror. */ /** An ES256 pair pinned by nobody: signatures from it are forgeries. */ @@ -601,18 +612,52 @@ test("9. a forged A2B signature fails the pane's connection without a byte writt }); /* ------------------------------------------------------------------ */ -/* 10. the roster command (DEFERRED to Task 11) */ +/* 10. the roster command: answered with no grant, no relay session */ /* ------------------------------------------------------------------ */ -// Matrix case 10 - `ssh_agent_identities` accepted with no grant - belongs to -// Task 11: the roster command does not exist yet and this task must not -// build it. The responder-side surface (fingerprintAgentBlob, -// parseIdentitiesAnswer) is already exported for that task to reuse. -const TODO_PLACEHOLDER = (): void => { - /* Task 11 will drive the roster command here. */ -}; - -test.todo( - "10. (Task 11) ssh_agent_identities is accepted with no grant and answers the filtered roster", - TODO_PLACEHOLDER, -); +// Matrix case 10, flipped from the Task 9 todo now that the command exists +// (spec 2026-10-08 §5.4, Task 11). The whole matrix stack is deliberately +// ABSENT: there is no broker, no grant, no pairing, and no B. The point of +// the case is that the roster read needs none of that - A's live agent and +// A's own gate mirror are its entire world. +test("10. ssh_agent_identities is accepted with no grant and no relay session: the WHOLE roster answers, blobs withheld", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-roster-")); + const stub = await startStubAgent(ROSTER, OPENSSH_10X_SCHEME); + writeSshEnabled(dir, { on: true, changedAt: "2026-10-08T10:00:00.000Z" }); + const savedSock = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = stub.path; // the handler's production socket lookup, over a real round trip + try { + // The arm dispatch would run, on the same data-dir-only context the node + // suite builds; routing of the type is the node suite's own pin. + const result = await execSshAgentIdentities({ config: { dataDir: dir } } as unknown as CommandContext); + expect(result.ok).toBe(true); + const roster = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(roster).not.toBeNull(); + // The WHOLE roster, not a grant-filtered one: with nothing granted there + // is no set to scope by, and the approval screen must show both keys. + expect(roster?.identities).toEqual([ + { fingerprint: fp(KEY_IN), comment: "granted key" }, + { fingerprint: fp(KEY_OUT), comment: "ungranted key" }, + ]); + // The blobs are withheld: neither wire encoding of either blob appears + // anywhere in the serialized result frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_IN, KEY_OUT]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + // Only one-byte requests ever reached the agent: the classic candidate + // (13) confirmed FAILURE, the 10.x candidate (11) resolved the scheme + // positively, and the roster was asked in the RESOLVED numbering. No + // sign body, no session byte: nothing the responder's rules exclude. + expect(stub.received).toEqual([Buffer.from([13]), Buffer.from([11]), Buffer.from([11])]); + // Nothing was written anywhere the read could not already see: the data + // dir holds exactly the gate mirror, no session state, no audit artifact. + expect(readdirSync(dir)).toEqual(["ssh-enabled.json"]); + } finally { + if (savedSock === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = savedSock; + await stub.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); From 51e10425093e2dee0d41082fd66e3ffbf1351efd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:38:53 -0700 Subject: [PATCH 122/170] fix(node): spell the package.json import with { type: json } The relay integration suite under @internal/server already imports agent modules in place, and Task 11's roster case reaches commands/ssh-identity.ts, whose CommandContext type pulls commands/context.ts - and with it ../update.js - into the server's NodeNext typecheck, where TS1543 refuses a JSON import without the attribute. The attribute is the spelling this repo already ships (server version.ts, this package's release script); runtime behaviour is unchanged (tsdown and bun build inline it either way). --- apps/node/agent/src/version.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/version.ts b/apps/node/agent/src/version.ts index c8227fdb9..e7e0da6f1 100644 --- a/apps/node/agent/src/version.ts +++ b/apps/node/agent/src/version.ts @@ -1,8 +1,12 @@ -import pkg from "../package.json"; +import pkg from "../package.json" with { type: "json" }; /** * The agent's own version, sourced from package.json (the single source of * truth). resolveJsonModule lets tsc type it, Bun's bundler inlines it for - * both tsdown and `bun build --compile`. + * both tsdown and `bun build --compile`. The `with { type: "json" }` attribute + * is what the `@internal/server` typecheck needs: its tests import the agent's + * modules in place under NodeNext, which requires the attribute on a JSON + * import - the spelling the server's own version.ts and this package's + * release script already use. */ export const NODE_VERSION: string = pkg.version; From 9df24127994aeccaae1f8b8d7b5590af5df0a3e1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:59:58 -0700 Subject: [PATCH 123/170] feat(ssh-protocol): the ssh_host_key capture arm and the required relay-open host-pin carriage Task 12 (spec 2026-10-08 S9): the capture command names one resolved destination (host/port/user, every field grammar-checked before the plane signs); its answer is a list of verbatim known_hosts lines sharing ONE shape predicate with the relay-open's pin field (isSshKnownHostsPinLine: one bounded printable line, no newline smuggle, no comment marker). The ssh_relay_open grammar makes hostPin REQUIRED - a pinless relay-open is a relay grant with no pin, which does not exist - same pre-merge grammar-extension class as T8's paneId. Census 6->7. --- .../src/__tests__/node-frames.test.ts | 82 ++++++++++++- .../src/__tests__/node-results-ssh.test.ts | 31 +++++ packages/subshell-protocol/src/index.ts | 6 + packages/subshell-protocol/src/node-frames.ts | 1 + .../subshell-protocol/src/node-results.ts | 31 ++++- packages/subshell-protocol/src/ssh-frames.ts | 109 +++++++++++++++++- packages/subshell-protocol/src/ssh-limits.ts | 18 +++ packages/subshell-protocol/src/ssh-results.ts | 18 +++ 8 files changed, 286 insertions(+), 10 deletions(-) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index b232427eb..314e18661 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -69,6 +69,10 @@ const relayOpenCmd: SshRelayOpenCommand = { fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], lifetimeMs: 30_000, paneId: "11111111-2222-4333-8444-555555555555", + // Task 12: the destination's pinned host-key line (A's known_hosts entry, + // captured at grant creation). Required: a relay-open without it is a + // relay grant with no pin, which is exactly what the grammar refuses. + hostPin: "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000", }; describe("parseNodeCommandBody", () => { @@ -963,17 +967,18 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these six types, and the count is the + // The family's census: exactly these seven types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_agent_identities", "ssh_discover_aliases", + "ssh_host_key", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(6); + expect(SSH_COMMAND_TYPES).toHaveLength(7); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -983,7 +988,9 @@ describe("ssh command arms and the launch ssh block", () => { ? relayOpenCmd : t === "ssh_relay_close" ? { type: t, ref: "r-1", reason: "lifetime-expiry" } - : { type: t }; + : t === "ssh_host_key" + ? { type: t, host: "git.example.test", port: 22, user: null } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -1000,6 +1007,39 @@ describe("ssh command arms and the launch ssh block", () => { }); }); + it("the ssh_host_key arm names a destination and nothing else (spec 2026-10-08 §9, Task 12)", () => { + // The capture command asks for ONE destination's known_hosts entries: host + // always, port always (ssh's own lookup spells the port), user as its + // honest null when the snapshot named none. Stray members do not ride. + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "git.example.test", port: 22, user: null })).toEqual({ + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + }); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: "git.example.test", port: 2222, user: "deploy" }), + ).toEqual({ type: "ssh_host_key", host: "git.example.test", port: 2222, user: "deploy" }); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: "[2001:db8::1]", port: 22, user: null, smuggled: "x" }), + ).not.toHaveProperty("smuggled"); + // Refusals: the host takes the alias grammar (option-like, whitespace, + // control chars, over-long); the port is a positive 16-bit int; the user + // is null or an alias-grammar name. + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "-oProxyCommand=x", port: 22, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "a b", port: 22, user: null })).toBeNull(); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: `h${"x".repeat(300)}`, port: 22, user: null }), + ).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 0, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 65536, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22.5, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: "a b" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: 7 })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22 })).toBeNull(); // user must be present (null is spelled) + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: undefined })).toBeNull(); + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ @@ -1049,6 +1089,7 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => "fingerprints", "lifetimeMs", "paneId", + "hostPin", ] as const) { const partial = structuredClone(relayOpenCmd) as unknown as Record; delete partial[drop]; @@ -1119,6 +1160,41 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parsed).toEqual(relayOpenCmd); }); + it("refuses a hostPin that is not one known_hosts line (spec 2026-10-08 §9, Task 12)", () => { + // The pin carriage is A's recorded key line delivered to B; the grammar's + // job is the SHAPE: one printable line, bounded, never a comment, never a + // multi-line smuggle (the node writes it verbatim into B's 0600 pinned + // file, so a newline would be a second, plane-authored trust entry). + // Deep truth (does the key match D) stays OpenSSH's at connect time. + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "" })).toBeNull(); // empty is no pin + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: " " })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\nsecond ssh-rsa BBB" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\rBBB" })).toBeNull(); // CR smuggle + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\tB\tBB" })).toBeNull(); // tab smuggle + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "# comment line" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: 7 })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: `host ssh-rsa ${"A".repeat(4084)}` })).toBeNull(); // 4097 > SSH_MAX_HOST_PIN_LINE_CHARS + // Honest spellings parse: plain, hashed-pattern, and user-qualified lines + // all carry A's recorded trust in OpenSSH's own known_hosts form. + expect( + parseNodeCommandBody({ ...relayOpenCmd, hostPin: "[git.example.test]:2222 ssh-ed25519 AAAAC3Nza==" }), + ).not.toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + hostPin: + "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000 comment@host", + }), + ).not.toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + hostPin: "|1|bnVsbHNhbHRudWxsc2FsdA==|dGhlaGFzaHRoYXRpc25vdHRoaXM=|ssh-ed25519 AAAAC3NzaC1lZDI1NTE5", + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: `host ssh-rsa ${"A".repeat(4083)}` })).not.toBeNull(); // at the bound (4096 total) + }); + it("refuses fingerprint entries outside the SHA256 display form", () => { // The tight form is what stops control chars (and plain junk) reaching a // future audit row through a field that promises to be a fingerprint. diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index d98fea2df..64a814c1b 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test"; import { parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "../node-results.js"; +import { SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -122,3 +124,32 @@ describe("parseNodeSshIdentity", () => { expect(parseNodeSshIdentity({ signingPublicKey: "null" })).toBeNull(); }); }); + +describe("parseNodeSshHostKey (spec 2026-10-08 §9, Task 12)", () => { + const LINE_A = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; + const LINE_B = "|1|bnVsbHNhbHRudWxsc2FsdA==|dGhlaGFzaA==| ssh-ed25519 AAAA"; + test("narrows a well-formed answer of verbatim known_hosts lines", () => { + expect(parseNodeSshHostKey({ lines: [LINE_A] })).toEqual({ lines: [LINE_A] }); + expect(parseNodeSshHostKey({ lines: [] })).toEqual({ lines: [] }); // the honest "recorded nothing" + }); + test("rebuilds the lines array (extra answer members do not ride)", () => { + const parsed = parseNodeSshHostKey({ lines: [LINE_A, LINE_B], sneaky: "x" }); + expect(parsed).not.toHaveProperty("sneaky"); + expect(parsed?.lines).toEqual([LINE_A, LINE_B]); + }); + test("refuses a malformed or over-cap answer", () => { + expect(parseNodeSshHostKey({ lines: "not an array" })).toBeNull(); + expect(parseNodeSshHostKey({ lines: [7] })).toBeNull(); + expect(parseNodeSshHostKey({ lines: ["host ssh-rsa AAA\nsecond entry"] })).toBeNull(); // smuggled newline + expect(parseNodeSshHostKey({ lines: [""] })).toBeNull(); + expect(parseNodeSshHostKey({ lines: null })).toBeNull(); + expect( + parseNodeSshHostKey({ + lines: Array.from({ length: SSH_MAX_HOST_KEY_LINES + 1 }, (_, i) => `h${i} ssh-ed25519 A`), + }), + ).toBeNull(); + expect( + parseNodeSshHostKey({ lines: Array.from({ length: SSH_MAX_HOST_KEY_LINES }, (_, i) => `h${i} ssh-ed25519 A`) }), + ).not.toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 87f4f78b5..9293e5e97 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -153,6 +153,7 @@ export { parseNodePromptDeliver, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, parseNodeStatDirResult, @@ -254,12 +255,14 @@ export { export { isSshGrantFingerprint, isSshGrantFingerprints, + isSshKnownHostsPinLine, isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, + type SshHostKeyCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, type SshRelayCloseCommand, @@ -272,6 +275,8 @@ export { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_GRANT_FINGERPRINTS, + SSH_MAX_HOST_KEY_LINES, + SSH_MAX_HOST_PIN_LINE_CHARS, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, SSH_PATH_MAX_CHARS, @@ -313,6 +318,7 @@ export type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 45910346a..653e3c942 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1890,6 +1890,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_resolve_config": case "ssh_register_identity": case "ssh_agent_identities": + case "ssh_host_key": case "ssh_relay_open": case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 1271e1141..020544239 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,12 +23,13 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { isSshGrantFingerprint } from "./ssh-frames.js"; -import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { isSshGrantFingerprint, isSshKnownHostsPinLine } from "./ssh-frames.js"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_HOST_KEY_LINES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; import type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; @@ -615,6 +616,32 @@ export function parseNodeSshAgentIdentities(data: unknown): NodeSshAgentIdentiti return { identities }; } +/** + * Validates and narrows an `ssh_host_key` command's `result{data}` (spec + * 2026-10-08 §9, Task 12). Each line is checked with the SAME predicate that + * gates the relay-open's pin carriage ({@link isSshKnownHostsPinLine}) - one + * definition of "what a wire host-key line is", answer in and pin out, so a + * line A answered can always ride the open that carries it - and the answer + * is capped at {@link SSH_MAX_HOST_KEY_LINES} rather than truncated (the + * roster cap's reasoning: a past-cap answer is a malformed machine, not a + * long one). An empty list parses: "A has recorded nothing" is the fact the + * capture fails closed on, and a fabrication-shaped error would hide it. + * Deep well-formedness (which token is the key, whether it matches D) is the + * capture service's fingerprint extraction and B's OpenSSH at connect time. + * @param data - the `data` member of a successful result frame + * @returns the narrowed entries, or null when malformed + */ +export function parseNodeSshHostKey(data: unknown): NodeSshHostKeyResult | null { + if (!isRecord(data) || !Array.isArray(data.lines)) return null; + if ((data.lines as unknown[]).length > SSH_MAX_HOST_KEY_LINES) return null; + const lines: string[] = []; + for (const line of data.lines as unknown[]) { + if (!isSshKnownHostsPinLine(line)) return null; + lines.push(line); + } + return { lines }; +} + /** * Validates and narrows an `ssh_register_identity` command's `result{data}` * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 857213128..0bda2cd7a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,13 +9,17 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds four commands here: + * refused. The sealed agent-relay milestone (M2) adds five commands here: * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches * the plane inside a signed command on the existing node link), the roster * read `ssh_agent_identities` (§5.4: the approval screen asks A's agent for - * its public identities, fingerprints plus comments, blobs withheld), and the - * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN - * hands one machine the whole pairing, the CLOSE names why it ends). What + * its public identities, fingerprints plus comments, blobs withheld), the + * host-key capture `ssh_host_key` (§9, Task 12: the plane asks A's + * `known_hosts` for one resolved destination's entries, and the answer + * becomes the pin the relay-open carries to B), and the brokered pair + * `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN hands one + * machine the whole pairing plus the destination's host-key pin, the CLOSE + * names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which * carries no per-message signing because the link already authenticates. @@ -33,7 +37,7 @@ */ import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; -import { SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_PIN_LINE_CHARS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -80,6 +84,33 @@ export interface SshAgentIdentitiesCommand { type: "ssh_agent_identities"; } +/** + * Ask the key home for the `known_hosts` entries its connecting account has + * recorded for ONE resolved destination (spec 2026-10-08 §9 "M2 host-key pin + * path", Task 12): the capture command whose answer becomes the pin delivered + * to B on the relay-open. Unlike the roster read it must NAME a destination, + * because OpenSSH's host-key lookup is per destination - `host`, `port`, and + * the connecting `user` (null when the snapshot named none) are the three + * facts the lookup's candidate spellings are built from, and each is sent in + * its resolved form so a later `~/.ssh/config` edit cannot retarget the ask + * (the same store-resolved rule §6.1 applies to the grant selector). + * + * Answered with NO grant and no relay session, like `detect`, + * `ssh_register_identity`, and `ssh_agent_identities`; it writes no audit row + * on either side (the durable record of a pin is `node.ssh_host_pin.create`, + * written by the capture ACT, not by the question), and an unreachable or + * refusing machine answers a named error - the capture never guesses. + */ +export interface SshHostKeyCommand { + type: "ssh_host_key"; + /** The resolved destination host (bracketed IPv6 spelled as OpenSSH spells it). */ + host: string; + /** The resolved port; always sent, because `[host]:port` is one of ssh's own lookup spellings. */ + port: number; + /** The connecting user, or null when the snapshot named none (the bare-`host` spellings still match). */ + user: string | null; +} + /** * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the * key home whose agent signs, B is the connecting machine that holds none. @@ -115,6 +146,19 @@ export type SshRelayRole = "A" | "B"; * matching {@link isSshPaneId}; the grammar names the SAME shape so a command * that could only ever fail the node's socket-path guard is refused here, * before the plane signs it. + * + * `hostPin` (Task 12, spec 2026-10-08 §9): the destination's pinned + * `known_hosts` line, captured from A at grant creation and REQUIRED. A + * relay-open without it is a relay grant with no pin, and the invariant says + * such a thing does not exist: B must never fall back to its own ambient + * TOFU, so the grammar refuses the pinless open rather than let the launch + * render an `accept-new` config against an untrusted machine's file. The B + * side writes the line, byte-for-byte, to `/ssh//known_hosts` + * (0600, beside the config the pane's `ssh -F` reads); the shape rule is + * {@link isSshKnownHostsPinLine} - one printable line, never a smuggled + * second entry. The same class of pre-merge grammar extension as `paneId`: + * plane and agent ship together under the exact-match protocol gate, so the + * field is required outright, not version-gated. */ export interface SshRelayOpenCommand { type: "ssh_relay_open"; @@ -140,6 +184,8 @@ export interface SshRelayOpenCommand { lifetimeMs: number; /** The pane (subshell id) the pairing serves: names B's proxy socket path (§5.2). */ paneId: string; + /** The destination's pinned host-key line (A's known_hosts entry; required - a pinless relay-open is malformed, §9). */ + hostPin: string; } /** @@ -187,6 +233,7 @@ export type SshNodeCommandBody = | SshResolveConfigCommand | SshRegisterIdentityCommand | SshAgentIdentitiesCommand + | SshHostKeyCommand | SshRelayOpenCommand | SshRelayCloseCommand; @@ -196,6 +243,7 @@ export const SSH_COMMAND_TYPES = [ "ssh_resolve_config", "ssh_register_identity", "ssh_agent_identities", + "ssh_host_key", "ssh_relay_open", "ssh_relay_close", ] as const; @@ -275,6 +323,35 @@ export function isSshGrantFingerprints(value: unknown): value is string[] { return isStrArray(value) && value.every(isSshGrantFingerprint) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; } +/** + * Whether `value` is ONE OpenSSH `known_hosts` line as the wire carries it: + * non-empty, bounded ({@link SSH_MAX_HOST_PIN_LINE_CHARS}), trimmed, and free + * of control characters - which subsumes the newline, the load-bearing half. + * The B side writes a relay-open's pin line verbatim into the pane's 0600 + * pinned file, so a smuggled `\n` would install a SECOND, plane-authored + * trust entry beside A's; the comment-marker refusal keeps `#` lines out of a + * field that promises to be a key. Everything past shape - whether the line + * is a well-formed entry, which key it carries, whether it matches D - is + * OpenSSH's own judgment at connect time on B (StrictHostKeyChecking yes + * against this exact file), and the capture service's fingerprint extraction + * on the plane. Hashed (`|1|…`) and option-prefixed entries pass: they are + * how the operator's own file may spell A's trust, verbatim-rewritten. + * + * One definition, both directions: the `ssh_host_key` ANSWER validator + * (`node-results.ts`) and the `ssh_relay_open` pin carriage both check with + * this predicate, so a line A answered can always ride the relay-open. + */ +export function isSshKnownHostsPinLine(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_MAX_HOST_PIN_LINE_CHARS && + value === value.trim() && + !/\p{Cc}/u.test(value) && + !value.startsWith("#") + ); +} + /** * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` * routes every `ssh_` arm here, so the SSH grammar lives in ONE file @@ -301,6 +378,20 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // asks the WHOLE roster, so a plane-sent field could only aim at // narrowing or widening what A's agent actually reports. return { type: "ssh_agent_identities" }; + case "ssh_host_key": { + // The capture asks for ONE destination's entries, so the destination is + // the whole input: host and user take the SAME token hygiene the alias + // and snapshot names apply (option-like, whitespace, and control-char + // refusals at the grammar, before the plane signs the ask), the port is + // a real 16-bit int because `[host]:port` is one of ssh's own lookup + // spellings, and the user is REQUIRED to state itself - null spelled - + // so no omitted field can silently widen or narrow who the lookup names. + if (!isAliasName(value.host)) return null; + if (!isInt(value.port) || (value.port as number) < 1 || (value.port as number) > 65_535) return null; + if (!("user" in value)) return null; + if (!(value.user === null || isAliasName(value.user))) return null; + return { type: "ssh_host_key", host: value.host, port: value.port as number, user: value.user }; + } case "ssh_relay_open": { // EVERY pairing field is required (§5.1): this command IS the pairing, // and an executor guessing a missing half would be guessing trust @@ -345,6 +436,13 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // by it, so it must be a path-composability id - the SAME guard the // node's socket bind runs, applied before anything is signed. if (!isSshPaneId(value.paneId)) return null; + // Task 12 (spec §9): the destination's pinned host-key line, REQUIRED. + // A relay-open with no pin is a relay grant with no pin, and B must + // never ambient-TOFU a destination it does not already trust: the + // refusal is the grammar's, before the plane signs and before either + // endpoint runs. Shape only here ({@link isSshKnownHostsPinLine}); + // whether the line matches D is ssh's own hard block on B at connect. + if (!isSshKnownHostsPinLine(value.hostPin)) return null; // Rebuilt from the validated fields, never a cast of the candidate: a // stray member on the wire drops here, exactly as the close arm drops. return { @@ -360,6 +458,7 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu fingerprints: [...value.fingerprints], lifetimeMs: value.lifetimeMs as number, paneId: value.paneId, + hostPin: value.hostPin, }; } case "ssh_relay_close": { diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index e27b738fc..ae52fdc62 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -177,6 +177,24 @@ export const SSH_RELAY_TEARDOWN_GRACE_MS = 5_000; */ export const SSH_MAX_GRANT_FINGERPRINTS = 8; +/** + * Longest single OpenSSH `known_hosts` line the wire may carry, in EITHER + * direction: the `ssh_host_key` answer's entries and the `ssh_relay_open` + * pin's line alike (spec 2026-10-08 §9, Task 12). A real line is a pattern + * list, a key type, and base64 key material - an RSA-8192 entry with a long + * pattern runs well past a kilobyte, and the grammar's bound must not refuse + * the honest file while leaving a hostile plane an unbounded text field. + */ +export const SSH_MAX_HOST_PIN_LINE_CHARS = 4096; + +/** + * Host-key lines one `ssh_host_key` answer may carry (Task 12). A destination + * with more recorded entries than this is not the operator's `known_hosts` + * the capture expects; the answer is malformed, refused at the grammar rather + * than truncated. + */ +export const SSH_MAX_HOST_KEY_LINES = 32; + /** * Largest raw stdout slice one brokered `session_frame` event may carry * (design §3's pump, restated as a number: the node-frames `session_frame` diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index daef58083..cc233d19d 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -98,3 +98,21 @@ export interface NodeSshAgentIdentitiesResult { /** Every public identity A's live agent carries; the approval screen's choice list. */ identities: NodeSshAgentIdentity[]; } + +/** + * `ssh_host_key` answer (spec 2026-10-08 §9, Task 12): the entries A's + * connecting account has recorded for ONE resolved destination, each in + * OpenSSH's own `known_hosts` line spelling (pattern, key type, base64 key + * material, optional comment), verbatim. An EMPTY list is the honest fact "A + * has recorded nothing for this destination" - a distinct answer from the + * named error an unreadable file or a missing tool produces, and the capture + * service fails the grant creation closed on exactly this empty answer + * (a relay grant must carry a pin). The key material here is PUBLIC by + * nature (a known_hosts entry is what a client uses to CHECK a server), and + * it is what becomes the pin; the audit rows name only the destination and + * the entry's `SHA256:` fingerprint, never these bytes. + */ +export interface NodeSshHostKeyResult { + /** Every `known_hosts` line matching the destination's lookup; verbatim, at most SSH_MAX_HOST_KEY_LINES. */ + lines: string[]; +} From cb0b4bf2885494c42f6da6750a1f288f67a7ba43 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:01:34 -0700 Subject: [PATCH 124/170] feat(pane-runtime): relay-mode ssh config render pins the trust source and forces yes Task 12 (spec 2026-10-08 S9): renderSshConfigContents gains an optional relay fact - the session's pinned known_hosts path. In relay mode the render emits StrictHostKeyChecking yes and a single UserKnownHostsFile naming the pinned file, REPLACING (never appending) the snapshot's own trust refs, so B's ambient file is never consulted for a relay destination. Without the option the bytes are the M1 accept-new posture byte-for-byte. buildSshKnownHostsPath composes the path with the same guards as the config/socket paths; the plane renders it into the config, B writes to its own derivation. --- packages/pane-runtime/src/index.ts | 2 + .../src/ssh/__tests__/ssh-render.test.ts | 83 +++++++++++++++++++ packages/pane-runtime/src/ssh/ssh-render.ts | 76 ++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index bbbeaaf48..5b5c939ed 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -152,7 +152,9 @@ export { export { buildAgentSocketPath, buildSshConfigPath, + buildSshKnownHostsPath, renderSshConfigContents, + type SshRelayRenderOption, sshDestinationToken, sshOptionTokens, } from "./ssh/ssh-render.js"; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index 01adceec4..0f94245bf 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { buildAgentSocketPath, buildSshConfigPath, + buildSshKnownHostsPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens, @@ -219,6 +220,88 @@ describe("sshDestinationToken", () => { }); }); +describe("buildSshKnownHostsPath", () => { + // Spec 2026-10-08 §9 (Task 12): the B side writes the delivered pin to a + // 0600 known_hosts file in the SAME per-pane ssh dir, so `ssh -F` reads a + // config whose UserKnownHostsFile points at it. One derivation, both ends. + it("composes the pinned file beside the config and socket, one dir", () => { + expect(buildSshKnownHostsPath("/data", "s1")).toBe("/data/ssh/s1/known_hosts"); + expect(dirname(buildSshKnownHostsPath("/data", "s1"))).toBe(dirname(buildSshConfigPath("/data", "s1"))); + expect(dirname(buildSshKnownHostsPath("/data", "s1"))).toBe(dirname(buildAgentSocketPath("/data", "s1"))); + }); + + it("runs the same guards as the config path", () => { + expect(() => buildSshKnownHostsPath("data", "s1")).toThrow(/absolute/); + expect(() => buildSshKnownHostsPath("/d", "")).toThrow(/paneId/); + expect(() => buildSshKnownHostsPath("/d", "../escape")).toThrow(/paneId/); + expect(() => buildSshKnownHostsPath("/d", "a".repeat(65))).toThrow(/paneId/); + expect(buildSshKnownHostsPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/known_hosts`); + }); +}); + +describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { + const PIN = "/data/ssh/s1/known_hosts"; + const relay = { hostPinPath: PIN }; + + it("emits the pinned UserKnownHostsFile and forces yes, not accept-new", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/deploy/.ssh/known_hosts"] }), relay); + expect(out).toContain(" StrictHostKeyChecking yes"); + expect(out).not.toContain("accept-new"); + // The pinned file is the ONLY trust source: B's own ambient known_hosts + // is NOT the authority (spec §9), so the snapshot's own trust refs are + // replaced, never appended. + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + expect(out).not.toContain("/home/deploy/.ssh/known_hosts"); + }); + + it("forces yes even when the snapshot named no trust refs (relay replaces, never defers)", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: [] }), relay); + expect(out).toContain(" StrictHostKeyChecking yes"); + expect(out).not.toContain("accept-new"); + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + }); + + it("quotes the pinned path only when it needs it (the same tokenizer rule as other file values)", () => { + const spaced = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh dir/s1/known_hosts" }); + expect(spaced).toContain(' UserKnownHostsFile "/data/ssh dir/s1/known_hosts"'); + }); + + it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust source + yes)", () => { + const out = renderSshConfigContents(baseSnapshot(), relay); + for (const [key, value] of [ + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ClearAllForwardings", "yes"], + ["EscapeChar", "none"], + ["RemoteCommand", "none"], + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], + ] as [string, string][]) { + expect(out).toContain(` ${key} ${value}`); + } + }); + + it("a non-relay render is byte-for-byte the M1 accept-new posture (the branch is untouched)", () => { + const snap = baseSnapshot(); + expect(renderSshConfigContents(snap)).toContain(" StrictHostKeyChecking accept-new"); + // No second argument is the M1 call; it must equal an explicit-undefined. + expect(renderSshConfigContents(snap, undefined)).toBe(renderSshConfigContents(snap)); + }); + + it("refuses a non-absolute or oversized hostPinPath (never composes a stray trust file)", () => { + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: "relative/known_hosts" })).toThrow(/absolute/); + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: "" })).toThrow(/absolute/); + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: `/${"x".repeat(5000)}` })).toThrow(/path/i); + }); + + it("refuses a hand-built snapshot carrying a forbidden member, same as non-relay", () => { + const smuggled = { ...baseSnapshot(), proxyCommand: "nc attacker 4444" } as unknown as ReturnType< + typeof baseSnapshot + >; + expect(() => renderSshConfigContents(smuggled, relay)).toThrow("not renderable"); + }); +}); + describe("buildSshConfigPath", () => { it("composes the derived path from the two facts each side already holds", () => { expect(buildSshConfigPath("/data", "s1")).toBe("/data/ssh/s1/config"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index 99bf2fa9f..d74535256 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -128,6 +128,24 @@ function knownHostsLines(files: string[]): string[] { return files.map((f) => ` UserKnownHostsFile ${configPathValue(f)}`); } +/** + * The relay-mode render option (spec 2026-10-08 §9, Task 12): the path of the + * per-session pinned `known_hosts` file the B machine writes from the + * relay-open's delivered pin. It is deliberately a PATH and not the key + * line: the pin reaches B on the signed command and lands in that 0600 file, + * and the config only has to POINT the connection at it. A per-launch relay + * fact passed as an option, never smuggled through the frozen destination + * snapshot (which records what the operator approved, not which session + * carries it). + */ +export interface SshRelayRenderOption { + /** Absolute POSIX path of the session's pinned known_hosts file (the 0600 beside the config). */ + hostPinPath: string; +} + +/** The two spellings of the ONE host-key-checking line: the M1 posture and the relay block. */ +const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new", relay: "yes" }; + /** * Render the runtime ssh_config FILE for one snapshot: the policy above, * every hop included, plus the snapshot's trust refs and identity refs under @@ -136,15 +154,47 @@ function knownHostsLines(files: string[]): string[] { * from the snapshot's own refs"). Contents are deterministic in the snapshot * — the same approval always renders the same file, which is what makes the * run's recorded config byte-checkable. + * + * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY two + * things, and pins the test suite to all of them: `StrictHostKeyChecking` + * says `yes` instead of `accept-new` (a changed D key is refused by OpenSSH + * naming D; nothing new is ever recorded), and `UserKnownHostsFile` is the + * session's pinned file and NOTHING ELSE - the snapshot's own trust refs are + * replaced, because on the relay path B's ambient known_hosts is not the + * authority, A's recorded key is. Absent the option (every M1 launch) the + * bytes are the accept-new posture byte-for-byte, untouched. */ -export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): string { +export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string { assertRenderable(snapshot); + if (relay !== undefined) { + // The same path discipline `sshOptionTokens` applies to the config path: + // this value is interpolated into a file ssh reads verbatim, and a + // relative or oversized name is not a renderable trust file. + if ( + relay.hostPinPath.length === 0 || + !relay.hostPinPath.startsWith("/") || + relay.hostPinPath.length > SSH_PATH_MAX_CHARS + ) { + throw new Error("relay host pin path must be absolute POSIX"); + } + } + const checking = relay ? HOST_KEY_CHECK.relay : HOST_KEY_CHECK.direct; const lines = [ "# Subshell managed SSH config - GENERATED, do not edit.", "# Mandatory runtime policy for the connection and every ProxyJump hop (spec 2026-10-07 §5.2).", + ...(relay + ? [ + "# Relay mode: D is verified against the pinned host-key file only - no new keys are recorded (spec 2026-10-08 §9).", + ] + : []), "Host *", - ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${value}`), - ...knownHostsLines(snapshot.knownHostsFiles), + ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), + // Relay: the pinned file is the ONE trust source, the snapshot's own + // refs must not ride beside it. Direct: the M1 rule (silence IS the + // policy, the machine's default known_hosts is the trust store). + ...(relay + ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`] + : knownHostsLines(snapshot.knownHostsFiles)), ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), "", @@ -234,3 +284,23 @@ export function buildAgentSocketPath(dataDir: string, paneId: string): string { if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh agent socket path: invalid paneId"); return `${dataDir}/ssh/${paneId}/agent.sock`; } + +/** + * The relay session's PINNED host-key file for a pane (spec 2026-10-08 §9, + * Task 12): `known_hosts` beside the config and the agent socket, in the one + * per-pane dir that is removed with the pane. Like {@link + * buildAgentSocketPath}, BOTH ends derive it from (dataDir, paneId) and the + * byte-equality is the check - but here the directions are the reverse of the + * socket's: the PLANE composes it into the relay-mode config it renders (the + * `UserKnownHostsFile` line the pane's ssh reads), and B's relay-open + * executor writes the delivered pin to its OWN derivation, never to a path + * named on the wire. A config pointing at a path B did not derive would + * point ssh at a file nothing wrote: with `StrictHostKeyChecking yes` that + * is a refusal naming D, which is the fail-closed half of the design. Same + * guards, same refusals (throws) as the config path. + */ +export function buildSshKnownHostsPath(dataDir: string, paneId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh known-hosts path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh known-hosts path: invalid paneId"); + return `${dataDir}/ssh/${paneId}/known_hosts`; +} From b3b8eb7e5373c3f4e63ad9d51baea4941e95f30b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:06:58 -0700 Subject: [PATCH 125/170] feat(node): ssh_host_key capture arm and the B-side pinned known_hosts write Task 12 (spec 2026-10-08 S9): the ssh_host_key executor answers A's known_hosts entries for one destination by evaluating them with ssh-keygen -F (OpenSSH's own matching, not a re-parse), one run per candidate spelling, headers stripped and lines deduped through the frozen answer validator. The gate speaks first; an absent trust file is the honest empty fact the capture fails closed on; a missing keygen, a timeout, a spawn failure, an unreadable file, and a malformed answer each refuse by name. No audit row, no key bytes in any log. openBRelaySession writes the delivered relay-open pin line, shape-re-checked, to buildSshKnownHostsPath(dataDir, paneId) at 0600 (dir 0700) before binding the agent socket, refusing without writing on a malformed pin. --- .../__tests__/commands-ssh-host-key.test.ts | 248 ++++++++++++++++++ .../src/__tests__/commands-ssh-relay.test.ts | 3 + .../src/__tests__/relay-responder.test.ts | 90 ++++++- apps/node/agent/src/commands/index.ts | 7 + apps/node/agent/src/commands/ssh-host-key.ts | 144 ++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 46 +++- apps/node/agent/src/commands/ssh-shared.ts | 28 ++ 7 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts create mode 100644 apps/node/agent/src/commands/ssh-host-key.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts new file mode 100644 index 000000000..c1a483136 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -0,0 +1,248 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshProcessResult } from "@internal/pane-runtime"; +import type { JsonValue, NodeCommandBody, SshHostKeyCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshHostKey, type HostKeySeams, sshHostKeyCandidates } from "../commands/ssh-host-key.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The `ssh_host_key` capture arm (spec 2026-10-08 §9, Task 12): A answers the + * `known_hosts` entries for one resolved destination so the plane can + * capture the pin the relay-open carries to B. Posture copied from + * commands-ssh.test.ts (real temp dirs, the gate mirror written by its own + * writer, refusals by `ok:false` + message) with the ssh-keygen run replaced + * by the executor's `runProcess` seam - the OPPOSITE of what runs against a + * real agent is what the tests pin here: the candidate SPELLINGS, the + * comment-header strip, the dedup, and the fail-closed error classes. The + * real `ssh-keygen -F` behavior against a fixture file is the same tool's + * behavior at the sshd suite's disposal; this file owns the arm's decisions. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const LINE_A = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; + +let base: string; +let savedKeygen: string | undefined; +let savedKnownHosts: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-hostkey-"))); + savedKeygen = process.env.SUBSHELL_SSH_KEYGEN_PATH; + savedKnownHosts = process.env.SUBSHELL_SSH_KNOWN_HOSTS; +}); + +afterAll(() => { + if (savedKeygen === undefined) delete process.env.SUBSHELL_SSH_KEYGEN_PATH; + else process.env.SUBSHELL_SSH_KEYGEN_PATH = savedKeygen; + if (savedKnownHosts === undefined) delete process.env.SUBSHELL_SSH_KNOWN_HOSTS; + else process.env.SUBSHELL_SSH_KNOWN_HOSTS = savedKnownHosts; + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, gateOn = true): CommandContext { + return { config: { dataDir: dataDir(tag, gateOn) } } as unknown as CommandContext; +} + +function cmd(over: Partial = {}): SshHostKeyCommand { + return { type: "ssh_host_key", host: "git.example.test", port: 22, user: null, ...over }; +} + +function trustFile(tag: string, content: string | null): string { + const path = join(base, tag, "known_hosts"); + mkdirSync(join(base, tag), { recursive: true }); + if (content !== null) writeFileSync(path, content, { mode: 0o600 }); + return path; +} + +interface KeygenCall { + candidate: string; + argv: readonly string[]; +} + +/** A fake ssh-keygen: canned stdout per candidate, with the call log. */ +function fakeRun(byCandidate: Record>): { + run: NonNullable; + calls: KeygenCall[]; +} { + const calls: KeygenCall[] = []; + return { + calls, + run: async (argv): Promise => { + const candidate = argv[argv.length - 1]; + calls.push({ candidate, argv }); + const canned = byCandidate[candidate] ?? {}; + return { + code: canned.code ?? 0, + stdout: canned.stdout ?? "", + stderr: canned.stderr ?? "", + timedOut: canned.timedOut ?? false, + spawnError: canned.spawnError ?? false, + }; + }, + }; +} + +function seamsFor(trust: string, run: HostKeySeams["runProcess"]): HostKeySeams { + return { + resolveKeygenBin: async () => "/usr/bin/ssh-keygen", + trustFile: () => trust, + runProcess: run ?? (async () => ({ code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false })), + }; +} + +describe("ssh_host_key candidates (OpenSSH's lookup spellings)", () => { + it("a default-port ask without a user asks the bare host and the bracketed-port spelling", () => { + expect(sshHostKeyCandidates(cmd())).toEqual(["[git.example.test]:22", "git.example.test"]); + }); + it("a user and a non-default port cover all four spellings, deduped", () => { + expect(sshHostKeyCandidates(cmd({ port: 2222, user: "deploy" }))).toEqual([ + "[git.example.test]:2222", + "git.example.test", + "[deploy@git.example.test]:2222", + "deploy@git.example.test", + ]); + // A host already spelled with brackets is not re-bracketed: OpenSSH + // writes known_hosts entries for `[::1]:2222` exactly once spelled. + expect(sshHostKeyCandidates(cmd({ host: "[::1]", port: 2222, user: "deploy" }))).toEqual([ + "[::1]:2222", + "[::1]", + "[deploy@[::1]]:2222", + "deploy@[::1]", + ]); + }); +}); + +describe("ssh_host_key arm (gate ON)", () => { + it("the gate speaks before any lookup: no keygen resolution, no run", async () => { + let resolved = false; + const res = await execSshHostKey(makeCtx("gate", false), cmd(), { + resolveKeygenBin: async () => { + resolved = true; + return "/usr/bin/ssh-keygen"; + }, + }); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(resolved).toBe(false); + }); + + it("an absent trust file is the honest EMPTY answer - the capture's fail-closed fact, not an error", async () => { + const missing = join(base, "never-created", "known_hosts"); + const fake = fakeRun({}); + const res = await execSshHostKey(makeCtx("missing"), cmd(), seamsFor(missing, fake.run)); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + // Nothing spawned: the file's ABSENCE is answered without OpenSSH. + expect(fake.calls).toHaveLength(0); + }); + + it("a missing ssh-keygen refuses by name (the binary-ladder message class)", async () => { + const trust = trustFile("nokeygen", `${LINE_A}\n`); + const res = await execSshHostKey(makeCtx("nokeygen"), cmd(), { + resolveKeygenBin: async () => null, + trustFile: () => trust, + }); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); + + it("answers the matching entry verbatim, headers and blanks stripped, argv spelled -f file -F candidate", async () => { + const trust = trustFile("match", `${LINE_A}\n# a comment\nother.example ssh-rsa BBB\n`); + const fake = fakeRun({ + "git.example.test": { + stdout: `# Host git.example.test found: line 2\n${LINE_A}\n`, + }, + }); + const res = await execSshHostKey(makeCtx("match"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(true); + expect((res as { data: JsonValue }).data).toEqual({ lines: [LINE_A] }); + // The ask spells the file target with `-f` (never a HOME-relative read) + // and each candidate as its own argv element - no shell string anywhere. + expect(fake.calls.map((c) => c.candidate)).toEqual(["[git.example.test]:22", "git.example.test"]); + expect(fake.calls[1]?.argv.slice(0, 4)).toEqual(["/usr/bin/ssh-keygen", "-f", trust, "-F"]); + }); + + it("dedups a line matched under several candidate spellings", async () => { + const trust = trustFile("dedup", `${LINE_A}\n`); + const fake = fakeRun({ + "[git.example.test]:22": { stdout: `${LINE_A}\n` }, + "git.example.test": { stdout: `${LINE_A}\n` }, + "deploy@git.example.test": { stdout: `${LINE_A}\n` }, + "[deploy@git.example.test]:22": { code: 1, stdout: "", stderr: "" }, + }); + const res = await execSshHostKey(makeCtx("dedup"), cmd({ user: "deploy" }), seamsFor(trust, fake.run)); + expect((res as { data: { lines: string[] } }).data.lines).toEqual([LINE_A]); + }); + + it("a silent non-zero run with stderr is the named unreadable-file refusal, never the empty fact", async () => { + const trust = trustFile("unreadable", `${LINE_A}\n`); + const fake = fakeRun({ + "[git.example.test]:22": { code: 1, stderr: "Load key ...: Permission denied" }, + "git.example.test": { code: 1, stderr: "Load key ...: Permission denied" }, + }); + const res = await execSshHostKey(makeCtx("unreadable"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("could not be read"); + }); + + it("a timeout and a spawn failure each refuse by name", async () => { + const trust = trustFile("failures", `${LINE_A}\n`); + const timeout = fakeRun({ "[git.example.test]:22": { timedOut: true } }); + const t = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, timeout.run)); + expect(t.ok).toBe(false); + expect(String((t as { error?: string }).error)).toInclude("deadline"); + const spawn = fakeRun({ "[git.example.test]:22": { spawnError: true } }); + const s = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, spawn.run)); + expect(s.ok).toBe(false); + expect(String((s as { error?: string }).error)).toInclude("could not be started"); + }); + + it("an answer the frozen validator rejects (an over-cap line) is refused, not passed through", async () => { + const trust = trustFile("malformed", "x\n"); + const huge = `host ssh-ed25519 ${"A".repeat(5000)}`; + const fake = fakeRun({ "git.example.test": { stdout: `${huge}\n` }, "[git.example.test]:22": { code: 1 } }); + const res = await execSshHostKey(makeCtx("malformed"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("malformed"); + }); + + it("dispatch routes ssh_host_key to the HANDLER, not to unsupported (gate ON, no keygen, no trust file -> empty fact)", async () => { + // Gate ON, a keygen that resolves, and an ABSENT trust file: only the + // handler can produce the empty ok:true answer; `unsupported` here would + // mean the dispatch arm is missing, the gate's words would mean it ran + // before the lookup. + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/bin/sh"; + process.env.SUBSHELL_SSH_KNOWN_HOSTS = join(base, "dispatch-missing", "known_hosts"); + const res = await dispatchCommand(makeCtx("dispatch"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + }); + + it("dispatch routes to the HANDLER's binary-missing class when the file EXISTS (still not `unsupported`)", async () => { + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/nonexistent/ssh-keygen-binary"; + const trust = trustFile("dispatch-exists", `${LINE_A}\n`); + process.env.SUBSHELL_SSH_KNOWN_HOSTS = trust; + const res = await dispatchCommand(makeCtx("dispatch2"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index 90e727daa..831369f74 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -81,6 +81,9 @@ function openCmd(over: Partial = {}): SshRelayOpenCommand { fingerprints: ["SHA256:AAAA"], lifetimeMs: 30_000, paneId: PANE, + // Task 12: the relay-open's required destination pin; the B branch writes + // it to the pane's 0600 known_hosts file before binding the socket. + hostPin: "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000", ...over, }; } diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index f4af31db4..6ee7df87b 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -281,6 +281,8 @@ const evilKeysReady: Promise = es256Pair(); const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); +// Task 12: the destination's pinned known_hosts line the relay-open carries. +const HOST_PIN = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; const ROSTER: RosterEntry[] = [ { blob: KEY_IN, comment: "granted key" }, { blob: KEY_OUT, comment: "ungranted key" }, @@ -340,6 +342,7 @@ async function openFixture( fingerprints: [fp(KEY_IN)], lifetimeMs: 30_000, paneId: "pane-a-test", // grammar (b): the command names the pane + hostPin: HOST_PIN, // grammar (Task 12): the destination's pinned key line ...overrides, }; await openARelaySession({ @@ -1036,6 +1039,7 @@ test("openARelaySession refuses a command that does not name this machine in its fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }; // role B: the A branch is not for this command. await expect( @@ -1106,6 +1110,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }; // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. await expect( @@ -1281,6 +1286,7 @@ async function refusalCmd(over: Partial): Promise { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-hostpin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-pin", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", // selfNodeId below matches: this machine is B + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-b-test", + hostPin: HOST_PIN, + }; + const { socketPath } = await openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-b-test", + cmd, + sendRelayFrame: () => {}, + }); + try { + const pinPath = join(dataDir, "ssh", "pane-b-test", "known_hosts"); + // The file is the delivered line, byte-for-byte, plus the trailing + // newline ssh_config's own file reader expects. + expect(readFileSync(pinPath, "utf8")).toBe(`${HOST_PIN}\n`); + // 0600 (enforced past any umask), and the socket bound beside it. + expect(statSync(pinPath).mode & 0o777).toBe(0o600); + expect(existsSync(socketPath)).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("openBRelaySession refuses a malformed host pin WITHOUT writing a file", async () => { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-badmin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-bad", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-bad", + // A smuggled second entry: the executor's last-station shape check (the + // grammar refuses this on the wire too, but the write is the trust file). + hostPin: `host ssh-rsa AAA\nother-host ssh-rsa BBB`, + }; + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-bad", + cmd, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/host pin/); + // Nothing bound, nothing written: the pane's dir does not even exist. + expect(existsSync(join(dataDir, "ssh", "pane-bad", "known_hosts"))).toBe(false); + expect(relay.size).toBe(0); + rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index ed4a45ca3..c199a7dba 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshHostKey } from "./ssh-host-key.js"; import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; @@ -62,6 +63,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * agent's public identities as fingerprints plus comments, blobs withheld; * gated like the config arms, because reading the account's agent IS an SSH * act, and it probes the agent's numbering before asking, never guessing), + * the host-key capture `ssh_host_key` (spec 2026-10-08 §9, Task 12 - the + * `known_hosts` entries for one destination, evaluated by `ssh-keygen -F` so + * OpenSSH's own matching answers; gated like the config arms, and an absent + * file is the honest empty answer the capture fails closed on), * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, @@ -151,6 +156,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshRegisterIdentity(ctx); case "ssh_agent_identities": return await execSshAgentIdentities(ctx); + case "ssh_host_key": + return await execSshHostKey(ctx, cmd); case "ssh_relay_open": return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": diff --git a/apps/node/agent/src/commands/ssh-host-key.ts b/apps/node/agent/src/commands/ssh-host-key.ts new file mode 100644 index 000000000..5f950dadf --- /dev/null +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -0,0 +1,144 @@ +import { existsSync } from "node:fs"; +import { runSshProcess, sshChildPath } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshHostKey, type SshHostKeyCommand } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { knownHostsPath, resolveSshKeygenBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * `ssh_host_key` (spec 2026-10-08 §9, Task 12): answer with the + * `known_hosts` entries this machine has recorded for ONE resolved + * destination, so the plane can capture the pin that later rides the + * relay-open to B. + * + * The evaluator is OpenSSH's own `ssh-keygen -F`, not a hand-rolled parse. + * known_hosts matching (pattern globs, the `[host]:port` and `user@host` + * spellings, hashed entries) is OpenSSH's own logic; anything less is the + * loose re-parse the design refuses. One `-F` run per candidate spelling + * covers the forms a file may carry, and the union is deduped by line - the + * answer is A's recorded trust, verbatim, never a reconstruction of it. + * + * The three rules this arm keeps, shared with the roster read: + * - **The gate speaks first.** Reading the account's trust file is an SSH + * act; a machine whose mirror is not ON refuses {@link SSH_GATE_REFUSAL} + * before any lookup or spawn. + * - **Fail closed, never fabricate.** No ssh-keygen, an unreadable file, a + * timeout, or an answer the grammar rejects each answer `ok:false` with a + * named error. The ABSENT case is the one `ok:true` answer that carries + * nothing: no `known_hosts` file, or a file with no entry for this + * destination, is the honest fact "A has recorded nothing" - and the + * capture service fails grant creation closed on exactly that empty + * answer, which is why the emptiness is reported as a fact and never as + * an error the caller might paper over. + * - **No audit row, no logging of the bytes.** The durable record of a pin + * is the `node.ssh_host_pin.create` row the CAPTURE ACT writes, naming the + * destination and a fingerprint only; this command is the question, and a + * question writes nothing. The key bytes are public material, but they + * belong in the pin row and the relay command, not in this machine's log. + */ + +/** Test seams for {@link execSshHostKey} (production omits all three). */ +export interface HostKeySeams { + /** Where ssh-keygen is; defaults to this machine's own lookup ladder. */ + resolveKeygenBin?: () => Promise; + /** The trust file to read; defaults to the connecting account's `~/.ssh/known_hosts`. */ + trustFile?: () => string; + /** One ssh-keygen run; defaults to {@link runSshProcess}. The seam's contract: absolute argv[0], full child env, deadline. */ + runProcess?: typeof runSshProcess; +} + +/** + * The candidate spellings `ssh-keygen -F` must be asked with to cover how a + * destination may appear in known_hosts: OpenSSH looks a connection up under + * `[user@host]:port`, `[host]:port`, `user@host`, and the bare `host`, and + * the same set is what capture asks with. An honest superset (the bracketed + * spelling is asked at the default port too) costs nothing: a pattern only + * matches a candidate it actually spells, and any line this returns is a + * line A recorded for THIS destination's host/user/port triple. + * + * The bracket rule is OpenSSH's own spelling: an IPv6 literal carries its + * brackets in known_hosts already (`[::1]:2222`, and `[::1]` for the + * default-port entry), so a host spelled with brackets is NOT wrapped again. + * A resolved snapshot's host is either a plain name or a bracketed literal - + * the grammar keeps the brackets in (the ssh-render doc says so) - so this + * one rule reproduces the exact bytes ssh wrote. + */ +export function sshHostKeyCandidates({ host, port, user }: SshHostKeyCommand): string[] { + const bracket = (h: string): string => (h.startsWith("[") ? h : `[${h}]`); + const out = [`${bracket(host)}:${port}`, host]; + if (user !== null) out.push(`${bracket(`${user}@${host}`)}:${port}`, `${user}@${host}`); + return [...new Set(out)]; +} + +/** + * One ssh-keygen -F run against one candidate, stdout split into entry lines. + * ssh-keygen precedes each found entry with a `# Host x found: line N` + * comment; those and blanks are the only lines the file's OWN entries are + * not, and both are dropped here. Non-zero exit with no output is OpenSSH's + * spelling of "this candidate matched nothing" (and of an unreadable file - + * the caller distinguishes those by whether ANY candidate produced bytes + * and by the stderr it logs); non-zero with output still yields its entries. + */ +function entryLines(stdout: string): string[] { + const lines: string[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + lines.push(line); + } + return lines; +} + +/** Execute `ssh_host_key` for one destination. */ +export async function execSshHostKey( + ctx: CommandContext, + cmd: SshHostKeyCommand, + seams?: HostKeySeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const keygen = await (seams?.resolveKeygenBin ?? resolveSshKeygenBin)(); + if (keygen === null) return { ok: false, error: "ssh-keygen binary missing: ssh-keygen" }; + const trustFile = (seams?.trustFile ?? knownHostsPath)(); + // An absent trust file is not an error to throw around - it is the common + // honest case (a machine that has never connected anywhere), reported as + // the empty fact so the capture's fail-closed branch, not a transport + // failure, is what the operator's screen names. + if (!existsSync(trustFile)) { + const validated = parseNodeSshHostKey({ lines: [] }); + if (validated === null) return { ok: false, error: "malformed host-key answer" }; + return { ok: true, data: validated as unknown as JsonValue }; + } + const runProcess = seams?.runProcess ?? runSshProcess; + const env: Record = { + HOME: process.env.HOME ?? "", + PATH: await sshChildPath(), + }; + const found = new Set(); + let sawReadFailure = false; + for (const candidate of sshHostKeyCandidates(cmd)) { + // `-f ` (NOT `-F file`): the search target spelled as OpenSSH's + // own usage prints, so the read never depends on whose HOME the daemon + // was launched under. stdin ignored, streams captured - runSshProcess's + // short-RPC posture, the same one `ssh -G` resolution runs under. + const run = await runProcess([keygen, "-f", trustFile, "-F", candidate], env, 10_000); + if (run.spawnError) return { ok: false, error: "ssh-keygen could not be started on this machine" }; + if (run.timedOut) return { ok: false, error: "ssh-keygen did not finish within the deadline" }; + if (run.code !== 0 && run.stdout.trim() === "") { + // Non-zero AND silent: "no match" (normal, keep looking) is + // indistinguishable from "cannot read the file" only by stderr, and + // the file's readability was not claimed - so an unreadable file must + // not read as "A recorded nothing". One line to the log (names never + // key bytes); the refusal goes to the plane as the named error. + sawReadFailure = sawReadFailure || run.stderr.trim() !== ""; + continue; + } + for (const line of entryLines(run.stdout)) found.add(line); + } + if (found.size === 0 && sawReadFailure) { + return { ok: false, error: "the known_hosts trust file could not be read on this machine" }; + } + const validated = parseNodeSshHostKey({ lines: [...found] }); + if (validated === null) return { ok: false, error: "malformed host-key answer" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 3d928f453..013cf9959 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -1,6 +1,10 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { buildSshKnownHostsPath, enforceMode } from "@internal/pane-runtime"; import { bytesOfJwk, + isSshKnownHostsPinLine, type RelayFrame, SSH_RELAY_LIFETIME_MS, SSH_RELAY_MAX_PER_NODE, @@ -254,15 +258,18 @@ function decodePeerEncryptionJwk(b64: string): string { /** * Open the B side of a brokered relay pairing: enforce §4.4's machine pin on - * A (first pairing pins both halves, a moved pin is a hard block), then bind + * A (first pairing pins both halves, a moved pin is a hard block), write the + * destination's pinned host-key line to the pane's 0600 known_hosts file + * (Task 12, §9 - the trust source the relay-mode config names), then bind * the pane's agent proxy socket and register its ref. Throws a named refusal * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an - * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a ref - * already owned, a ref the plane already closed (tombstoned), or the - * node-side registry cap - and EVERY refusal releases the proxy this call - * just bound (fix round T8 MAJOR 1: a thrown cap refusal once bypassed that - * release). Resolves with the composed socket path - the value the launch's - * scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a + * malformed host pin (never a second trust entry), a ref already owned, a + * ref the plane already closed (tombstoned), or the node-side registry cap - + * and EVERY refusal releases the proxy this call just bound (fix round T8 + * MAJOR 1: a thrown cap refusal once bypassed that release). Resolves with + * the composed socket path - the value the launch's scoped `SSH_AUTH_SOCK` + * must match byte for byte (§5.2). * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. @@ -303,6 +310,31 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock ); } + // The destination's pinned host-key line, written to the pane's 0600 + // known_hosts file BEFORE the proxy binds (Task 12, spec 2026-10-08 §9): + // the pane's `ssh -F` reads the relay-mode config, whose one + // UserKnownHostsFile names this exact path. The line is verbatim from the + // signed command; the shape is re-checked HERE because this is the last + // station before a trust file a live ssh consults - a smuggled newline + // would be a second, plane-authored entry, and `yes` would then block on + // the wrong key. The path is this machine's OWN derivation + // ({@link buildSshKnownHostsPath}(dataDir, paneId)), never a name taken + // from the wire, so a hostile plane cannot point the write outside the + // per-session dir that leaves with the pane. Refusal throws (dispatch + // answers ok:false); nothing was bound yet, so there is no resource to + // release on this path. + if (!isSshKnownHostsPinLine(cmd.hostPin)) { + throw new Error("relay open refused: the delivered host pin is not one known_hosts line"); + } + const hostPinPath = buildSshKnownHostsPath(args.dataDir, args.paneId); + { + const dir = dirname(hostPinPath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(hostPinPath, `${cmd.hostPin}\n`, { mode: 0o600 }); + await enforceMode(hostPinPath, 0o600); // umask cannot leak bits past this + } + const identity = await loadOrCreateIdentity(args.dataDir); const proxy = await startAgentProxy({ dataDir: args.dataDir, diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts index b6d895798..9f81f794b 100644 --- a/apps/node/agent/src/commands/ssh-shared.ts +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -23,9 +23,20 @@ import { findBinary } from "@internal/pane-runtime"; /** env-override seam for the ssh binary, spelled like the harness overrides. */ export const SSH_BINARY_ENV = "SUBSHELL_SSH_PATH"; +/** env-override seam for ssh-keygen, the `ssh_host_key` capture arm's binary (Task 12). */ +export const SSH_KEYGEN_BINARY_ENV = "SUBSHELL_SSH_KEYGEN_PATH"; + /** The known install locations the ladder falls back to. */ const SSH_KNOWN_PATHS = ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"]; +/** ssh-keygen's install locations: the same packages that ship `ssh`, beside it. */ +const SSH_KEYGEN_KNOWN_PATHS = [ + "/usr/bin/ssh-keygen", + "/bin/ssh-keygen", + "/usr/local/bin/ssh-keygen", + "/opt/homebrew/bin/ssh-keygen", +]; + /** * The gate's own words, spelled once: a machine whose local mirror is not ON * refuses EVERY SSH command with exactly this, before any binary lookup or @@ -39,6 +50,23 @@ export async function resolveSshBin(): Promise { return await findBinary("ssh", SSH_BINARY_ENV, SSH_KNOWN_PATHS); } +/** Resolve the ssh-keygen binary for this machine, or null (the `ssh_host_key` arm). */ +export async function resolveSshKeygenBin(): Promise { + return await findBinary("ssh-keygen", SSH_KEYGEN_BINARY_ENV, SSH_KEYGEN_KNOWN_PATHS); +} + +/** + * The connecting account's `known_hosts` trust file - the capture source + * (spec 2026-10-08 §9: A's trust in D is read from A's OWN file). Overridable + * by env for tests and non-default homes, exactly like the binary seams; the + * default is `~/.ssh/known_hosts`. + */ +export function knownHostsPath(): string { + const override = process.env.SUBSHELL_SSH_KNOWN_HOSTS; + if (override !== undefined && override !== "") return override; + return `${connectingHomeDir()}/.ssh/known_hosts`; +} + /** The connecting account's home, the same way the daemon itself sees it. */ export function connectingHomeDir(): string { return process.env.HOME || homedir(); From 451b53d42bfe953f796a7d466c45c26ce55056a3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:42:42 -0700 Subject: [PATCH 126/170] feat(ssh-host-pins): the TOFU store - capture from A, explicit supply, delete-recovery, audited by fingerprint only Migration 0051 gives a pending grant request its resolved destination (user@host:port, nullable: legacy rows fail the approval by name), and the new ssh-host-pins service owns the ssh_host_pins rows 0050 shipped: captureHostPin asks the key home over the ssh_host_key arm (or takes one explicit known_hosts line from the trust screen), pins exactly one answer or refuses by name (no-pin / ambiguous / changed), never overwrites a standing pin, and audits destination + SHA256 fingerprint only - the key bytes never enter an audit row. Delete + re-capture is the TOFU recovery; pins outlive the grants that captured them. The ssh-rpc seam gains sshHostKey; backend-errors gains SSH_HOST_PIN_MISSING / _CHANGED / _INVALID. --- .../__tests__/backup-cli-subprocess.test.ts | 17 +- apps/server/api/src/db/migrate.ts | 5 + .../0051-ssh-request-destination.ts | 20 + .../0051-ssh-request-destination.test.ts | 96 ++++ .../repositories/ssh-host-pins.repository.ts | 78 ++++ .../api/src/db/types/ssh-grants.db-types.ts | 22 +- .../src/db/types/ssh-saved-hosts.db-types.ts | 32 ++ .../__tests__/ssh-host-pins.service.test.ts | 309 +++++++++++++ .../api/src/services/backups/database.ts | 2 +- apps/server/api/src/services/nodes/ssh-rpc.ts | 30 ++ .../api/src/services/ssh-host-pins.service.ts | 413 ++++++++++++++++++ packages/backend-errors/src/error-codes.ts | 36 ++ 12 files changed, 1050 insertions(+), 10 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0051-ssh-request-destination.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts create mode 100644 apps/server/api/src/db/repositories/ssh-host-pins.repository.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts create mode 100644 apps/server/api/src/services/ssh-host-pins.service.ts diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index e0ed7c3ee..20f49f0d0 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,17 +392,17 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049+0050 together: this era's four migrations are the tail - // of the registered ledger, so the prefix runs contiguously through 0046 and - // boot re-applies all four. Advancing LATEST_BACKUP_MIGRATION (0050) means - // the strip set moves with it in the same commit. + // WHY 0047+0048+0049+0050+0051 together: this era's five migrations are the + // tail of the registered ledger, so the prefix runs contiguously through 0046 + // and boot re-applies all five. Advancing LATEST_BACKUP_MIGRATION (0051) + // means the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", ) .all(), ).toEqual([ @@ -438,6 +438,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0048-ssh-launch-and-saved-hosts" }, { name: "0049-ssh-relay-identity" }, { name: "0050-ssh-grants" }, + { name: "0051-ssh-request-destination" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 0fed9f7cd..d043abfdf 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -50,6 +50,7 @@ import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; /** * Runs all pending Kysely migrations against the app database. @@ -149,6 +150,10 @@ export async function runMigrations(): Promise { // The grant tier: standing key grants, the durable first-use approval // queue, and the M2 host-pin store (spec 2026-10-08 §6, §9). "0050-ssh-grants": sshGrantsMigration, + // The first-use request carries the full resolved destination so the + // approval that creates the grant can capture the host-key pin at the + // exact `user@host:port` the launch dialed (spec 2026-10-08 §9). + "0051-ssh-request-destination": sshRequestDestinationMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts new file mode 100644 index 000000000..74330cd84 --- /dev/null +++ b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts @@ -0,0 +1,20 @@ +import type { Kysely } from "kysely"; + +/** + * The first-use request carries the FULL resolved destination (spec + * 2026-10-08 §9, Task 12). The grant's selector is hostname-scoped (matched + * against the resolved host at launch); the host-key PIN is keyed per + * `user@host:port`, and the approval that creates the grant is also the + * capture moment for that pin - which means the durable pending row must + * outlive the asking launch with the exact destination the launch dialed, not + * just its host. Nullable BY DESIGN: a pending row written before this + * migration exists cannot be back-filled (the launch is gone), and the + * approval path fails closed on null rather than guessing a port or a user. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").addColumn("destination", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").dropColumn("destination").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts new file mode 100644 index 000000000..d16e7d44c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as destinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshGrantRequests: Record; +} + +/** + * Task 12's one storage change: `ssh_grant_requests` gains `destination`, the + * full resolved `user@host:port` the asking launch dialed. Nullable on + * purpose (a pre-T12 pending row cannot be back-filled, and approval fails + * closed on null); everything else 0050 froze stays untouched. + */ +describe("migration 0051-ssh-request-destination", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0051-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + await destinationMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds destination as a nullable column on the request row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("destination"); + const destination = cols.rows.find((r) => r.name === "destination"); + expect(destination?.notnull).toBe(0); // legacy pending rows carry null; approval refuses them by name + }); + + it("an old-shaped row still inserts (null destination) and a full one round-trips", async () => { + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-legacy", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p1", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + } as never) + .execute(); + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-full", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p2", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + destination: "deploy@git.example.test:2222", + } as never) + .execute(); + const rows = await db.selectFrom("sshGrantRequests").select(["id", "destination"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "req-legacy", destination: null }, + { id: "req-full", destination: "deploy@git.example.test:2222" }, + ]), + ); + }); + + it("down() removes only the new column and keeps the 0050 tables", async () => { + await destinationMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_grant_requests')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("destination"); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toEqual([]); + }); +}); diff --git a/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts new file mode 100644 index 000000000..7c4ceba04 --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts @@ -0,0 +1,78 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; + +/** + * Repository for the M2 host-key TOFU store (spec 2026-10-08 §9; the table + * ships in migration 0050, this file is T12's logic on it). The row key is + * the UNIQUE `(owner_user_id, destination)`: one pin per owner per resolved + * `user@host:port`, independent of grants (one grant selector over many hosts + * yields many pins, and a grant's revoke never touches a pin - the pin is the + * owner's TOFU record, not the grant's property). + * + * The capture path has ONE write: {@link insertPin}. There is deliberately NO + * update-by-key write here: a differing key for a pinned destination is §9's + * hard block, not a row to overwrite - recovery is {@link deletePin} plus a + * fresh capture at the next grant creation. `host_key` is a PUBLIC known_hosts + * line, and the row is the durable record the audit names only a fingerprint + * of; nothing in this file logs or echoes the bytes. + * + * Every read and write is OWNER-SCOPED at the WHERE clause, the saved-hosts + * posture verbatim: a foreign row is absent, never "forbidden". + */ +export class SshHostPinsRepository extends BaseRepository { + /** Record a capture. The caller validated the row (line grammar, key choice). */ + async insertPin(pin: SshHostPinTable): Promise { + await this.db.insertInto("sshHostPins").values(pin).execute(); + } + + /** The owner's pin for one canonical destination; null when unpinned. */ + async getPin(ownerUserId: string, destination: string): Promise { + const row = await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return row ?? null; + } + + /** + * Re-stamp `updated_at` after an ACCEPTED match (a capture at the same key + * agreeing with the standing pin). The row's key, id, and bytes never move + * - only the "last accepted match" stamp does, and only on a match: a + * differing key is §9's block, not an update. + */ + async touchPin(ownerUserId: string, destination: string, at: string): Promise { + await this.db + .updateTable("sshHostPins") + .set({ updatedAt: at }) + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .execute(); + } + + /** The owner's pins for the trust screen, newest first. */ + async listPins(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * Delete one of the owner's pins; false for foreign AND absent (one 404 + * at the route). The §9 recovery half: TOFU re-decides after a delete, so + * the next capture at a differing key SUCCEEDS as a new first-capture + * rather than overwriting the old one. + */ + async deletePin(ownerUserId: string, destination: string): Promise { + const res = await this.db + .deleteFrom("sshHostPins") + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts index e5781b9ea..5e731431c 100644 --- a/apps/server/api/src/db/types/ssh-grants.db-types.ts +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -86,6 +86,17 @@ export interface SshGrantRequestTable { keyHomeNodeId: string; /** The resolved destination hostname the launch would have dialed (stored resolved, like the grant) */ resolvedSelector: string; + /** + * The FULL canonical destination `user@host:port` the asking launch dialed + * (the `sshCanonicalDestination` spelling, migration 0051). The selector + * above is hostname-scoped (the grant's match key); the host-key PIN is + * keyed per this triple, and the approval that creates the grant captures + * the pin at exactly what was dialed - which requires the row to outlive + * the launch carrying it. NULL only on a row written before 0051 (the + * asking launch is gone, nothing to back-fill); approval refuses such a + * row by name rather than guess a port or a user. + */ + destination: string | null; /** JSON array of pre-selected `SHA256:` fingerprints, or null when none rode the request (the approver picks) */ requestedFingerprints: string | null; /** B's pane (subshell id) whose launch asked - what the approvals screen names as the requester */ @@ -115,7 +126,16 @@ export interface SshHostPinTable { ownerUserId: string; /** Canonical resolved destination `user@host:port` (the `sshCanonicalDestination` spelling) */ destination: string; - /** The pinned public host key line (` `, OpenSSH known_hosts spelling), never a secret */ + /** + * The pinned public host-key entry, one OpenSSH `known_hosts` line: the + * destination's pattern, the key type, and the base64 key material (plus + * any comment A's own file carried) - stored VERBATIM from A's recorded + * trust so B's file carries exactly the bytes A matched, never a + * reconstruction that re-spells A's entry. Public material end to end + * (a known_hosts entry CHECKS a server, it is not a secret); the audit + * rows name the destination and this line's `SHA256:` fingerprint, never + * the bytes themselves. + */ hostKey: string; /** ISO 8601 first capture (the TOFU moment) */ createdAt: string; diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts index 1070089f5..43a828e32 100644 --- a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -46,3 +46,35 @@ export function sshCanonicalDestination({ }): string { return user === null ? `${host}:${port}` : `${user}@${host}:${port}`; } + +/** A canonical destination's three facts (the {@link sshCanonicalDestination} shape). */ +export interface SshDestinationTriple { + host: string; + port: number; + user: string | null; +} + +/** + * The exact inverse of {@link sshCanonicalDestination}: split the stored + * `user@host:port` back into its three facts (Task 12: the approval captures + * the host-key pin by ASKING A for a destination, and the ask takes the + * triple). Unambiguous because the composer built it: the user is before the + * first `@`, the port after the LAST `:` - and the host is either a plain + * name or a bracketed IPv6 literal, so a `:` inside a host never sits after + * the final one. null for anything the composer would not have written: no + * port part, a non-numeric or out-of-range port, an empty host or user. + */ +export function parseSshCanonicalDestination(text: string): SshDestinationTriple | null { + const at = text.indexOf("@"); + const user = at === -1 ? null : text.slice(0, at); + const rest = at === -1 ? text : text.slice(at + 1); + const colon = rest.lastIndexOf(":"); + if (colon < 1) return null; + const host = rest.slice(0, colon); + const portText = rest.slice(colon + 1); + if (host === "" || (user !== null && (user === "" || user.includes("@")))) return null; + if (!/^[0-9]{1,5}$/.test(portText)) return null; + const port = Number(portText); + if (port < 1 || port > 65_535) return null; + return { host, port, user }; +} diff --git a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts new file mode 100644 index 000000000..5b5cf8eff --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -0,0 +1,309 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinFor, + hostPinRefusal, + listHostPins, + SshHostPinError, + setSshHostPinsDepsForTests, +} from "@/services/ssh-host-pins.service.js"; + +/** + * The host-key pin store (spec 2026-10-08 §9, Task 12). The properties this + * suite exists to pin, one per §9 sentence: + * + * - A CAPTURE writes the destination's pin from A's `known_hosts` answer and + * audits `node.ssh_host_pin.create` naming destination + fingerprint ONLY: + * the audit row and every log path must never carry the line's bytes, and + * the fingerprint is OpenSSH's own `SHA256:` over the decoded blob. + * - The EMPTY answer is the named `no-pin` failure (mapped to + * SSH_HOST_PIN_MISSING): a relay grant never stands without its pin. + * - MULTIPLE distinct keys are `ambiguous`, refused rather than picked. + * - A capture at a key DIFFERENT from the stored pin is `changed`: nothing + * is written, nothing is overwritten, the row stands verbatim. + * - The SAME key again is idempotent (no second create row; updatedAt moves). + * - DELETE + re-capture is the TOFU recovery: the differing key becomes the + * new pin only AFTER the delete, and the delete audits destination + + * fingerprint. + * - Per-owner scoping: a foreign owner's pin is absent, never forbidden. + * - The explicit line (trust-screen supply) is grammar-checked and audits + * `via: explicit`. + * + * The A fetch is the service's own seam; no test here touches a socket, an + * ssh-keygen, or a developer's `~/.ssh`. + */ + +const HOST = "git.example.test"; +const DEST = `${HOST}:22`; +/** A real ed25519 public blob shape: the wire encoding, base64'd as known_hosts spells it. */ +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 7), +]); +const LINE = `${HOST} ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const OTHER_BYTES = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]); +const OTHER_LINE = `${HOST} ssh-ed25519 ${OTHER_BYTES.toString("base64")}`; +const EXPECTED_FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const OTHER_FP = `SHA256:${createHash("sha256").update(OTHER_BYTES).digest("base64url")}`; + +/** A clock the suite moves: stamps assert the touch, the row, and the audit. */ +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + +/** Scripted A: what the fetch answers, or the RPC error it throws. */ +let fetchAnswer: () => NodeSshHostKeyResult = () => ({ lines: [LINE] }); +let fetchCalls: { nodeId: string; triple: unknown }[] = []; + +function installDeps() { + fetchCalls = []; + setSshHostPinsDepsForTests({ + nowIso: () => new Date(clockMs).toISOString(), + fetchHostKey: async (nodeId, triple) => { + fetchCalls.push({ nodeId, triple }); + return fetchAnswer(); + }, + }); +} + +async function expectPinError(fn: () => Promise, code: SshHostPinError["code"]): Promise { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(SshHostPinError); + const e = err as SshHostPinError; + expect(e.code).toBe(code); + return e; + } + throw new Error(`expected SshHostPinError(${code}); nothing threw`); +} + +let owner: string; +let stranger: string; +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `hostpins-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("hostpins-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + fetchAnswer = () => ({ lines: [LINE] }); + installDeps(); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +async function latestAudit(action: string): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["metadataJson"]) + .where("action", "=", action) + .orderBy("createdAt", "desc") + .limit(1) + .execute(); + return rows[0] ? (JSON.parse(rows[0].metadataJson ?? "{}") as Record) : {}; +} + +describe("capture", () => { + it("writes the pin from A's answer and audits destination + fingerprint, never the bytes", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(row.hostKey).toBe(LINE); + expect(row.destination).toBe(DEST); + // The ask carried the PARSED triple (not the raw text): host git.example.test, port 22, user null. + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 22, user: null } }]); + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + expect(audit.via).toBe("captured"); + // The bytes are nowhere on the trail: the base64 key material is not in + // the audit metadata (the fingerprint is its digest, not its spelling). + const meta = JSON.stringify(audit); + expect(meta).not.toContain(KEY_BYTES.toString("base64")); + expect(hostKeyFingerprints(LINE)).toEqual([EXPECTED_FP]); + }); + + it("an empty answer is the named no-pin failure and writes NOTHING (a relay grant never lacks its pin)", async () => { + fetchAnswer = () => ({ lines: [] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "no-pin", + ); + expect(hostPinRefusal(err)).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_HOST_PIN_MISSING }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(0); + }); + + it("two distinct keys from A are refused (ambiguous), not picked", async () => { + fetchAnswer = () => ({ lines: [LINE, OTHER_LINE] }); + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "ambiguous", + ); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); + + it("a differing capture is the changed hard block: nothing written, the row stands verbatim", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); // never overwritten + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(1); // the original capture is the only create row + }); + + it("the same key again is idempotent: no second create row, the updatedAt stamp moves", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + clockMs += 60_000; + const again = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(again.updatedAt).toBe(new Date(clockMs).toISOString()); + const creates = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(creates).toHaveLength(1); + }); + + it("an explicit line (the trust-screen door) pins without asking A, audited via explicit", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: LINE }); + expect(row.hostKey).toBe(LINE); + expect(fetchCalls).toHaveLength(0); // supplied is never fetched + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.via).toBe("explicit"); + }); + + it("an explicit line that is not ONE grammar-valid key entry is refused", async () => { + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: "just-a-host" }), + "invalid-line", + ); + await expectPinError( + () => + captureHostPin({ + ownerUserId: owner, + aNodeId: null, + destination: DEST, + hostKeyLine: "a b\nsecond ssh-ed25519 A", + }), + "invalid-line", + ); + }); + + it("the machine's RPC failures leave as the named capture causes (offline, refused)", async () => { + fetchAnswer = () => { + throw new SshRpcError("offline", "no live socket", "node-a"); + }; + const offline = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "offline", + ); + expect(hostPinRefusal(offline)).toMatchObject({ status: 409, code: BackendErrorCodes.NODE_OFFLINE }); + fetchAnswer = () => { + throw new SshRpcError("refused", "ssh disabled on this node", "node-a"); + }; + const refused = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "refused", + ); + expect(hostPinRefusal(refused)).toMatchObject({ status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); +}); + +describe("delete and re-capture (the TOFU recovery)", () => { + it("delete removes the row and audits destination + fingerprint; the re-capture at the NEW key succeeds as a fresh TOFU", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + const removed = await deleteHostPin({ ownerUserId: owner, destination: DEST }); + expect(removed?.hostKey).toBe(LINE); + const audit = await latestAudit("node.ssh_host_pin.delete"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + // The §9 recovery: only AFTER the delete does the differing key become the pin. + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const re = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(re.hostKey).toBe(OTHER_LINE); + expect(re.createdAt).toBe(new Date(clockMs).toISOString()); + // The recovered pin fingerprints as the NEW key. + expect(hostKeyFingerprints(re.hostKey)).toEqual([OTHER_FP]); + }); + + it("deleting an absent or foreign destination answers null and audits nothing", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await deleteHostPin({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await deleteHostPin({ ownerUserId: owner, destination: "other.test:22" })).toBeNull(); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).not.toBeNull(); // stranger deleted nothing + const dels = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.delete") + .execute(); + expect(dels).toHaveLength(0); + }); + + it("pins are per-owner and per-destination: one owner's pin is absent for the other, and ports/users are distinct keys", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await hostPinFor({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await listHostPins({ ownerUserId: stranger })).toHaveLength(0); + const withUser = `deploy@${HOST}:22`; + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: withUser, hostKeyLine: LINE }); + expect(await listHostPins({ ownerUserId: owner })).toHaveLength(2); + // The screens list never carries key bytes - only the fingerprint view. + const views = await listHostPins({ ownerUserId: owner }); + expect(JSON.stringify(views)).not.toContain(KEY_BYTES.toString("base64")); + expect(views.every((v) => v.fingerprint === EXPECTED_FP)).toBe(true); + }); +}); + +describe("the user@host:port triple", () => { + it("a user and a non-default port parse to the ask the capture sends", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: "deploy@git.example.test:2222" }); + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 2222, user: "deploy" } }]); + }); +}); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 3a7fc3b33..9c23198d3 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0050-ssh-grants"; +export const LATEST_BACKUP_MIGRATION = "0051-ssh-request-destination"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index b00499089..b5534b9e7 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,9 +1,11 @@ import { type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, + type NodeSshHostKeyResult, type NodeSshResolveOutcomeWire, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "@internal/subshell-protocol"; @@ -126,6 +128,34 @@ export async function sshRegisterIdentity(nodeId: string): Promise { return parsed.signingPublicKey; } +/** + * Ask one machine for the `known_hosts` entries it has recorded for one + * resolved destination (spec 2026-10-08 §9, Task 12): the host-key capture + * whose answer becomes the pin the relay-open delivers to B. The destination + * is the whole input because OpenSSH's host-key lookup is per destination; + * the triple is sent RESOLVED so a later config edit cannot retarget the ask. + * An empty answer is a SUCCESS (`{ lines: [] }`, the honest "recorded + * nothing"); only a transport/wire failure throws. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshHostKey( + nodeId: string, + destination: { host: string; port: number; user: string | null }, +): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_host_key", ...destination }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshHostKey(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed host-key list`, nodeId); + } + return parsed; +} + /** * Ask one machine for its LIVE agent's public roster (spec 2026-10-08 §5.4, * Task 11): the approval screen's choice list, as the frozen validator diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts new file mode 100644 index 000000000..1f250b15a --- /dev/null +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -0,0 +1,413 @@ +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine, type NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshHostKey } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The host-key pin store (spec 2026-10-08 §9, Task 12): the M2 TOFU record + * that tells a relay WHERE D's identity comes from. Trust for a destination + * follows the key home, not the connecting box: the pin is captured from A's + * own `~/.ssh/known_hosts` at grant creation (or supplied explicitly by the + * operator), stored owner-scoped per canonical `user@host:port`, delivered to + * B on the signed relay-open, and rendered into the session as + * `UserKnownHostsFile ` + `StrictHostKeyChecking yes`. A changed D + * key is then refused by OpenSSH itself, naming D; the plane never overwrites + * a stored pin (a differing capture is the §9 hard block), and recovery is + * delete-the-pin plus a fresh grant-creation TOFU - no separate rotate act. + * + * **The fail-closed rules this module keeps:** + * - A capture that finds nothing fails with the named `no-pin` cause. A + * relay launch refuses on it (`prepareRelayLeg`): B never ambient-TOFUs a + * destination, and a grant approval refuses on it too, so no grant ever + * stands without its destination's pin. + * - A capture that finds MULTIPLE distinct keys for the destination is + * `ambiguous`, refused rather than "pick one" - the pin must name A's one + * recorded key, not a coin flip over A's file. + * - A capture whose key differs from the stored pin is `changed`: nothing is + * written, nothing is deleted; the operator verifies out of band, deletes + * the pin, and re-creates the grant to re-decide TOFU. + * - Transport truth is the machine's, not this module's: offline / outdated / + * timeout / refused / malformed each leave as the named cause, and the + * grant approval stays PENDING through all of them (the ask can be retried; + * a fabricated pin could not be un-fabricated). + * - The audit rows (`node.ssh_host_pin.create|delete`) name the destination + * and the pinned key's `SHA256:` fingerprint ONLY - a host-key fingerprint + * is a public identifier (docs/security.md §10) - never the line's bytes, + * and no log line or notification this module writes carries a key. The + * capture COMMAND writes no row at all; the row belongs to the capture ACT. + */ + +/** Why a capture refused; each code names its own door (the routes render the copy). */ +export type SshHostPinFailure = + | "no-pin" + | "ambiguous" + | "changed" + | "invalid-line" + | "offline" + | "unsupported" + | "timeout" + | "refused" + | "malformed"; + +/** The loud refusal every pin act raises. No row exists after a thrown `changed`-class failure. */ +export class SshHostPinError extends Error { + readonly code: SshHostPinFailure; + /** The canonical destination the act was about (ids/hosts in copy, never key bytes). */ + readonly destination: string; + + constructor(code: SshHostPinFailure, message: string, destination: string) { + super(message); + this.name = "SshHostPinError"; + this.code = code; + this.destination = destination; + } +} + +const repo = new SshHostPinsRepository(db); + +/** The service's world. Production at {@link defaultPinsDeps}; tests install a fake. */ +export interface SshHostPinsDeps { + /** The one clock: row stamps. */ + nowIso(): string; + /** + * Ask one machine for its `known_hosts` entries for one destination + * (default: `ssh-rpc.sshHostKey`, the `??` at the call site being the + * production wiring). A test seam so the capture surface can pin its + * fail-closed behavior without a live ssh-keygen. + */ + fetchHostKey?( + nodeId: string, + destination: { host: string; port: number; user: string | null }, + ): Promise; +} + +let depsOverride: SshHostPinsDeps | null = null; + +function pinsDeps(): SshHostPinsDeps { + if (depsOverride) return depsOverride; + return { nowIso: () => new Date().toISOString() }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins capture/fail-closed without a node. + */ +export function setSshHostPinsDepsForTests(deps: SshHostPinsDeps | null): void { + depsOverride = deps; +} + +/* ------------------------------------------------------------------ */ +/* the line -> fingerprint extraction (public identifiers, never keys) */ +/* ------------------------------------------------------------------ */ + +/** OpenSSH key-type tokens as known_hosts spells them (plain, cert, and security-key families). */ +const KEY_TYPE_RE = /^(?:ssh|ecdsa|sk)-[A-Za-z0-9][A-Za-z0-9@._-]*$/; +/** Strict standard base64 (the known_hosts field's own spelling), shape only. */ +const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * The `SHA256:` display fingerprint of a known_hosts line's key material - + * OpenSSH's own notation: SHA-256 over the decoded public blob (the agent + * wire encoding is the same bytes a known_hosts base64 field decodes to), + * base64url WITHOUT padding. A line with no extractable (type, base64) pair + * is malformed; a line whose several pairs disagree on the key is `ambiguous` + * for CAPTURE purposes, so the return is a SET and the callers decide what + * plurality means. + */ +export function hostKeyFingerprints(line: string): string[] { + const tokens = line.split(/\s+/); + const fps = new Set(); + for (let i = 0; i + 1 < tokens.length; i += 1) { + const type = tokens[i]; + const b64 = tokens[i + 1]; + if (type === undefined || b64 === undefined) continue; + if (!KEY_TYPE_RE.test(type) || !B64_RE.test(b64)) continue; + const bytes = Buffer.from(b64, "base64"); + if (bytes.length === 0) continue; + fps.add(`SHA256:${createHash("sha256").update(bytes).digest("base64url")}`); + } + return [...fps]; +} + +/* ------------------------------------------------------------------ */ +/* capture */ +/* ------------------------------------------------------------------ */ + +/** The line a capture would pin, chosen from A's answer; raises the named refusals. */ +function chooseCapturedLine(destination: string, lines: string[]): string { + if (lines.length === 0) { + throw new SshHostPinError( + "no-pin", + `no known_hosts entry for ${destination}: connect to the destination once from the key home, or supply its host key explicitly`, + destination, + ); + } + const perLine = lines.map((line) => ({ line, fps: hostKeyFingerprints(line) })); + if (perLine.some((e) => e.fps.length === 0)) { + throw new SshHostPinError( + "invalid-line", + `the host-key answer for ${destination} carried an unparsable entry`, + destination, + ); + } + const distinct = new Set(perLine.flatMap((e) => e.fps)); + if (distinct.size > 1) { + // Several KEYS (not several spellings of one key) match the destination: + // pinning would pick one of A's entries on the capture's own authority, + // and that is exactly the coin flip §9's byte-equality posture refuses. + throw new SshHostPinError( + "ambiguous", + `the key home recorded more than one host key for ${destination}; clean the known_hosts entry or supply the key explicitly`, + destination, + ); + } + const first = perLine[0]; + if (first === undefined) { + // Unreachable (lines.length was checked above), but the grammar's "pick + // the one key" step never casts: an empty pick is a named refusal, not a + // crash. + throw new SshHostPinError("no-pin", `no known_hosts entry for ${destination}`, destination); + } + return first.line; +} + +/** + * Ask A for the destination's `known_hosts` entries and pick the one line to + * pin (the fetch half of {@link captureHostPin}, split out so the explicit + * path never builds an answer it will not read). Every transport failure + * leaves as the named {@link SshHostPinError}; the empty answer is the + * `no-pin` refusal, never a fabricated key. + */ +async function fetchAndChooseLine(aNodeId: string | null, destination: string): Promise { + const triple = parseSshCanonicalDestination(destination); + if (triple === null) { + throw new SshHostPinError( + "invalid-line", + `${destination} is not a canonical user@host:port destination`, + destination, + ); + } + // Without a supplied line there is no pin without a key home to ask: the + // trust screen supplies lines, never fetches. + if (aNodeId === null) { + throw new SshHostPinError("invalid-line", `no key home to ask for ${destination}'s host key`, destination); + } + let answer: NodeSshHostKeyResult; + try { + const fetcher = pinsDeps().fetchHostKey ?? sshHostKey; // the `??` is the production wiring + answer = await fetcher(aNodeId, triple); + } catch (err) { + if (err instanceof SshRpcError) throw rpcFailure(err, destination); + throw err; + } + return chooseCapturedLine(destination, answer.lines); +} + +/** + * Capture (or accept, idempotently) the owner's pin for one canonical + * destination. `hostKeyLine` supplies the explicit-pin posture (the operator's + * key, not a fetch); absent it, A's `known_hosts` is asked over the signed + * RPC. Returns the standing row. Raises {@link SshHostPinError} with the + * named cause on every fail-closed door, writing NOTHING: a stored pin that + * disagrees is `changed` (the hard block, never an overwrite), a fresh + * capture audits `node.ssh_host_pin.create` naming destination + fingerprint. + */ +export async function captureHostPin(args: { + ownerUserId: string; + /** The key home the fetch asks; null when the line is SUPPLIED (the trust screen names no machine). */ + aNodeId: string | null; + destination: string; // canonical `user@host:port` + hostKeyLine?: string; +}): Promise { + const { ownerUserId, aNodeId, destination } = args; + if (!isSshKnownHostsPinLine(args.hostKeyLine ?? "") && args.hostKeyLine !== undefined) { + throw new SshHostPinError( + "invalid-line", + `the supplied host key for ${destination} is not one known_hosts line`, + destination, + ); + } + const line = args.hostKeyLine ?? (await fetchAndChooseLine(aNodeId, destination)); + const fps = hostKeyFingerprints(line); + if (fps.length !== 1) { + throw new SshHostPinError( + "invalid-line", + `the host key for ${destination} carries no single public key`, + destination, + ); + } + const existing = await repo.getPin(ownerUserId, destination); + if (existing !== null) { + const stored = hostKeyFingerprints(existing.hostKey); + if (stored.length !== 1 || stored[0] !== fps[0]) { + // §9's byte-equality hard block, at the plane's edge: A now reports a + // different key than the pin. Nothing written, nothing deleted - the + // operator's re-decide (delete + fresh capture) is the only way past. + throw new SshHostPinError( + "changed", + `the key home now reports a different host key for ${destination} than the pinned one`, + destination, + ); + } + // The same key again is the TOFU record agreeing with itself: the row + // stands (updatedAt = the last accepted match), no second create row. + await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); + return { ...existing, updatedAt: pinsDeps().nowIso() }; + } + const now = pinsDeps().nowIso(); + const row: SshHostPinTable = { + id: crypto.randomUUID(), + ownerUserId, + destination, + hostKey: line, + createdAt: now, + updatedAt: now, + }; + await repo.insertPin(row); + await pinAudit(ownerUserId, "node.ssh_host_pin.create", aNodeId, { + destination, + fingerprint: fps[0], // the public identifier ONLY, never the line's bytes (§10) + via: args.hostKeyLine !== undefined ? "explicit" : "captured", + }); + return row; +} + +/** Read the owner's pin for one destination, or null (no audit, no side effect). */ +export async function hostPinFor(args: { ownerUserId: string; destination: string }): Promise { + return await repo.getPin(args.ownerUserId, args.destination); +} + +/** Delete the owner's pin (the §9 recovery's first half); false for foreign AND absent. */ +export async function deleteHostPin(args: { + ownerUserId: string; + destination: string; +}): Promise { + const existing = await repo.getPin(args.ownerUserId, args.destination); + if (existing === null) return null; + if (!(await repo.deletePin(args.ownerUserId, args.destination))) return null; // raced away + const fps = hostKeyFingerprints(existing.hostKey); + await pinAudit(args.ownerUserId, "node.ssh_host_pin.delete", null, { + destination: args.destination, + fingerprint: fps[0] ?? null, // names the removed pin; never its bytes + }); + return existing; +} + +/** One pin as the trust screen reads it: destination + fingerprint, NEVER key bytes. */ +export interface SshHostPinView { + /** Pin row id (uuid) */ + id: string; + /** Canonical resolved destination `user@host:port` */ + destination: string; + /** The pinned key's `SHA256:` display fingerprint (a public identifier; the line itself is not serialized) */ + fingerprint: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match */ + updatedAt: string; +} + +/** The trust screen's list: every one of the owner's pins, display-shaped. */ +export async function listHostPins(args: { ownerUserId: string }): Promise { + const rows = await repo.listPins(args.ownerUserId); + return rows.map((row) => ({ + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); +} + +/* ------------------------------------------------------------------ */ +/* refusal mapping: the RPC's kinds and the capture's own doors */ +/* ------------------------------------------------------------------ */ + +/** The grant-surface refusal shape (identical arms to `ssh-grants`'s narrow type). */ +export type SshHostPinRefusal = { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string }; + +/** Map the capture's own error onto the coded refusal its caller renders. */ +export function hostPinRefusal(err: SshHostPinError): SshHostPinRefusal { + switch (err.code) { + case "no-pin": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The key home has no recorded host key for that destination. Connect to it once from the key home, or supply its host key on the trust screen.", + }; + case "ambiguous": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The key home recorded more than one host key for that destination. Resolve its known_hosts entry, then ask again.", + }; + case "changed": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_CHANGED, + message: + "The key home now reports a different host key for that destination than the stored pin. Verify out of band, delete the pin, and create the grant again to re-trust.", + }; + case "invalid-line": + return { status: 400, code: BackendErrorCodes.SSH_HOST_PIN_INVALID, message: err.message }; + case "offline": + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "The key home has no live connection right now; bring its Subshell app online and ask again.", + }; + case "unsupported": + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "The Subshell app on the key home is too old to report host keys. Update it from its machine page.", + }; + case "timeout": + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "The key home did not answer the host-key request in time; check its connection and ask again.", + }; + case "refused": + case "malformed": + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The key home refused the host-key request. Check that SSH is switched on there.", + }; + } +} + +/** The RPC error as the capture's own named cause (the machine's text goes to the LOG, never the refusal). */ +function rpcFailure(err: SshRpcError, destination: string): SshHostPinError { + if (err.kind === "refused" || err.kind === "malformed") { + logger + .withError(err) + .warn(`ssh host-key rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + } + return new SshHostPinError(err.kind, `host-key capture for ${destination} failed: ${err.kind}`, destination); +} + +function pinAudit( + actorUserId: string, + action: string, + aNodeId: string | null, + metadata: Record, +): Promise { + const event: AuditEventInput = { + actorUserId, + action, + targetType: aNodeId === null ? null : "node", + targetId: aNodeId, + metadataJson: JSON.stringify(metadata), + }; + return audit(event); +} diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index c01c7c28c..b9fc93fad 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -278,6 +278,30 @@ export enum BackendErrorCodes { * history). */ SSH_GRANT_ALREADY_ANSWERED = "SSH_GRANT_ALREADY_ANSWERED", + /** + * A relay grant creation the host-key capture refused because the key home + * has no `known_hosts` entry for the destination (spec 2026-10-08 §9, + * Task 12). A relay grant must carry a pin - B verifies D against A's + * recorded key, never its own ambient TOFU - so the grant is NOT created + * and the approval stays pending. The remedy the message names: connect to + * the destination once from the key home, or supply the key on the trust + * screen. + */ + SSH_HOST_PIN_MISSING = "SSH_HOST_PIN_MISSING", + /** + * The host-key capture found the destination pinned to a DIFFERENT key + * (spec 2026-10-08 §9): the TOFU hard block at the plane's edge, the mirror + * of the block OpenSSH itself raises on B. Nothing is overwritten; recovery + * is delete the pin (trust screen) plus a fresh grant-creation TOFU, no + * separate rotate act. + */ + SSH_HOST_PIN_CHANGED = "SSH_HOST_PIN_CHANGED", + /** + * A host-key line (an explicit pin, or a canonical destination) the wire + * grammar refuses: not one bounded printable line, a comment or blank, a + * smuggled newline. Refused before any write (spec 2026-10-08 §9). + */ + SSH_HOST_PIN_INVALID = "SSH_HOST_PIN_INVALID", } /** @@ -572,4 +596,16 @@ export const BackendErrorCodeDefs = { message: "That grant request has already been answered", statusCode: 409, }, + [BackendErrorCodes.SSH_HOST_PIN_MISSING]: { + message: "The key home has no recorded host key for that destination", + statusCode: 409, + }, + [BackendErrorCodes.SSH_HOST_PIN_CHANGED]: { + message: "The host key for that destination does not match the stored pin", + statusCode: 409, + }, + [BackendErrorCodes.SSH_HOST_PIN_INVALID]: { + message: "That host-key entry is not a valid known_hosts line", + statusCode: 400, + }, }; From 59ef1e3e4e83b2145bb2f971c77e45ba696f7694 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:42:47 -0700 Subject: [PATCH 127/170] feat(ssh-relay): every relay leg carries the destination pin Approval captures before the grant exists: a pending request without a destination is refused by name, an un-pinnable destination (no known_hosts entry on A, an offline or refusing key home) fails the approval CLOSED with the grant left pending. prepareRelayLeg then re-derives the pin per resolved destination (manual and wildcard grants are covered; one selector over many hosts yields many pins), so a relay open either carries a validated known_hosts line or the launch refuses - never ambient TOFU on B. openRelay refuses a bad hostPin before signing, and the field rides the signed ssh_relay_open to both sides; B writes it verbatim into the pane's 0600 known_hosts. composeSshLaunch in relay mode renders UserKnownHostsFile and StrictHostKeyChecking yes; the M1 render is byte-for-byte untouched. /api/ssh/host-pins (cookie-only) is the trust screen's door: supply a line, list fingerprints, delete for the TOFU recovery. security.md names the audit family. --- .../api/ssh/__tests__/grants-route.test.ts | 40 ++- .../api/ssh/__tests__/host-pins-route.test.ts | 242 ++++++++++++++++++ apps/server/api/src/api/ssh/index.ts | 8 +- apps/server/api/src/api/ssh/trust.route.ts | 167 ++++++++++++ .../__tests__/helpers/ssh-relay-stack.ts | 3 + .../__tests__/ssh-agent-identities.test.ts | 12 + .../__tests__/ssh-grants.service.test.ts | 139 +++++++++- .../__tests__/ssh-launch.service.test.ts | 28 +- .../__tests__/ssh-relay.integration.test.ts | 1 + .../__tests__/ssh-relay.service.test.ts | 18 ++ .../api/src/services/ssh-grants.service.ts | 106 +++++++- .../api/src/services/ssh-launch.service.ts | 30 ++- .../api/src/services/ssh-relay.service.ts | 22 ++ docs/security.md | 8 + 14 files changed, 810 insertions(+), 14 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts create mode 100644 apps/server/api/src/api/ssh/trust.route.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts index c6c44b589..7cc6c8ba3 100644 --- a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -18,6 +18,7 @@ import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { attachScriptedNode, ok, statDirEcho } from "@/test-helpers/scripted-node.js"; @@ -69,7 +70,14 @@ const emails: string[] = []; /** The broker's stand-in: it records the pairing and answers a fixed socket. */ const FAKE_SOCK = "/home/scripted/.subshell/ssh/fake-pane/agent.sock"; -let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }[] = []; +let openCalls: { + grantId: string; + fingerprints: string[]; + paneId: string; + aNode: string; + bNode: string; + hostPin?: string; +}[] = []; let closeGrantCalls: { grantId: string; reason: string }[] = []; let notifyCalls: string[] = []; let openRelayThrows: ((input: unknown) => never) | null = null; @@ -79,7 +87,20 @@ let rosterAnswer: () => NodeSshAgentIdentitiesResult = () => ({ }); let rosterCalls: string[] = []; +/** Task 12: the key home's canned host-key answer (the approval + launch captures). */ +const PIN_LINE = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const pinAnswer: () => { lines: string[] } = () => ({ lines: [PIN_LINE] }); +let pinFetch: { nodeId: string; destination: string }[] = []; + function installFakeDeps() { + pinFetch = []; + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async (nodeId, triple) => { + pinFetch.push({ nodeId, destination: `${triple.host}:${triple.port}` }); + return pinAnswer(); + }, + }); const broker = { async openRelay(input: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }) { if (openRelayThrows) openRelayThrows(input); @@ -194,6 +215,8 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); resetNodeRegistryForTests(); for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); await db.deleteFrom("sshGrantRequests").execute(); @@ -317,6 +340,7 @@ describe("GET/POST /api/ssh/grant-requests", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "git.example.test", + destination: "git.example.test:22", paneId: "pane-queue-probe", }); if (!asked.ok) throw new Error("fixture ask refused"); @@ -382,6 +406,7 @@ describe("GET/POST /api/ssh/grant-requests", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "second.example.test", + destination: "second.example.test:22", paneId: "pane-deny-probe", }); if (!second.ok) throw new Error("fixture ask refused"); @@ -522,6 +547,18 @@ describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => // The relay override: the snapshot names /run/user/501/ssh-agent.sock; // the pane gets the proxy socket the broker verified (spec §5.2). expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe(FAKE_SOCK); + // Task 12 end to end at the door: the broker received the captured pin, + // and the launch's OWN rendered config is the relay one - yes against + // the pinned file the B open writes beside the socket, never accept-new + // against B's ambient known_hosts. + expect(openCalls[0]?.hostPin).toBe(PIN_LINE); + const launch = scripted.cmdsOf("launch")[0] as unknown as { + ssh?: { configPath: string; fileContent: string }; + }; + const dir = launch.ssh?.configPath?.slice(0, launch.ssh.configPath.lastIndexOf("/")) ?? ""; + expect(launch.ssh?.fileContent).toContain("StrictHostKeyChecking yes"); + expect(launch.ssh?.fileContent).not.toContain("accept-new"); + expect(launch.ssh?.fileContent).toContain(`UserKnownHostsFile ${dir}/known_hosts`); } finally { scripted.detach(); } @@ -590,6 +627,7 @@ describe("GET /api/ssh/grant-requests/:id/identities (the roster behind the appr aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "git.example.test", + destination: "git.example.test:22", paneId, }); if (!asked.ok) throw new Error("fixture ask refused"); diff --git a/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts new file mode 100644 index 000000000..18b9230ea --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts @@ -0,0 +1,242 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `/api/ssh/host-pins` (spec 2026-10-08 §8-§9, Task 12): the destination + * trust screen's HTTP surface. Pinned here: + * - the cookie doctrine of every ssh sibling (empty cookie 401, a bearer + * token 403), owner-scoped reads (a foreign destination is the SAME 404 an + * absent one is on DELETE); + * - the serialized view carries destination + `SHA256:` fingerprint and NEVER + * the key bytes; + * - POST supplies an explicit pin (grammar-checked destination and line, the + * §9 hard block as a 409 `SSH_HOST_PIN_CHANGED`), DELETE removes a pin and + * the follow-up re-capture succeeds (the TOFU recovery), and the audit trail + * names only destination + fingerprint. + * + * No key home is asked by these routes (the screen SUPPLIES lines), so no + * node, broker, or fetch is wired - the pins service's fetch seam is installed + * with a never-called script to keep the promise. + */ +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "hostpinsapi-1"; +const ownerEmail = `hostpinsapi-owner-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `hostpinsapi-other-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let ownerCookie: string; +let otherCookie: string; +const emails: string[] = []; + +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 5), +]); +const LINE = `git.example.test ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const DEST = "git.example.test:22"; +const probeSubshellIds: string[] = []; + +function fetchPins( + path = "/api/ssh/host-pins", + init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}, +) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(otherEmail); + emails.push(ownerEmail, otherEmail); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + // No fetch door on these routes; a call here is a bug the test would surface. + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async () => { + throw new Error("the trust screen must never ask a key home"); + }, + }); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const id of probeSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +describe("the cookie doctrine", () => { + it("an empty cookie is 401 and a real subshell token is 403 on every door", async () => { + // A MACHINE credential must be refused the same way the sibling ssh doors + // refuse it (the fake-token path 401s at better-auth before the guard). + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "hostpins-bearer-probe", + workingDir: mkdtempSync(join(tmpdir(), "hostpins-bearer-")), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + probeSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + expect((await fetchPins()).status).toBe(401); + expect((await fetchPins(undefined, { bearer: key })).status).toBe(403); + expect((await fetchPins(undefined, { method: "POST", body: { destination: DEST, hostKey: LINE } })).status).toBe( + 401, + ); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", bearer: key })).status).toBe(403); + }); +}); + +describe("POST /api/ssh/host-pins (explicit pin)", () => { + it("supplies a pin: the view carries destination + fingerprint, never the key bytes", async () => { + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: LINE }, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { pin: Record }; + expect(json.pin.destination).toBe(DEST); + expect(json.pin.fingerprint).toBe(FP); + expect(JSON.stringify(json)).not.toContain(KEY_BYTES.toString("base64")); + // The audit row names destination + fingerprint ONLY. + const row = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.create") + .executeTakeFirstOrThrow(); + const meta = JSON.parse(row.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ destination: DEST, fingerprint: FP, via: "explicit" }); + expect(JSON.stringify(meta)).not.toContain(KEY_BYTES.toString("base64")); + }); + + it("refuses a non-canonical destination and a malformed key line with the named 400", async () => { + const badDest = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: "not-canonical", hostKey: LINE }, + }); + expect(badDest.status).toBe(400); + expect(((await badDest.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_INVALID); + const badLine = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: "git.example.test just-a-token" }, + }); + expect(badLine.status).toBe(400); + const smuggled = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `a b\nsecond ssh-ed25519 AAA` }, + }); + expect(smuggled.status).toBe(400); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(0); + }); + + it("a destination already pinned to a DIFFERENT key is the §9 hard block, never an overwrite", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + // The standing pin is untouched. + const standing = await db.selectFrom("sshHostPins").select("hostKey").executeTakeFirstOrThrow(); + expect(standing.hostKey).toBe(LINE); + }); +}); + +describe("GET list and the owner axis", () => { + it("the list shows the caller's pins only, as fingerprint views", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const mine = (await (await fetchPins(undefined, { cookie: ownerCookie })).json()) as { pins: unknown[] }; + const theirs = (await (await fetchPins(undefined, { cookie: otherCookie })).json()) as { pins: unknown[] }; + expect(mine.pins).toHaveLength(1); + expect(theirs.pins).toHaveLength(0); + expect(JSON.stringify(mine.pins)).not.toContain(KEY_BYTES.toString("base64")); + }); +}); + +describe("DELETE /api/ssh/host-pins/:destination (the recovery)", () => { + it("deletes the pin (audited with fingerprint) and the next supply at a NEW key succeeds as a fresh TOFU", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const del = await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: ownerCookie }); + expect(del.status).toBe(200); + expect(((await del.json()) as { deleted: boolean }).deleted).toBe(true); + const delAudit = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.delete") + .executeTakeFirstOrThrow(); + expect(JSON.parse(delAudit.metadataJson ?? "{}")).toMatchObject({ destination: DEST, fingerprint: FP }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const re = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(re.status).toBe(200); + }); + + it("a foreign or absent destination is the same 404", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: otherCookie })).status).toBe(404); + expect( + (await fetchPins("/api/ssh/host-pins/nobody.test:22", { method: "DELETE", cookie: ownerCookie })).status, + ).toBe(404); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(1); + }); +}); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 67262bc05..9c5351566 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -5,6 +5,7 @@ import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; +import { sshTrustRoutes } from "@/api/ssh/trust.route.js"; /** * `/api/ssh` — the launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): @@ -25,4 +26,9 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) // top-level `.route()` tier is the Elysia App-type-depth trap, and these // are the launcher's own authorization surface. .use(sshGrantsRoutes) - .use(sshGrantRequestsRoutes); + .use(sshGrantRequestsRoutes) + // The host-key pin trust screen (spec 2026-10-08 §8-§9, Task 12): the TOFU + // pins a relay launch verifies D against. Mounted inside this group for the + // same Elysia App-type-depth reason the grant tier is; these are the + // grant layer's destination-trust half. + .use(sshTrustRoutes); diff --git a/apps/server/api/src/api/ssh/trust.route.ts b/apps/server/api/src/api/ssh/trust.route.ts new file mode 100644 index 000000000..6711633c7 --- /dev/null +++ b/apps/server/api/src/api/ssh/trust.route.ts @@ -0,0 +1,167 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { apiModels } from "@/schema/index.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinRefusal, + listHostPins, + SshHostPinError, +} from "@/services/ssh-host-pins.service.js"; + +/** + * `/api/ssh/host-pins` - the destination trust screen (spec 2026-10-08 §8-§9, + * Task 12): LIST the caller's host-key pins, SUPPLY an explicit pin (the §9 + * "or an explicit pin" door: the operator's key for a destination the key + * home has not connected to yet), and DELETE one - the recovery's first half, + * whose second half is the fresh grant-creation TOFU the next capture runs. + * + * The row model is the saved-hosts/grants posture verbatim: per-owner reads, + * a foreign id the SAME 404 an absent row is, and the cookie doctrine of + * every ssh sibling (deciding which key authenticates a destination is a + * browser-session act). What the screen NEVER sees is the key bytes: the + * serialized view carries the destination and the pinned key's `SHA256:` + * fingerprint - a public identifier - and the stored line leaves this server + * only on the signed relay-open to B. + * + * The audit trail (written by the service): `node.ssh_host_pin.create` and + * `.delete`, each naming destination + fingerprint only (docs/security.md + * §10). Supplying a pin for a destination that already stands pinned to a + * DIFFERENT key is the §9 hard block (409, nothing written) - delete the pin + * first, that is the whole recovery flow. + */ + +/** The trust screen's row shape: destination + public fingerprint, never key bytes. */ +const SshHostPinViewSchema = t.Object({ + id: t.String({ description: "Pin row id (uuid)" }), + destination: t.String({ description: "Canonical resolved destination `user@host:port`" }), + fingerprint: t.String({ description: "The pinned key's SHA256: display fingerprint (public identifier)" }), + createdAt: t.String({ description: "ISO 8601 first capture (the TOFU moment)" }), + updatedAt: t.String({ description: "ISO 8601 of the last accepted match" }), +}); + +export const sshTrustRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/host-pins", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + return { pins: await listHostPins({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + pins: t.Array(SshHostPinViewSchema, { description: "The caller's pinned destinations, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshHostPins", + tags: ["ssh"], + description: + "Lists the caller's destination host-key pins as destination plus SHA256: fingerprint; the pinned key line itself is display-excluded by construction", + }, + }, + ) + .post( + "/host-pins", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const destination = body.destination.trim(); + if (parseSshCanonicalDestination(destination) === null) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "That destination is not a canonical user@host:port.", + }); + } + const line = body.hostKey.trim(); + if (!isSshKnownHostsPinLine(line) || hostKeyFingerprints(line).length !== 1) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "The host key must be one known_hosts line carrying exactly one public key.", + }); + } + try { + const row = await captureHostPin({ ownerUserId: user.id, aNodeId: null, destination, hostKeyLine: line }); + return { + pin: { + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + }; + } catch (err) { + if (err instanceof SshHostPinError) return throwCodedRefusal(hostPinRefusal(err)); + throw err; + } + }, + { + body: t.Object({ + destination: t.String({ + minLength: 1, + maxLength: 512, + description: "Canonical resolved destination `user@host:port` (the spelling the launch keys the pin by)", + }), + hostKey: t.String({ + minLength: 1, + maxLength: 4096, + description: + "The pinned entry in OpenSSH known_hosts form (pattern, key type, base64 key material) - stored verbatim and carried verbatim to B", + }), + }), + response: { + 200: t.Object({ pin: SshHostPinViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "createSshHostPin", + tags: ["ssh"], + description: + "Supplies an explicit host-key pin for a destination (the §9 alternative to capturing from the key home); a destination pinned to a different key is the named hard block, never an overwrite", + }, + }, + ) + .delete( + "/host-pins/:destination", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const deleted = await deleteHostPin({ ownerUserId: user.id, destination: params.destination }); + if (deleted === null) { + return throwCodedRefusal({ + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "No pin for that destination", + }); + } + return { deleted: true }; + }, + { + params: t.Object({ destination: t.String({ description: "Canonical destination whose pin is removed" }) }), + response: { + 200: t.Object({ deleted: t.Boolean({ description: "True once the pin row is gone" }) }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshHostPin", + tags: ["ssh"], + description: + "Deletes the caller's pin for one destination (the TOFU recovery's first half): the next capture at a fresh key re-decides trust; the audit names the removed destination and fingerprint only", + }, + }, + ); diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index 733080600..c2085f7fe 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -412,6 +412,9 @@ export async function openStack(opts: StackOptions = {}): Promise { grantId: "grant-1", fingerprints, paneId: PANE_ID, + // Task 12: the pin is REQUIRED on the open; B's branch writes it beside + // the socket, which is part of what this stack proves end to end. + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, aNode: "a-node", bNode: "b-node", aPeer: { signingPublicKey: aId.signingPublicJwk, encryptionPublicJwk: aId.publicJwk }, diff --git a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts index 40e29dc26..5bfdcd31d 100644 --- a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts @@ -15,6 +15,7 @@ import { requestFirstUse, setSshGrantsDepsForTests, } from "@/services/ssh-grants.service.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import type { RelayBroker } from "@/services/ssh-relay.service.js"; /** @@ -62,6 +63,8 @@ const NODE_A = "roster-node-a"; const NODE_B = "roster-node-b"; const HOST = "git.example.test"; +/** Task 12: the canned key-home pin the approve door captures. */ +const PIN_LINE = `${HOST} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000`; const PANE = "11111111-2222-4333-8444-555555555555"; let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); @@ -78,6 +81,13 @@ function installRosterDeps(): void { }; rosterFailure = null; rosterCalls = []; + // Task 12: approveGrant captures the destination's host-key pin; this + // roster suite approves a request, so the pins seam answers with a canned + // line and never reaches the (unwired) node RPC. + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async () => ({ lines: [PIN_LINE] }), + }); setSshGrantsDepsForTests({ nowIso, broker: () => @@ -102,6 +112,7 @@ async function mkPendingRequest(): Promise { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: `${HOST}:22`, paneId: PANE, }); if (!answer.ok) throw new Error("the pending row was not created"); @@ -130,6 +141,7 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); await db.deleteFrom("auditEvents").execute(); diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts index 6ed51c1dc..8f1cee9d7 100644 --- a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -25,6 +25,7 @@ import { sweepExpiredGrantRequests, updateGrant, } from "@/services/ssh-grants.service.js"; +import { captureHostPin, hostKeyFingerprints, setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -78,8 +79,14 @@ const NODE_A = "gsvc-node-a"; const NODE_B = "gsvc-node-b"; const nodes = new NodesRepository(db); -/** The one destination this suite routes through. */ +/** The one destination this suite routes through, and its canonical triple. */ const HOST = "git.example.test"; +const DEST = "git.example.test:22"; +/** A's canned known_hosts answer for DEST (Task 12: the approval's capture). */ +const PIN_LINE = `${HOST} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000`; +/** What the pinned host-key fetch answers; a test script can empty or swap it. */ +let pinAnswer: () => { lines: string[] } = () => ({ lines: [PIN_LINE] }); +let pinFetchCalls: { nodeId: string; destination: string }[] = []; /** A fake clock the sweep and the expiry windows read; setT() moves it. */ let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); @@ -94,6 +101,14 @@ let closeGrantCalls: FakeCall[] = []; let notifyCalls: { ownerUserId: string; requestId: string }[] = []; function installFakeDeps(brokerOverrides: Partial = {}) { + pinFetchCalls = []; + setSshHostPinsDepsForTests({ + nowIso, + fetchHostKey: async (nodeId, triple) => { + pinFetchCalls.push({ nodeId, destination: `${triple.host}:${triple.port}` }); + return pinAnswer(); + }, + }); const broker = { openRelay: async (input: unknown) => { openCalls.push(input); @@ -148,6 +163,8 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); await db.deleteFrom("auditEvents").execute(); @@ -167,7 +184,9 @@ beforeEach(() => { async function cleanTier() { await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshHostPins").execute(); await db.deleteFrom("auditEvents").execute(); + pinAnswer = () => ({ lines: [PIN_LINE] }); } async function latestAudit(action: string): Promise> { @@ -193,6 +212,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "pane-ask-1", }); expect(answer.ok).toBe(true); @@ -221,6 +241,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(first.ok).toBe(true); @@ -231,6 +252,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p2", }); expect(again.ok).toBe(true); @@ -254,6 +276,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(first.ok).toBe(true); @@ -267,6 +290,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(after.ok).toBe(true); @@ -284,6 +308,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); if (asked.ok) requestRef = asked.value.requestId; @@ -433,6 +458,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p9", }); expect(asked.ok).toBe(true); @@ -451,6 +477,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p9", }); expect(again.ok).toBe(true); @@ -517,6 +544,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "key home" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "pane-1", }); expect(leg.ok).toBe(true); @@ -541,6 +569,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "key home" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "pane-ask", }); expect(leg.ok).toBe(false); @@ -565,6 +594,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -590,6 +620,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -619,6 +650,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -628,6 +660,110 @@ describe("ssh-grants.service", () => { }); }); + describe("host-key pinning at the grant doors (Task 12, spec 2026-10-08 §9)", () => { + async function ask(): Promise { + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + destination: DEST, + paneId: "p-pin", + }); + if (!asked.ok) throw new Error("ask refused"); + return asked.value.requestId; + } + + it("approve captures A's host key as ssh_host_pins for user@host:port BEFORE the grant stands (audit names destination + fingerprint)", async () => { + const requestId = await ask(); + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(approved.ok).toBe(true); + // The ask went to the KEY HOME with the parsed triple. + expect(pinFetchCalls).toEqual([{ nodeId: NODE_A, destination: DEST }]); + const pin = await db.selectFrom("sshHostPins").selectAll().execute(); + expect(pin).toHaveLength(1); + expect(pin[0]?.destination).toBe(DEST); + expect(pin[0]?.hostKey).toBe(PIN_LINE); + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(hostKeyFingerprints(PIN_LINE)[0]); + // The bytes are on no grant/audit row anywhere. + const all = await db.selectFrom("auditEvents").select(["action", "metadataJson"]).execute(); + for (const row of all) expect(row.metadataJson ?? "").not.toContain("AAAAC3NzaC1lZDI1NTE5"); + }); + + it("approve with NO pin to capture refuses by name: the request stays pending and NO grant row exists", async () => { + const requestId = await ask(); + pinAnswer = () => ({ lines: [] }); // A has recorded nothing for DEST + const refused = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_HOST_PIN_MISSING }); + expect((await repo.getRequest(owner, requestId))?.status).toBe("pending"); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(0); + }); + + it("a standing pin satisfies the approval door WITHOUT re-asking A (TOFU persists across grants)", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: NODE_A, destination: DEST }); + const requestId = await ask(); + pinAnswer = () => { + throw new Error("must not fetch: the pin stands"); + }; + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(approved.ok).toBe(true); + }); + + it("the launch leg hands the broker the pin line; no pin means the open is refused before it is attempted", async () => { + const g = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "g", + selector: HOST, + fingerprints: FPS, + }); + if (!g.ok) throw new Error("grant fixture refused"); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + destination: DEST, + paneId: "pane-pin", + }); + expect(leg.ok).toBe(true); + // Manual grants have no approve-capture: the FIRST OPEN captured it. + expect(pinFetchCalls).toEqual([{ nodeId: NODE_A, destination: DEST }]); + const call = openCalls[0] as Record; + expect(call.hostPin).toBe(PIN_LINE); + // And with nothing to capture, a second destination under the same grant refuses before any open. + openCalls = []; + pinAnswer = () => ({ lines: [] }); + const denied = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + destination: "other.example.test:22", + paneId: "pane-pin-2", + }); + expect(denied.ok).toBe(false); + if (denied.ok) return; + expect(denied.refusal.code).toBe(BackendErrorCodes.SSH_HOST_PIN_MISSING); + expect(openCalls).toHaveLength(0); // no session, no relay-open, no ambient TOFU on B + }); + + it("the legacy pending row (no destination to pin) is refused by name, never approved blind", async () => { + const requestId = await ask(); + await db.updateTable("sshGrantRequests").set({ destination: null }).where("id", "=", requestId).execute(); + const refused = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal.code).toBe(BackendErrorCodes.SSH_HOST_PIN_MISSING); + expect((await repo.getRequest(owner, requestId))?.status).toBe("pending"); + }); + }); + describe("the grants screen and revoke", () => { it("create validates the selector and stores it normalized", async () => { expect(normalizeSshGrantSelector(" Git.Example.TEST ")).toBe("git.example.test"); @@ -718,6 +854,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p", }); expect((await listGrantRequests({ ownerUserId: owner })).length).toBe(1); diff --git a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts index 0ca430c04..fbc841dc8 100644 --- a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { buildSshConfigPath, renderSshConfigContents } from "@internal/pane-runtime"; +import { buildSshConfigPath, buildSshKnownHostsPath, renderSshConfigContents } from "@internal/pane-runtime"; import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; @@ -58,6 +58,32 @@ describe("isWireSafeSshDestination", () => { }); }); +describe("composeSshLaunch in relay mode (Task 12, spec 2026-10-08 §9)", () => { + it("the relay render forces yes on the PINNED file and replaces the snapshot's trust refs", () => { + const snapshot = SNAP({ knownHostsFiles: ["/home/scripted/.ssh/known_hosts"] }); + const pinPath = buildSshKnownHostsPath("/home/scripted/.subshell", ID); + const composed = composeSshLaunch({ + snapshot, + targetDataDir: "/home/scripted/.subshell", + subshellId: ID, + hostPinPath: pinPath, + }); + expect(composed.fileContent).toContain("StrictHostKeyChecking yes"); + expect(composed.fileContent).not.toContain("accept-new"); + expect(composed.fileContent).toContain(` UserKnownHostsFile ${pinPath}`); + expect(composed.fileContent).not.toContain("/home/scripted/.ssh/known_hosts"); // B's ambient file is not consulted + // Same path derivation as the config: the pane's dir holds config + known_hosts + agent.sock. + expect(pinPath).toBe(`/home/scripted/.subshell/ssh/${ID}/known_hosts`); + }); + + it("absent the pin option, the bytes are the M1 compose byte-for-byte (the accept-new branch is untouched)", () => { + const snapshot = SNAP(); + const plain = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(plain.fileContent).toBe(renderSshConfigContents(snapshot)); + expect(plain.fileContent).toContain("accept-new"); + }); +}); + describe("composeSshLaunch", () => { it("composes the config at the target machine's derived path, with the rendered bytes and the full option tail", () => { const snapshot = SNAP(); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index 20ee64bac..401ecdf3d 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -403,6 +403,7 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep fingerprints: [fp(KEY_IN)], lifetimeMs: SSH_RELAY_LIFETIME_MS, paneId: PANE_ID, + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, }); await openARelaySession({ relay: s.aRelay, diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index c025f0269..2119ca09e 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -41,6 +41,7 @@ import { * routing is a byte-for-byte copy and no session record ever holds a blob. */ +const PIN_LINE = `git.example.test ssh-ed25519 ${"A".repeat(51)}`; const PANE = "11111111-2222-4333-8444-555555555555"; const A_SIGNING = '{"kty":"EC","crv":"P-256","x":"AX","y":"AY","use":"sig"}'; const A_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"EX","y":"EY","use":"enc"}'; @@ -162,6 +163,9 @@ function makeHarness( bNode: "b", aPeer: { signingPublicKey: A_SIGNING, encryptionPublicJwk: A_ENCRYPT }, bPeer: { signingPublicKey: B_SIGNING, encryptionPublicJwk: B_ENCRYPT }, + // Task 12: the destination's pin is REQUIRED at the broker; the base + // carries a well-formed line and one refusal test drops it. + hostPin: PIN_LINE, ...over, }), }; @@ -208,6 +212,12 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { // (a) the grant's selected fingerprints are delivered for A's responder. expect(toA?.fingerprints).toEqual(FINGERPRINTS); expect(toA?.grantId).toBe("grant-1"); + // Task 12 (spec §9): the destination's pinned host-key line rides the + // signed open to BOTH sides (B writes it beside the socket it binds); the + // audit still names only ids, never the pin bytes. + expect(toA?.hostPin).toBe(PIN_LINE); + expect(toB?.hostPin).toBe(PIN_LINE); + expect(JSON.stringify(h.audits[0]?.meta ?? {})).not.toContain("ssh-ed25519"); // The open is audited once, ids only. expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open"]); expect(h.audits[0]?.meta).toEqual({ @@ -279,6 +289,14 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { await expect(h.open({ paneId: "" })).rejects.toMatchObject({ code: "bad-pane-id" }); await expect(h.open({ paneId: "x".repeat(65) })).rejects.toMatchObject({ code: "bad-pane-id" }); await expect(h.open({ fingerprints: ["not-a-fingerprint"] })).rejects.toMatchObject({ code: "bad-fingerprints" }); + // Task 12 (spec §9): the pin door at the mint - a session without the + // destination's pinned line does not exist, so a missing, empty, or + // multi-line hostPin is refused BEFORE any command leaves the plane. + await expect(h.open({ hostPin: "" })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: undefined })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: "host ssh-rsa AAA\nsecond ssh-rsa BBB" })).rejects.toMatchObject({ + code: "bad-host-pin", + }); expect(h.commands).toHaveLength(0); expect(h.audits).toHaveLength(0); }); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index 0711e251b..bf058fd0e 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -13,6 +13,7 @@ import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from import { type AuditEventInput, audit } from "@/services/audit.js"; import { SshRpcError, sshAgentIdentities } from "@/services/nodes/ssh-rpc.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { captureHostPin, hostPinFor, hostPinRefusal, SshHostPinError } from "@/services/ssh-host-pins.service.js"; import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -292,6 +293,13 @@ export async function requestFirstUse(args: { aNodeId: string; bNodeId: string; resolvedSelector: string; + /** + * The FULL canonical destination `user@host:port` the asking launch dialed + * (Task 12): the approval that answers this row also captures the host-key + * pin, and the pin is keyed per triple - the host alone cannot name a port + * or a user. Row-stored (migration 0051), never re-derived at approval. + */ + destination: string; paneId: string; }): Promise> { const now = grantsDeps().nowIso(); @@ -303,6 +311,7 @@ export async function requestFirstUse(args: { ownerUserId: args.ownerUserId, keyHomeNodeId: args.aNodeId, resolvedSelector: args.resolvedSelector, + destination: args.destination, requestedFingerprints: null, // the approver selects; the asking pane proposes nothing (spec §6.2) paneId: args.paneId, bNodeId: args.bNodeId, @@ -341,14 +350,21 @@ export async function sweepExpiredGrantRequests(): Promise { /** * Answer a pending request YES (spec §6.2): validate the selection against - * the cap and the grammar FIRST, then flip the row `pending -> approved` on a - * compare-and-set (a raced approver or the sweep loses here and sees the 409, - * never a double grant), then write the standing grant with EXACTLY the - * chosen fingerprints, created via first-use. Audits BOTH facts: the answer - * (`approve`) and the row's birth (`create`, the same event the screen's own - * create writes, per §6.2), naming ids, destination, and the CHOSEN - * fingerprint VALUES - §10 makes the approve row the only durable record of - * the operator's selection, so a count here would record no selection. + * the cap and the grammar FIRST, then - Task 12 - ensure the destination's + * host-key PIN exists (capture from A's `known_hosts`, or accept the owner's + * standing/explicit pin: the approval is "grant creation" for the first-use + * door, and a relay grant is never created without its pin), then flip the + * row `pending -> approved` on a compare-and-set (a raced approver or the + * sweep loses here and sees the 409, never a double grant), then write the + * standing grant with EXACTLY the chosen fingerprints, created via first-use. + * Audits BOTH facts: the answer (`approve`) and the row's birth (`create`, + * the same event the screen's own create writes, per §6.2), naming ids, + * destination, and the CHOSEN fingerprint VALUES - §10 makes the approve row + * the only durable record of the operator's selection, so a count here would + * record no selection. A capture failure leaves the row PENDING and the grant + * unwritten (the pending row is what makes the ask retryable); a pin row + * captured before a lost race stands harmless - same owner, same key home, + * same destination, and the retried capture accepts it idempotently. */ export async function approveGrant(args: { ownerUserId: string; @@ -363,6 +379,29 @@ export async function approveGrant(args: { const row = await repo.getRequest(args.ownerUserId, args.requestId); if (!row) return refused(NOT_FOUND_REQUEST); if (row.status !== "pending") return refused(ALREADY_ANSWERED); + // Capture-at-grant-creation (spec §9), before the flip and before the grant + // row exists: no pin, no grant. The stored pin (a previous grant's TOFU + // record for the same destination) satisfies the door WITHOUT re-asking A - + // the TOFU decision persists across grants; only an ABSENT pin triggers the + // fetch, whose every failure leaves the request pending untouched. + if (row.destination === null) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "This request predates host-key pinning and carries no full destination to pin. Deny it and launch again to ask fresh.", + }); + } + try { + await ensureDestinationPin({ + ownerUserId: args.ownerUserId, + aNodeId: row.keyHomeNodeId, + destination: row.destination, + }); + } catch (err) { + if (err instanceof SshHostPinError) return refused(hostPinRefusal(err)); + throw err; + } const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "approved"); if (!flipped) return refused(ALREADY_ANSWERED); const grant: SshKeyGrantTable = { @@ -654,6 +693,31 @@ function rosterRpcRefusal(err: SshRpcError): SshRefusalNarrow { }; } +/* ------------------------------------------------------------------ */ +/* the pin door (spec 2026-10-08 §9, Task 12): a relay never opens */ +/* without the destination's stored pin - the approval captures one */ +/* (this helper's first-use half) and the launch leg captures at the */ +/* first open for a destination a manual grant has not pinned yet. */ +/* ------------------------------------------------------------------ */ + +/** + * Read the destination's pin, capturing from A's `known_hosts` when none + * stands. Returns the row's pinned `known_hosts` line - the exact bytes that + * ride the relay-open to B. Raises {@link SshHostPinError} with the named + * cause on every fail-closed door (no entry, ambiguous, changed, the machine + * unreachable); nothing is written and no relay opens past one. + */ +export async function ensureDestinationPin(args: { + ownerUserId: string; + aNodeId: string; + destination: string; +}): Promise<{ line: string }> { + const existing = await hostPinFor({ ownerUserId: args.ownerUserId, destination: args.destination }); + if (existing !== null) return { line: existing.hostKey }; + const row = await captureHostPin(args); + return { line: row.hostKey }; +} + /* ------------------------------------------------------------------ */ /* the launch leg: match -> open (or ask -> refuse) */ /* ------------------------------------------------------------------ */ @@ -681,6 +745,14 @@ export async function prepareRelayLeg(args: { aNode: { id: string; name: string }; bNodeId: string; resolvedHost: string; + /** + * The canonical destination `user@host:port` the launch dialed (Task 12): + * the pin lookup key AND the capture destination (a manual wildcard grant + * gets its pin at this first open for each concrete destination - "one + * selector over many hosts yields many pins"), AND the first-use row's + * stored destination. The relay-open never carries a pairing without it. + */ + destination: string; paneId: string; }): Promise> { const grant = await matchGrant({ @@ -694,6 +766,7 @@ export async function prepareRelayLeg(args: { aNodeId: args.aNode.id, bNodeId: args.bNodeId, resolvedSelector: args.resolvedHost, + destination: args.destination, paneId: args.paneId, }); const ref = asked.ok ? asked.value.requestId : ""; @@ -733,6 +806,20 @@ export async function prepareRelayLeg(args: { if (!standing) { return refused(GRANT_REVOKED_DURING_LAUNCH); } + // The pin door (spec §9, Task 12): read the destination's stored pin, + // capturing from A's `known_hosts` at first open when none stands (the + // manual-grant and later-destination case). Every failure refuses the + // launch BEFORE any session exists - a relay without a pin would mean B + // ambient-TOFUing D, which is the one posture the design refuses. + let hostPin: string; + try { + hostPin = ( + await ensureDestinationPin({ ownerUserId: args.viewerId, aNodeId: args.aNode.id, destination: args.destination }) + ).line; + } catch (err) { + if (err instanceof SshHostPinError) return refused(hostPinRefusal(err)); + throw err; + } const broker = grantsDeps().broker(); let socketPath: string; try { @@ -744,6 +831,7 @@ export async function prepareRelayLeg(args: { bNode: args.bNodeId, aPeer, bPeer, + hostPin, }); socketPath = opened.socketPath; } catch (err) { @@ -793,6 +881,8 @@ function relayRefusalCopy(code: SshRelayRefusal["code"]): string { case "bad-pane-id": case "bad-fingerprints": return "The relay refused the session's identifiers as malformed; the launch was refused."; + case "bad-host-pin": + return "The relay refused the destination host pin as malformed; the launch was refused."; } } diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 738dd2720..f6d22f13d 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import { BackendErrorCodes } from "@internal/backend-errors"; import { buildSshConfigPath, + buildSshKnownHostsPath, discoverSshAliases, findBinary, getHarness, @@ -125,6 +126,17 @@ export function composeSshLaunch(args: { snapshot: SshConnectionSnapshotWire; targetDataDir: string; subshellId: string; + /** + * The relay session's pinned host-key file path (Task 12, spec 2026-10-08 + * §9). PRESENT is the relay mode: the rendered config forces + * `StrictHostKeyChecking yes` and names this one file as the ONLY trust + * source - the pane's ssh verifies D against A's recorded key, never + * against B's ambient known_hosts. ABSENT is the M1 direct launch: the + * accept-new posture, byte-for-byte. The path is `buildSshKnownHostsPath` + * of the SAME two facts the config path derives from; B writes the pin + * delivered on the signed relay-open to its own derivation of them. + */ + hostPinPath?: string; }): { configPath: string; fileContent: string; @@ -134,7 +146,10 @@ export function composeSshLaunch(args: { const approved = parseSshConnectionSnapshot(args.snapshot); if (approved === null) throw new Error("ssh compose: snapshot failed grammar re-validation"); const configPath = buildSshConfigPath(args.targetDataDir, args.subshellId); - const fileContent = renderSshConfigContents(approved); + const fileContent = renderSshConfigContents( + approved, + args.hostPinPath === undefined ? undefined : { hostPinPath: args.hostPinPath }, + ); const presetFlags = [...sshOptionTokens(approved, configPath), "--", sshDestinationToken(approved)]; return approved.authAgentSocket === null ? { configPath, fileContent, presetFlags } @@ -444,12 +459,23 @@ export async function sshLaunch(args: { aNode: { id: aRow.id, name: aRow.name }, bNodeId: row.id, resolvedHost: snapshot.host, + // The canonical triple the pin is keyed by, from the SAME validated + // snapshot whose host drove the grant match (Task 12). + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), paneId: subshellId, }); if (!leg.ok) return leg; relay = leg.value; } - const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDir, + subshellId, + // Relay mode (Task 12): the config pins B's trust source to the file the + // signed relay-open just wrote beside it; a direct launch renders the M1 + // accept-new bytes untouched. + ...(relay ? { hostPinPath: buildSshKnownHostsPath(dataDir, subshellId) } : {}), + }); // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the // pane's ssh authenticates through is B's proxy the broker just verified, // never the snapshot's agent socket even if the config names one. Still diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts index e22a15a63..6dd7ec9ed 100644 --- a/apps/server/api/src/services/ssh-relay.service.ts +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -1,6 +1,7 @@ import { buildAgentSocketPath } from "@internal/pane-runtime"; import { isSshGrantFingerprints, + isSshKnownHostsPinLine, isSshPaneId, NODE_MAX_FRAME_BYTES, type NodeCommandBody, @@ -101,6 +102,7 @@ export interface RelayPeerKeys { export type SshRelayRefusalCode = | "bad-pane-id" | "bad-fingerprints" + | "bad-host-pin" | "same-node" | "no-node" | "local-node" @@ -171,6 +173,14 @@ export interface OpenRelayInput { aPeer: RelayPeerKeys; /** B's registered public halves, from B's identity row. */ bPeer: RelayPeerKeys; + /** + * The destination's pinned `known_hosts` line (spec 2026-10-08 §9, Task + * 12), REQUIRED and validated with the wire grammar's own predicate before + * anything is signed. B's open writes it to the pane's 0600 pinned file; + * a broker call without it is a broker bug, refused like the bad-pane-id + * and bad-fingerprints doors beside it. + */ + hostPin: string; } /** What a successful `openRelay` answers: the pairing, plus the socket the pane's scoped env must point at. */ @@ -267,6 +277,7 @@ function openCmd(input: OpenRelayInput, role: "A" | "B", ref: string, relayId: s fingerprints: [...input.fingerprints], lifetimeMs: SSH_RELAY_LIFETIME_MS, paneId: input.paneId, + hostPin: input.hostPin, }; } @@ -349,6 +360,17 @@ export function createRelayBroker(deps: RelayBrokerDeps): RelayBroker { "relay open refused: the grant's fingerprint set is malformed or over SSH_MAX_GRANT_FINGERPRINTS", ); } + // Task 12 (spec §9): the destination's pin travels on the open, and the + // grammar's own line predicate is the broker's check too - a relay + // session with no pin (or with a multi-line smuggle) is refused BEFORE + // anything is signed or sent, so "the relay grant carries a pin" is + // enforced at the mint, not hoped for at the endpoints. + if (!isSshKnownHostsPinLine(input.hostPin)) { + throw new SshRelayRefusal( + "bad-host-pin", + "relay open refused: the destination host pin is missing, empty, or not one known_hosts line", + ); + } if (input.aNode === input.bNode) { // A relay bridges two machines; a one-machine pairing means a caller // that should not have asked at all. diff --git a/docs/security.md b/docs/security.md index f7877408d..d45dda8a0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2293,6 +2293,14 @@ Audit events are written, grouped by family: is audit-only and expiry writes NOTHING; delete carries `relaysClosed`, the revoke's live-session cut made real; create carries `via: "first-use" | "manual"`; no key material, challenge, or signature rides any row), + `node.ssh_host_pin.create|delete` (spec §9, Task 12: the destination's + TOFU record, created at a grant's capture or an explicit supply and deleted + as the recovery's first half; metadata names the canonical destination and + the pinned key's `SHA256:` fingerprint ONLY - a host-key fingerprint is a + public identifier like the grant's agent fingerprints - plus `via: + "captured" | "explicit"` on create; the pinned line's bytes ride NO audit + row, log line, or notification, and the `ssh_host_key` capture COMMAND + itself writes nothing: the row belongs to the capture act), `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: From 69264e6fcd14e4e3ec9e140ec812a33e3e95f5b6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:44:12 -0700 Subject: [PATCH 128/170] test(node): narrow the ssh_host_key answer and pin the JWK-refusal fixture verify-types caught the two spots: the dedup assertion now narrows ok before reaching for lines, and the private-JWK refusal fixture carries the required host pin like every other open command. --- apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts | 3 ++- apps/node/agent/src/__tests__/relay-responder.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts index c1a483136..2687e27e0 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -181,7 +181,8 @@ describe("ssh_host_key arm (gate ON)", () => { "[deploy@git.example.test]:22": { code: 1, stdout: "", stderr: "" }, }); const res = await execSshHostKey(makeCtx("dedup"), cmd({ user: "deploy" }), seamsFor(trust, fake.run)); - expect((res as { data: { lines: string[] } }).data.lines).toEqual([LINE_A]); + if (!res.ok) throw new Error(`expected ok:true, got ${JSON.stringify(res)}`); + expect((res.data as { lines: string[] }).lines).toEqual([LINE_A]); }); it("a silent non-zero run with stderr is the named unreadable-file refusal, never the empty fact", async () => { diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 6ee7df87b..fab847686 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1174,6 +1174,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }, sendRelayFrame: () => {}, resolveAgentSocket: () => null, From bcbab66a13ccf689ea3201fa2897c89934b909fb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:05:52 -0700 Subject: [PATCH 129/170] fix(ssh-relay): neutralize GlobalKnownHostsFile in relay mode; idempotent host-pin capture --- apps/node/agent/src/commands/ssh-host-key.ts | 8 +- .../__tests__/ssh-host-pins.service.test.ts | 81 +++++++++++++++++ .../api/src/services/ssh-host-pins.service.ts | 87 +++++++++++++++---- .../src/ssh/__tests__/ssh-render.test.ts | 18 +++- packages/pane-runtime/src/ssh/ssh-render.ts | 20 +++-- 5 files changed, 185 insertions(+), 29 deletions(-) diff --git a/apps/node/agent/src/commands/ssh-host-key.ts b/apps/node/agent/src/commands/ssh-host-key.ts index 5f950dadf..10fe06426 100644 --- a/apps/node/agent/src/commands/ssh-host-key.ts +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -77,7 +77,8 @@ export function sshHostKeyCandidates({ host, port, user }: SshHostKeyCommand): s * not, and both are dropped here. Non-zero exit with no output is OpenSSH's * spelling of "this candidate matched nothing" (and of an unreadable file - * the caller distinguishes those by whether ANY candidate produced bytes - * and by the stderr it logs); non-zero with output still yields its entries. + * and by the stderr it reports); non-zero with output still yields its + * entries. */ function entryLines(stdout: string): string[] { const lines: string[] = []; @@ -127,8 +128,9 @@ export async function execSshHostKey( // Non-zero AND silent: "no match" (normal, keep looking) is // indistinguishable from "cannot read the file" only by stderr, and // the file's readability was not claimed - so an unreadable file must - // not read as "A recorded nothing". One line to the log (names never - // key bytes); the refusal goes to the plane as the named error. + // not read as "A recorded nothing". No log line is written here: the + // refusal below names the cause to the plane, which is the whole + // record this arm keeps. sawReadFailure = sawReadFailure || run.stderr.trim() !== ""; continue; } diff --git a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts index 5b5cf8eff..19e88cd04 100644 --- a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -6,7 +6,9 @@ import { hashPassword } from "better-auth/crypto"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { captureHostPin, @@ -257,6 +259,85 @@ describe("capture", () => { }); }); +describe("the concurrent-first-capture race (the UNIQUE index decides, never a raw error)", () => { + /** + * Simulate the interleaving deterministically without a second connection: + * blind the service's FIRST getPin (the read every serialized capture made + * before its rival committed) while the real UNIQUE(owner_user_id, + * destination) index stays live, so the loser's insert collides for real + * and the re-read sees the winner. Every later read answers truthfully. + */ + async function withBlindedFirstRead(fn: () => Promise): Promise { + const proto = SshHostPinsRepository.prototype as { + getPin: (ownerUserId: string, destination: string) => Promise; + }; + const realGetPin = proto.getPin; + let blinded = false; + proto.getPin = async function (this: SshHostPinsRepository, o: string, d: string) { + if (!blinded) { + blinded = true; + return null; + } + return await realGetPin.call(this, o, d); + }; + try { + return await fn(); + } finally { + proto.getPin = realGetPin; + } + } + + async function pinRowCount(): Promise { + const rows = await db.selectFrom("sshHostPins").select("id").execute(); + return rows.length; + } + + async function createAuditCount(): Promise { + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + return rows.length; + } + + it("two concurrent FIRST captures at the SAME key: no raw error, one row, one create row, both answer the winner's pin", async () => { + // This is what the unguarded get-then-insert surfaced as a raw + // SqliteError (a 500 on the grant path): the collision must resolve to + // the serialized path's outcome - idempotent success, touched stamp. + const [a, b] = await withBlindedFirstRead(async () => { + return await Promise.all([ + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + ]); + }); + expect(a.hostKey).toBe(LINE); + expect(b.hostKey).toBe(LINE); + expect(a.id).toBe(b.id); // both answered the SAME standing row + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); // the winner created; the loser only touched + }); + + it("a FIRST capture colliding with a DIFFERENT key is the changed block through the race path too, the winner's row standing", async () => { + // The winner's LINE stands; the loser (blinded initial read) races in + // with OTHER_LINE, hits the index, and must read back and refuse exactly + // as the serialized path does - not crash, not overwrite. + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); // the winner + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await withBlindedFirstRead(async () => { + return await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + }); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); + }); +}); + describe("delete and re-capture (the TOFU recovery)", () => { it("delete removes the row and audits destination + fingerprint; the re-capture at the NEW key succeeds as a fresh TOFU", async () => { await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts index 1f250b15a..df86bcd72 100644 --- a/apps/server/api/src/services/ssh-host-pins.service.ts +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -71,6 +71,17 @@ export class SshHostPinError extends Error { const repo = new SshHostPinsRepository(db); +/** + * True when a failed insert hit this table's UNIQUE + * `(owner_user_id, destination)` index (idx_ssh_host_pins_owner_destination): + * the `node-errors` message-containment posture, scoped to the table so any + * OTHER constraint violation stays loud instead of being swallowed as a race. + */ +function isUniquePinViolation(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return msg.includes("UNIQUE constraint failed") && msg.includes("ssh_host_pins"); +} + /** The service's world. Production at {@link defaultPinsDeps}; tests install a fake. */ export interface SshHostPinsDeps { /** The one clock: row stamps. */ @@ -209,6 +220,37 @@ async function fetchAndChooseLine(aNodeId: string | null, destination: string): return chooseCapturedLine(destination, answer.lines); } +/** + * The decision over a STANDING row, shared by the serialized read and the + * insert-collision readback so a raced capture decides EXACTLY as the + * serialized one: a differing key is §9's byte-equality hard block (nothing + * written, nothing deleted - the operator's re-decide is the only way past), + * the same key is the TOFU record agreeing with itself (the row stands, + * updatedAt = the last accepted match, no second create row). + */ +async function acceptStandingPin( + ownerUserId: string, + existing: SshHostPinTable, + fps: string[], + destination: string, +): Promise { + const stored = hostKeyFingerprints(existing.hostKey); + if (stored.length !== 1 || stored[0] !== fps[0]) { + // §9's byte-equality hard block, at the plane's edge: A now reports a + // different key than the pin. Nothing written, nothing deleted - the + // operator's re-decide (delete + fresh capture) is the only way past. + throw new SshHostPinError( + "changed", + `the key home now reports a different host key for ${destination} than the pinned one`, + destination, + ); + } + // The same key again is the TOFU record agreeing with itself: the row + // stands (updatedAt = the last accepted match), no second create row. + await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); + return { ...existing, updatedAt: pinsDeps().nowIso() }; +} + /** * Capture (or accept, idempotently) the owner's pin for one canonical * destination. `hostKeyLine` supplies the explicit-pin posture (the operator's @@ -243,23 +285,7 @@ export async function captureHostPin(args: { ); } const existing = await repo.getPin(ownerUserId, destination); - if (existing !== null) { - const stored = hostKeyFingerprints(existing.hostKey); - if (stored.length !== 1 || stored[0] !== fps[0]) { - // §9's byte-equality hard block, at the plane's edge: A now reports a - // different key than the pin. Nothing written, nothing deleted - the - // operator's re-decide (delete + fresh capture) is the only way past. - throw new SshHostPinError( - "changed", - `the key home now reports a different host key for ${destination} than the pinned one`, - destination, - ); - } - // The same key again is the TOFU record agreeing with itself: the row - // stands (updatedAt = the last accepted match), no second create row. - await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); - return { ...existing, updatedAt: pinsDeps().nowIso() }; - } + if (existing !== null) return await acceptStandingPin(ownerUserId, existing, fps, destination); const now = pinsDeps().nowIso(); const row: SshHostPinTable = { id: crypto.randomUUID(), @@ -269,7 +295,32 @@ export async function captureHostPin(args: { createdAt: now, updatedAt: now, }; - await repo.insertPin(row); + // The get-then-insert above races the UNIQUE (owner_user_id, destination) + // index: two concurrent FIRST captures of one destination both read "no + // row". A collision is decided, never surfaced raw: the loser re-reads the + // winner's row and accepts it through the SAME rule the serialized path + // applies (same key = the idempotent touch, different key = the changed + // block). A winner that vanished between the refused insert and the + // readback (a concurrent delete) gets one retry; a second double-race + // refuses by name rather than looping - a non-violation DB error is not a + // race and stays loud. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await repo.insertPin(row); + break; + } catch (err) { + if (!isUniquePinViolation(err)) throw err; + const standing = await repo.getPin(ownerUserId, destination); + if (standing !== null) return await acceptStandingPin(ownerUserId, standing, fps, destination); + if (attempt === 1) { + throw new SshHostPinError( + "changed", + `the host-pin store refused the capture for ${destination} twice while its row kept changing; ask again`, + destination, + ); + } + } + } await pinAudit(ownerUserId, "node.ssh_host_pin.create", aNodeId, { destination, fingerprint: fps[0], // the public identifier ONLY, never the line's bytes (§10) diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index 0f94245bf..bb33c86d4 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -261,12 +261,24 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { expect(out).toContain(` UserKnownHostsFile ${PIN}`); }); + it("neutralizes the system-wide file too: BOTH trust refs, UserKnownHostsFile pinned and GlobalKnownHostsFile /dev/null", () => { + // OpenSSH consults GlobalKnownHostsFile (/etc/ssh/ssh_known_hosts by + // default) ALONGSIDE UserKnownHostsFile, and a match there satisfies + // `yes` - outranking the user file is not enough. A planted or stale key + // in B's system-wide file must not authenticate D: the destination's + // global trust DB is /dev/null for every hop (spec 2026-10-08 §9: the + // pinned file is the authority, not ambient state on B). + const out = renderSshConfigContents(baseSnapshot(), relay); + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + expect(out).toContain(" GlobalKnownHostsFile /dev/null"); + }); + it("quotes the pinned path only when it needs it (the same tokenizer rule as other file values)", () => { const spaced = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh dir/s1/known_hosts" }); expect(spaced).toContain(' UserKnownHostsFile "/data/ssh dir/s1/known_hosts"'); }); - it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust source + yes)", () => { + it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust refs + yes)", () => { const out = renderSshConfigContents(baseSnapshot(), relay); for (const [key, value] of [ ["ForwardAgent", "no"], @@ -286,6 +298,10 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { expect(renderSshConfigContents(snap)).toContain(" StrictHostKeyChecking accept-new"); // No second argument is the M1 call; it must equal an explicit-undefined. expect(renderSshConfigContents(snap, undefined)).toBe(renderSshConfigContents(snap)); + // The relay's global-file neutralization must NOT leak into the M1 + // posture: the direct branch keeps consulting the machine's own trust + // files exactly as M1 rendered them. + expect(renderSshConfigContents(snap)).not.toContain("GlobalKnownHostsFile"); }); it("refuses a non-absolute or oversized hostPinPath (never composes a stray trust file)", () => { diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index d74535256..a15d88b26 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -155,14 +155,18 @@ const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new * — the same approval always renders the same file, which is what makes the * run's recorded config byte-checkable. * - * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY two + * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY three * things, and pins the test suite to all of them: `StrictHostKeyChecking` * says `yes` instead of `accept-new` (a changed D key is refused by OpenSSH - * naming D; nothing new is ever recorded), and `UserKnownHostsFile` is the + * naming D; nothing new is ever recorded); `UserKnownHostsFile` is the * session's pinned file and NOTHING ELSE - the snapshot's own trust refs are * replaced, because on the relay path B's ambient known_hosts is not the - * authority, A's recorded key is. Absent the option (every M1 launch) the - * bytes are the accept-new posture byte-for-byte, untouched. + * authority, A's recorded key is; and `GlobalKnownHostsFile` becomes + * `/dev/null`, because OpenSSH consults the system-wide file ALONGSIDE the + * user file and a match there satisfies `yes` - a planted or stale entry in + * B's `/etc/ssh/ssh_known_hosts` must not authenticate D either. Absent the + * option (every M1 launch) the bytes are the accept-new posture + * byte-for-byte, untouched: the direct branch never names the global file. */ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string { assertRenderable(snapshot); @@ -189,11 +193,13 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel : []), "Host *", ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), - // Relay: the pinned file is the ONE trust source, the snapshot's own - // refs must not ride beside it. Direct: the M1 rule (silence IS the + // Relay: the pinned file is the ONE trust source - the snapshot's own + // refs must not ride beside it, and neither may the system-wide DB + // (`yes` consults GlobalKnownHostsFile too, so it is /dev/null'd; one + // site names both trust refs). Direct: the M1 rule (silence IS the // policy, the machine's default known_hosts is the trust store). ...(relay - ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`] + ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`, " GlobalKnownHostsFile /dev/null"] : knownHostsLines(snapshot.knownHostsFiles)), ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), From 7ec2e82cb6109d2d0e607459d1537558419be777 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:13:11 -0700 Subject: [PATCH 130/170] feat(ssh-relay): ssh-fingerprint block in the runtime report, hand-rolled canonical validator --- .../src/__tests__/node-frames.test.ts | 102 ++++++++++++++++++ packages/subshell-protocol/src/index.ts | 4 + packages/subshell-protocol/src/node-frames.ts | 101 +++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 314e18661..1278098f8 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -22,6 +22,7 @@ import { parseNodeCommandBody, parseNodeEvent, parseNodeRuntimeReport, + parseNodeSshFingerprintReport, partPathOf, } from "../node-frames.js"; import { @@ -748,6 +749,107 @@ describe("ready.runtime (additive)", () => { }); }); +describe("ready.runtime.sshFingerprint (spec 2026-10-08 §4.6)", () => { + const base = { + type: "ready", + agentVersion: "0.2.0", + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const runtime = { + startedAt: "2026-09-12T10:00:00.000Z", + supervised: true, + service: { + manager: "systemd", + installed: true, + definitionPath: "/u/.config/systemd/user/subshell.service", + state: "running", + pid: 42, + enabled: true, + linger: true, + paneSafety: "keeps", + }, + configPath: "/u/.config/subshell/config.json", + agentLogPath: "/u/.config/subshell/logs/agent.log", + logging: { debug: false, source: "default" }, + logPath: null, + logHint: null, + tmuxPath: "/usr/bin/tmux", + binaryPath: "/u/.local/bin/subshell", + }; + /** A canonical 32-byte SHA-256 digest printed as 43 base64url characters. */ + const FP_A = `SHA256:${"A".repeat(43)}`; + const FP_B = `SHA256:${"B".repeat(43)}`; + const FP_C = `SHA256:${"C".repeat(43)}`; + const FP_D = `SHA256:${"D".repeat(43)}`; + const block = { + own: { signing: FP_A, encryption: FP_B }, + peers: [{ nodeId: "0f8e2c1a-0000-4000-8000-000000000001", signing: FP_C, encryption: FP_D }], + }; + + it("parseNodeSshFingerprintReport accepts a canonical block, empty peers included", () => { + expect(parseNodeSshFingerprintReport(block)).toEqual(block); + expect(parseNodeSshFingerprintReport({ own: block.own, peers: [] })).toEqual({ own: block.own, peers: [] }); + }); + + it("refuses fingerprints that are not canonical SHA256 base64url", () => { + // The spelling the node emits is `fingerprintJwk`'s exactly: the `SHA256:` + // prefix, 43 unpadded base64url characters, never the `+` `/` `=` spellings + // of standard base64 (a different scheme would fingerprint-compare against + // bytes the machine never pinned). + for (const bad of [ + "A".repeat(43), // no prefix + `SHA256:${"A".repeat(42)}`, // short digest + `SHA256:${"A".repeat(44)}`, // long digest + `SHA256:${"A".repeat(42)}=`, // padded, not canonical here + `SHA256:${"+".repeat(43)}`, // standard-base64 alphabet + `sha256:${"A".repeat(43)}`, // case-sensitive prefix + ]) { + expect(parseNodeSshFingerprintReport({ own: { signing: bad, encryption: FP_B }, peers: [] })).toBeNull(); + } + }); + + it("refuses structural junk: missing halves, non-array peers, bad peer ids", () => { + expect(parseNodeSshFingerprintReport(null)).toBeNull(); + expect(parseNodeSshFingerprintReport("own")).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: { signing: FP_A }, peers: [] })).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: block.own })).toBeNull(); // peers REQUIRED (empty = no peers) + expect(parseNodeSshFingerprintReport({ own: block.own, peers: "none" })).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: block.own, peers: [{ signing: FP_C, encryption: FP_D }] })).toBeNull(); + expect( + parseNodeSshFingerprintReport({ own: block.own, peers: [{ nodeId: "", signing: FP_C, encryption: FP_D }] }), + ).toBeNull(); + expect( + parseNodeSshFingerprintReport({ own: block.own, peers: [{ nodeId: "a b/c", signing: FP_C, encryption: FP_D }] }), + ).toBeNull(); + expect( + parseNodeSshFingerprintReport({ + own: block.own, + peers: [{ nodeId: "n".repeat(65), signing: FP_C, encryption: FP_D }], + }), + ).toBeNull(); + }); + + it("runtime carries a well-formed block and drops a malformed one, keeping the report", () => { + expect(parseNodeRuntimeReport({ ...runtime, sshFingerprint: block })?.sshFingerprint).toEqual(block); + const withBad = parseNodeRuntimeReport({ ...runtime, sshFingerprint: { own: "yes", peers: [] } }); + expect(withBad).not.toBeNull(); + expect(withBad && "sshFingerprint" in withBad ? withBad.sshFingerprint : undefined).toBeUndefined(); + expect(parseNodeRuntimeReport(runtime) && "sshFingerprint" in (parseNodeRuntimeReport(runtime) as object)).toBe( + false, + ); + }); + + it("the ready frame keeps a reported block end to end", () => { + const ev = parseNodeEvent({ ...base, runtime: { ...runtime, sshFingerprint: block } }); + expect(ev && ev.type === "ready" ? ev.runtime?.sshFingerprint : undefined).toEqual(block); + }); +}); + describe("service command", () => { it("parses every verb, with and without force", () => { for (const verb of NODE_SERVICE_VERBS) { diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 9293e5e97..f336cbc4c 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -105,6 +105,9 @@ export { type NodeRuntimeReport, type NodeServiceVerb, type NodeSshEnabledWire, + type NodeSshFingerprintPair, + type NodeSshFingerprintReport, + type NodeSshPeerFingerprint, type PluginReportWire, type PresetDefinitionWire, parseNodeCommandBody, @@ -112,6 +115,7 @@ export { parseNodeMaintenance, parseNodeRuntimeReport, parseNodeSshEnabled, + parseNodeSshFingerprintReport, parseRelayFrame, partPathOf, RELAY_DIRECTIONS, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 653e3c942..06b57bde7 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -491,6 +491,101 @@ export interface NodeRuntimeReport { tmuxPath: string | null; /** The agent binary this process re-enters (`selfInvoke.command`). */ binaryPath: string; + /** + * The machine's SSH trust block (spec 2026-10-08 §4.6): this machine's own + * signing and encryption key fingerprints, and the pinned half of every + * relay peer, as the canonical `SHA256:` form of `fingerprintJwk`. + * + * Fingerprints are the DISPLAY form of the trust the node enforces as raw + * byte equality (`machine-pin-store.ts`): this block carries no key + * material, only public identifiers, and exists so an operator can compare + * what one machine STORED about a peer against what the peer COMPUTES from + * its own keys. Absent from agents that predate M2, and dropped rather than + * fatal when malformed, like `logging`. + */ + sshFingerprint?: NodeSshFingerprintReport; +} + +/** + * One machine's two public relay halves as `SHA256:` fingerprints (spec + * 2026-10-08 §4.5): the ES256 signing key and the ECDH-ES encryption key. + */ +export interface NodeSshFingerprintPair { + /** Fingerprint of the ES256 machine-signing public key. */ + signing: string; + /** Fingerprint of the ECDH-ES encryption public key. */ + encryption: string; +} + +/** One pinned peer, named by id, with both of its fingerprints. */ +export interface NodeSshPeerFingerprint extends NodeSshFingerprintPair { + /** The pinned peer's node id. */ + nodeId: string; +} + +/** + * The §4.6 trust block: what this machine pins for itself and every peer, + * computed from its OWN key files, never from the plane's copy of them. The + * plane mirrors it onto `nodes.ssh_fingerprint` (the trust card's stale half). + */ +export interface NodeSshFingerprintReport { + /** This machine's own both halves. */ + own: NodeSshFingerprintPair; + /** Every pinned peer; the empty array is the honest "no peers yet". */ + peers: NodeSshPeerFingerprint[]; +} + +/** + * The canonical fingerprint spelling of `fingerprintJwk`: the `SHA256:` + * prefix and exactly the 43 unpadded base64url characters of a 32-byte + * SHA-256 digest. Nothing else is admitted: the plane mirrors and renders + * bytes it cannot re-derive, so a looser grammar would let another scheme's + * digests into the same out-of-band comparison the block exists to enable. + */ +const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{43}$/; + +/** Whether `value` is a canonical `SHA256:` fingerprint string. */ +function isSshFingerprint(value: unknown): value is string { + return typeof value === "string" && SSH_FINGERPRINT_RE.test(value); +} + +/** + * Node ids as the plane mints them: uuid text (hex and hyphens). `isNodeSubshellId`'s + * shape, reused because a peer id reaching the block is the same registry id. + */ +function isPeerNodeId(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-fA-F-]{1,64}$/.test(value); +} + +/** The `{ signing, encryption }` pair, or null when either half is off. */ +function parseFingerprintPair(value: unknown): NodeSshFingerprintPair | null { + if (!isRecord(value)) return null; + if (!isSshFingerprint(value.signing) || !isSshFingerprint(value.encryption)) return null; + return { signing: value.signing, encryption: value.encryption }; +} + +/** + * Shape-check a `NodeSshFingerprintReport` (the runtime report's §4.6 block). + * STRICT inside, like every required field of the runtime report it rides: + * a half-refused block would print a trust card whose compare is meaningless, + * so one bad entry refuses the whole block (the block itself is dropped from + * the runtime report leniently, the `logging` way: the card is lost, not the + * connection). + * @param value - the candidate, typically `ready.runtime.sshFingerprint` + * @returns the narrowed report, or null when malformed + */ +export function parseNodeSshFingerprintReport(value: unknown): NodeSshFingerprintReport | null { + if (!isRecord(value) || !Array.isArray(value.peers)) return null; + const own = parseFingerprintPair(value.own); + if (own === null) return null; + const peers: NodeSshPeerFingerprint[] = []; + for (const entry of value.peers) { + if (!isRecord(entry) || !isPeerNodeId(entry.nodeId)) return null; + const pair = parseFingerprintPair(entry); + if (pair === null) return null; + peers.push({ nodeId: entry.nodeId, signing: pair.signing, encryption: pair.encryption }); + } + return { own, peers }; } /** The agent's debug state, or the default when it did not say it properly. */ @@ -536,6 +631,11 @@ export function parseNodeRuntimeReport(value: unknown): NodeRuntimeReport | null ) { return null; } + // Lenient like `logging`, strict inside: a present-but-malformed block + // drops the BLOCK (no trust card), never the runtime report beside it + // (paths, supervision, the verbs). A valid one passes through narrowed. + const sshFingerprint = + value.sshFingerprint === undefined ? null : parseNodeSshFingerprintReport(value.sshFingerprint); return { startedAt: value.startedAt, supervised: value.supervised, @@ -562,6 +662,7 @@ export function parseNodeRuntimeReport(value: unknown): NodeRuntimeReport | null logHint: value.logHint, tmuxPath: value.tmuxPath, binaryPath: value.binaryPath, + ...(sshFingerprint ? { sshFingerprint } : {}), }; } From 5f352fcb202846009cb1e85ce83bc647fee4404c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:17:01 -0700 Subject: [PATCH 131/170] =?UTF-8?q?feat(ssh-relay):=20agent=20carries=20th?= =?UTF-8?q?e=20=C2=A74.6=20trust=20block=20in=20ready=20and=20on=20its=20l?= =?UTF-8?q?oopback=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/node/agent/src/__tests__/daemon.test.ts | 71 +++++++++++++++++ .../src/__tests__/machine-pin-store.test.ts | 24 ++++++ .../__tests__/ssh-fingerprint-report.test.ts | 79 +++++++++++++++++++ apps/node/agent/src/daemon.ts | 31 +++++++- .../src/dashboard/__tests__/dashboard.test.ts | 44 ++++++++++- apps/node/agent/src/dashboard/view.ts | 11 +++ apps/node/agent/src/machine-pin-store.ts | 14 ++++ apps/node/agent/src/ssh-fingerprint-report.ts | 52 ++++++++++++ 8 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts create mode 100644 apps/node/agent/src/ssh-fingerprint-report.ts diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 504c6f29d..ae43fd39c 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -16,6 +16,7 @@ import { join, resolve } from "node:path"; import type { TmuxRunner } from "@internal/pane-runtime"; import { type ControlKeyPair, + fingerprintJwk, generateControlKeys, HARNESS_BINARY_PLACEHOLDER, NODE_CLOSE_HANDSHAKE_REQUIRED, @@ -51,6 +52,7 @@ import { wsUrlFor, } from "../daemon.js"; import { type DaemonLock, lockPath } from "../lock.js"; +import { MachinePinStore } from "../machine-pin-store.js"; import { maintenancePath, writeMaintenance } from "../maintenance.js"; import { sweepIsScheduled } from "../retention-settings.js"; import { SubshellMetaStore } from "../subshell-meta.js"; @@ -2234,6 +2236,75 @@ describe("restart command (spec 2026-09-12 § 6.3)", () => { expect(h.plane.unparsed).toEqual([]); }); + /** + * A public EC P-256 JWK whose coordinates are 32 bytes of zero and of four: + * canonical unpadded base64url, so `fingerprintJwk` digests it, and no key + * VALUE the relay could seal to. Pin fixtures for the §4.6 block. + */ + const peerJwk = `{"kty":"EC","crv":"P-256","x":"${"A".repeat(43)}","y":"${"E".repeat(43)}"}`; + const peerJwk2 = `{"kty":"EC","crv":"P-256","x":"${"E".repeat(43)}","y":"${"A".repeat(43)}"}`; + + test("ready carries the ssh trust block computed from this machine's key files (spec §4.6)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-")); + daemonDirs.push(dir); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { + signing: peerJwk, + encryption: peerJwk2, + }); + const h = await startDaemon({ runtime: supervised, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + const block = ready.runtime?.sshFingerprint; + expect(block).toBeDefined(); + // Own halves come from the identity files the daemon just generated, the + // peer half from the pin file seeded above, all through `fingerprintJwk`. + const sig = (JSON.parse(readFileSync(join(dir, "node-signing-identity.json"), "utf8")) as { publicJwk: string }) + .publicJwk; + const enc = (JSON.parse(readFileSync(join(dir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + expect(block?.own).toEqual({ signing: await fingerprintJwk(sig), encryption: await fingerprintJwk(enc) }); + expect(block?.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(peerJwk), + encryption: await fingerprintJwk(peerJwk2), + }, + ]); + // And the frame SURVIVES the real parser on the way in (the fake plane + // parses every outbound frame with `parseNodeEvent`): a block the plane's + // grammar would drop is a block that never reaches the trust card. + expect(h.plane.unparsed).toEqual([]); + }); + + test("no runtime report means no trust block and no key reads (the block rides the runtime report)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-null-")); + daemonDirs.push(dir); + const h = await startDaemon({ runtime: null, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeUndefined(); + // The builder never ran: a null runtime must not mint the node's keypair + // or touch the pin file just to prove the block is absent. + expect(existsSync(join(dir, "identity.json"))).toBe(false); + expect(existsSync(join(dir, "ssh-machine-pins.json"))).toBe(false); + }); + + test("an unreadable pin file costs the block, never the connection", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-corrupt-")); + daemonDirs.push(dir); + writeFileSync(join(dir, "ssh-machine-pins.json"), "}{"); + const h = await startDaemon({ runtime: supervised, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeDefined(); + expect( + ready.runtime && "sshFingerprint" in ready.runtime ? ready.runtime.sshFingerprint : undefined, + ).toBeUndefined(); + expect(h.plane.unparsed).toEqual([]); + }); + // The ORDER is the contract: the daemon is the only sender of `result`, so // an executor that exited itself would leave the plane waiting out a // timeout instead of seeing a success. diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts index 434acecd6..b2c4ebe17 100644 --- a/apps/node/agent/src/__tests__/machine-pin-store.test.ts +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -163,6 +163,30 @@ test("SUBSHELL_CHANNEL_PIN has NO effect on this store: it is always strict", () } }); +test("entries() lists every pinned peer, id ascending (the §4.6 report's source)", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + // Empty store: the honest "no peers yet", not a missing method. + expect(store.entries()).toEqual([]); + // Insertion order deliberately unsorted; the list comes back sorted by id + // so the reported block (and its plane mirror) does not reshuffle on a + // re-pair of one peer. + store.pin("node-2", keyB); + store.pin("node-1", keyA); + expect(store.entries()).toEqual([ + { nodeId: "node-1", pin: keyA }, + { nodeId: "node-2", pin: keyB }, + ]); + // An entry's pin is a copy of the stored pair, not a live handle into a + // later re-pin of the same peer. + const [first] = store.entries(); + first.pin.signing = "mutated"; + expect(store.get("node-1")).toEqual(keyA); + // Fail-closed like every other read: the corrupt file quarantines and throws. + writeFileSync(machinePinPath(dir), "["); + expect(() => store.entries()).toThrow(); +}); + test("the source never consults SUBSHELL_CHANNEL_PIN (code, not prose, is scanned)", () => { // The header comment NAMES the env var to say the store never reads it; the // load-bearing fact is that no CODE expression touches it. Comments stripped diff --git a/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts new file mode 100644 index 000000000..d198a8d1c --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { base64UrlNoPad } from "@internal/subshell-protocol"; +import { identityPath, signingIdentityPath } from "../identity.js"; +import { MachinePinStore, machinePinPath } from "../machine-pin-store.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; + +/** + * The §4.6 trust block the node reports from its OWN files (spec 2026-10-08). + * The expected digests are derived through node:crypto here, independently of + * `fingerprintJwk` (whose DER preimage is pinned byte-identical to node:crypto's + * own encoder in the protocol package): the composition pins WHICH key lands + * in WHICH slot, not the hash. + */ + +/** node:crypto's independent digest of a public JWK string, unpadded base64url. */ +function nodeFp(jwkJson: string): string { + const key = createPublicKey({ key: JSON.parse(jwkJson), format: "jwk" }); + const spki = key.export({ type: "spki", format: "der" }); + return base64UrlNoPad(new Uint8Array(createHash("sha256").update(spki).digest())); +} + +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-sshfp-")); +} + +/** The public half stored in one keypair file. */ +function publicJwkIn(file: string): string { + return (JSON.parse(readFileSync(file, "utf8")) as { publicJwk: string }).publicJwk; +} + +test("own comes from the identity files: signing from node-signing-identity.json, encryption from identity.json", async () => { + const dir = freshDir(); + const report = await buildSshFingerprintReport(dir); + // First run generates through the ordinary loadOrCreateIdentity path, and + // the report then reads back exactly what the files hold. + expect(report.own.encryption).toBe(`SHA256:${nodeFp(publicJwkIn(identityPath(dir)))}`); + expect(report.own.signing).toBe(`SHA256:${nodeFp(publicJwkIn(signingIdentityPath(dir)))}`); + expect(report.peers).toEqual([]); + // The builder is a READ path: it never mints the pin file just to say "empty". + expect(existsSync(machinePinPath(dir))).toBe(false); +}); + +test("peers come from the machine pin store, sorted by nodeId, both halves fingerprinted", async () => { + const dir = freshDir(); + const a = freshJwk(); + const b = freshJwk(); + // Insertion order is deliberately reversed against the sorted output. + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000002", { signing: b, encryption: a }); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const report = await buildSshFingerprintReport(dir); + expect(report.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: `SHA256:${nodeFp(a)}`, + encryption: `SHA256:${nodeFp(b)}`, + }, + { + nodeId: "f0000000-0000-4000-8000-000000000002", + signing: `SHA256:${nodeFp(b)}`, + encryption: `SHA256:${nodeFp(a)}`, + }, + ]); +}); + +test("a corrupt pin file throws (fail closed, the store's doctrine), and the report never invents a peer set", async () => { + const dir = freshDir(); + writeFileSync(machinePinPath(dir), "}{ not json"); + await expect(buildSshFingerprintReport(dir)).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a7f14c663..070f40789 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -45,6 +45,7 @@ import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; import { sweepOrphanSshDirs } from "./ssh-dir-retention.js"; +import { buildSshFingerprintReport } from "./ssh-fingerprint-report.js"; import { SubshellMetaStore } from "./subshell-meta.js"; import { completeUpdate, revertAfterRefusal } from "./update.js"; import { NODE_VERSION } from "./version.js"; @@ -454,6 +455,30 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // degrades to null — the `ready` simply carries no `runtime`. const runtime = deps.runtime === undefined ? await collectRuntime().catch(() => null) : deps.runtime; + // The `ready` frame's runtime, with the §4.6 trust block merged in when + // this machine can state it. Recomputed PER DIAL, unlike `runtime` itself: + // the process facts cannot change while the pid does not, but peers get + // paired (and re-paired) during a process's life, and a trust block frozen + // at boot would under-report the pin store the whole comparison is about. + let sshRuntime: NodeRuntimeReport | null = runtime; + + /** + * The runtime report plus this machine's live trust block, or the bare + * report when the block cannot be built. A failure is logged once per dial + * and never costs the connection: a machine whose pin file is corrupt still + * comes online and keeps launching; only its trust card stays empty, on the + * plane and on its own dashboard alike. + */ + async function withSshFingerprint(report: NodeRuntimeReport | null): Promise { + if (report === null) return null; // no runtime report to ride: no block, no key reads + try { + return { ...report, sshFingerprint: await buildSshFingerprintReport(config.dataDir) }; + } catch (err) { + log(`ready: ssh trust block unavailable: ${err instanceof Error ? err.message : String(err)}`); + return report; + } + } + // PER-PROCESS lifetimes (mixing these up is a security bug — see VerifyContext in node-signing): const jtiLru = new JtiLru(); // survives every reconnect: a replayed jti never gets a second evaluation const seqTracker = new SeqTracker(); // per-CONNECTION value; reset on every open below @@ -1070,7 +1095,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The SSH mirror rides the same synchronous read + seed as the // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and // the memo that stops the first heartbeat repeating it are one read. - send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); + send(ws, readyEvent(config, sshRuntime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1198,6 +1223,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // backoff sleep exits 0 here — the first Ctrl-C never waits out a sleep or a dial. if (shuttingDown) stop(0); await provisioning; // §5: R7's provisioning close must not redial past its own key write + // Fresh block per dial, BEFORE the socket: `ready` is built synchronously + // in the open turn (the arm() comment argues the posture), so this is + // the last moment anything may await. + sshRuntime = await withSshFingerprint(runtime); const close = await runConnection(); if (shuttingDown) stop(0); // graceful: the socket closed cleanly on our request if (close.code === NODE_CLOSE_SUPERSEDED) { diff --git a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts index 76ad51257..658952d0e 100644 --- a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts +++ b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts @@ -1,9 +1,12 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fingerprintJwk } from "@internal/subshell-protocol"; import type { UpdateExecContext } from "../../commands/update.js"; import { type NodeConfig, saveConfig } from "../../config.js"; +import { MachinePinStore } from "../../machine-pin-store.js"; import { noteSweepScheduled } from "../../retention-settings.js"; import { newHome } from "../../test-preload.js"; import { NODE_VERSION } from "../../version.js"; @@ -11,6 +14,12 @@ import { refuseRequest } from "../guards.js"; import { buildRoutes } from "../routes.js"; import { getDaemonState, registerRestart, setDaemonState } from "../state.js"; +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + /** * The dashboard's routes against a throwaway enrolled config — the contract * the extracted `@internal/node-admin` cards fetch, pinned HERE because the @@ -94,6 +103,39 @@ test("GET /api/nodes/:id answers the local view — online, owner-managed, serve expect(v.runningSubshells).toBe(0); }); +test("the view carries the §4.6 trust block from the node-local store (spec 2026-10-08 §4.6)", async () => { + const cfg = await enrolled(); + const a = freshJwk(); + const b = freshJwk(); + new MachinePinStore(cfg.dataDir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const v = await (await call(cfg, "GET", "/api/nodes/self")).json(); + expect(v.sshTrust.stale).toBe(false); // the machine answering about ITSELF is never stale + expect(v.sshTrust.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(a), + encryption: await fingerprintJwk(b), + }, + ]); + // Own halves from this machine's identity files, not from anything a plane said. + const enc = (JSON.parse(readFileSync(join(cfg.dataDir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + const sig = ( + JSON.parse(readFileSync(join(cfg.dataDir, "node-signing-identity.json"), "utf8")) as { publicJwk: string } + ).publicJwk; + expect(v.sshTrust.own.encryption).toBe(await fingerprintJwk(enc)); + expect(v.sshTrust.own.signing).toBe(await fingerprintJwk(sig)); +}); + +test("a corrupt pin file costs the trust field, never the page", async () => { + const cfg = await enrolled(); + writeFileSync(join(cfg.dataDir, "ssh-machine-pins.json"), "}{"); + const r = await call(cfg, "GET", "/api/nodes/self"); + expect(r.status).toBe(200); + const v = await r.json(); + expect("sshTrust" in v).toBe(false); // absent, not a broken half-block + expect(v.id).toBe("node-abc"); // the rest of the view renders +}); + test("the maintenance flip round-trips through the real mirror, and `off` carries stopped: []", async () => { const cfg = await enrolled(); // `on` with no panes running is the clean case the CLI answers identically. diff --git a/apps/node/agent/src/dashboard/view.ts b/apps/node/agent/src/dashboard/view.ts index 89b52fa69..401ef4f1c 100644 --- a/apps/node/agent/src/dashboard/view.ts +++ b/apps/node/agent/src/dashboard/view.ts @@ -6,6 +6,7 @@ import type { NodeConfig } from "../config.js"; import { mapOs } from "../enroll.js"; import { readMaintenance } from "../maintenance.js"; import { collectRuntime } from "../runtime.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; import { SubshellMetaStore } from "../subshell-meta.js"; import { NODE_VERSION } from "../version.js"; import { getDaemonState } from "./state.js"; @@ -109,6 +110,15 @@ export async function liveSubshellCount(dataDir: string): Promise { export async function buildLocalNodeView(cfg: NodeConfig) { const runtime = await runtimeForView(); const m = maintenanceFields(cfg.dataDir); + // The §4.6 trust block from the node-local store (identity files plus + // `ssh-machine-pins.json`), the plane-independent side of the out-of-band + // compare. `stale: false` by definition: this page is the machine answering + // about itself, never a mirror of a departed connection. An unreadable pin + // set costs the FIELD (the card renders nothing), never the page, and the + // store's quarantine is the operator's repair. + const sshTrust = await buildSshFingerprintReport(cfg.dataDir) + .then((block) => ({ ...block, stale: false })) + .catch(() => null); // The `ready` frame's capability list, restated for the view so the card // shows what the plane is shown. const capabilities = ["uploads", "mcp"]; @@ -141,6 +151,7 @@ export async function buildLocalNodeView(cfg: NodeConfig) { held: null, serverUrl: cfg.serverUrl, runtime, + ...(sshTrust ? { sshTrust } : {}), runningSubshells: await liveSubshellCount(cfg.dataDir), }; } diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts index b3893564b..e5bda717a 100644 --- a/apps/node/agent/src/machine-pin-store.ts +++ b/apps/node/agent/src/machine-pin-store.ts @@ -93,6 +93,20 @@ export class MachinePinStore { this.saveAll(all); } + /** + * Every pinned peer as `{ nodeId, pin }` pairs, id ascending: the source of + * the §4.6 trust block the `ready` report and the loopback dashboard render + * (fingerprints are computed from these raw strings by the caller, never + * stored). The empty array is the honest "no peers yet"; a corrupt file + * throws like every other read, it never lists as empty. + */ + entries(): { nodeId: string; pin: MachinePin }[] { + const all = this.loadAll(); + return Object.keys(all) + .sort() + .map((nodeId) => ({ nodeId, pin: { signing: all[nodeId].signing, encryption: all[nodeId].encryption } })); + } + /** * Compare a candidate against the stored pin, BYTE-FOR-BYTE on both raw * public strings (fingerprints are display, not trust). Any difference in diff --git a/apps/node/agent/src/ssh-fingerprint-report.ts b/apps/node/agent/src/ssh-fingerprint-report.ts new file mode 100644 index 000000000..ecff708d9 --- /dev/null +++ b/apps/node/agent/src/ssh-fingerprint-report.ts @@ -0,0 +1,52 @@ +/** + * The §4.6 SSH trust block, computed from THIS machine's own files (spec + * 2026-10-08 §4.5/§4.6): both fingerprints of the node's own keypair (from + * `identity.ts`) and both fingerprints of every relay peer pinned in + * `machine-pin-store.ts`. + * + * This is the out-of-band check's node half: an operator compares what one + * machine prints here against what the PEER computes from its own private + * keys, on the two machines' own surfaces, and the only agreement that counts + * is byte equality of the `SHA256:` strings. Fingerprints are the DISPLAY + * form; the store next door still enforces the raw JWK strings. + * + * The fingerprints come from `fingerprintJwk` (canonical SHA-256 over the DER + * SubjectPublicKeyInfo, JWK member order cannot move it) - never a re-derive: + * a second hash rule would let two honest machines disagree about the same + * key, which is the exact failure this surface exists to catch. + * + * THROWING BY CONTRACT: an unreadable pin file or an unusable identity is + * fail-closed noise, not an empty block, and every caller decides what the + * absence costs (the `ready` carriage drops the block and says nothing; the + * dashboard card simply has nothing to render). + */ +import { + fingerprintJwk, + type NodeSshFingerprintReport, + type NodeSshPeerFingerprint, +} from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "./identity.js"; +import { MachinePinStore } from "./machine-pin-store.js"; + +/** + * Build the trust block from the node's data dir: own halves from the two + * identity files (generating a pre-M2 node's signing pair through the + * ordinary first-run path), peers from `ssh-machine-pins.json`, id ascending. + * @param dataDir - the agent's configured data dir (`loadConfig()` value) + */ +export async function buildSshFingerprintReport(dataDir: string): Promise { + const identity = await loadOrCreateIdentity(dataDir); + const own = { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }; + const peers: NodeSshPeerFingerprint[] = []; + for (const { nodeId, pin } of new MachinePinStore(dataDir).entries()) { + peers.push({ + nodeId, + signing: await fingerprintJwk(pin.signing), + encryption: await fingerprintJwk(pin.encryption), + }); + } + return { own, peers }; +} From 6a56de1368b59ec53b61f2508ccde147c8cde569 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:19:59 -0700 Subject: [PATCH 132/170] feat(ssh-relay): mirror the trust block at ready, serialize it under the runtime gate, clear the signing slot on rotation --- .../nodes/__tests__/nodes-crud-route.test.ts | 66 +++++++++++++++ .../__tests__/service-node.route.test.ts | 82 +++++++++++++++++++ .../api/src/api/nodes/get-node.route.ts | 10 ++- apps/server/api/src/api/nodes/node-view.ts | 74 +++++++++++++++++ .../src/api/nodes/rotate-node-key.route.ts | 20 ++++- .../src/db/repositories/nodes.repository.ts | 20 +++++ .../nodes/__tests__/node-ws-handler.test.ts | 64 +++++++++++++++ .../api/src/services/nodes/node-ws-handler.ts | 13 ++- 8 files changed, 346 insertions(+), 3 deletions(-) diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 6cc555bd3..16729f00a 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; +import { exportJWK, generateKeyPair } from "jose"; import { nodesRoutes } from "@/api/nodes/index.js"; import { authDatabase } from "@/auth/database.js"; import { getAuth } from "@/auth.js"; import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -21,6 +23,7 @@ import { } from "@/services/nodes/node-registry.js"; import { NodeRpcError } from "@/services/nodes/node-rpc.js"; import { ensureLocalNode, localPlatform } from "@/services/nodes/seed-local.js"; +import { deliverSigningKey } from "@/services/nodes/ssh-identity.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; @@ -98,6 +101,7 @@ describe("/api/nodes registry CRUD", () => { const app = new Elysia().use(errorHandlerPlugin).use(nodesRoutes); const nodes = new NodesRepository(db); const nodeShares = new NodeSharesRepository(db); + const identities = new IdentitiesRepository(db); let aliceCookie = ""; let bobCookie = ""; @@ -670,6 +674,68 @@ describe("/api/nodes registry CRUD", () => { resetNodeRegistryForTests(); }); + it("rotate: clears the signing slot too, and the SAME-bytes redial re-files it (no block, no peer re-pair)", async () => { + // Spec §13's rotation units, plane side. Rotation replaces the BEARER and + // everything entrusted under its link, and it clears BOTH machine-key + // slots: the X25519 link pin (previous case) and the ES256 signing slot + // here. What makes this safe rather than destructive is that the machine + // keys themselves never moved: the redial's §4.3 bootstrap answers with + // the SAME bytes (a quiet re-file, not a refusal), and the node's own + // peer pins are untouched by anything the plane does, so the relay keeps + // byte-equality and no peer has to re-pair. The fingerprint MIRROR also + // survives: the machine's reported trust is about keys that did not move. + const n = await mkNode(aliceId, `rot-sign-${crypto.randomUUID().slice(0, 8)}`); + const { publicKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + const signingJwk = JSON.stringify(await exportJWK(publicKey)); + await identities.register({ + principalId: `node:${n.id}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${"B".repeat(43)}","y":"${"A".repeat(43)}"}`, + signingPublicKey: signingJwk, + displayName: null, + }); + const block = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + await nodes.setEncryptPublicKey(n.id, "AAECzaWRtZXgtcHVibGljLWtleQ"); + await nodes.setSshFingerprint(n.id, JSON.stringify(block)); + + const sock = fakeSocket(); + attachConnection(n.id, sock); + try { + expect((await req("POST", `/${n.id}/rotate-key`, { cookie: aliceCookie })).status).toBe(200); + + // Cleared: the link pin and the signing slot. Preserved: the mirror + // (both halves and the peer entries, byte for byte). + const row = await nodes.findById(n.id); + expect(row?.encryptPublicKey).toBeNull(); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBeNull(); + expect(row?.sshFingerprint).toBe(JSON.stringify(block)); + + // The redial's §4.3 delivery, SAME bytes into the now-empty slot: stored + // quietly (a DIFFERENT key here would be refused against a filled slot, + // and the cleared slot plus the unchanged disk identity make that + // branch unreachable for an honest agent). + expect(await deliverSigningKey(n.id, signingJwk)).toBe(signingJwk); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBe(signingJwk); + + // And the redial's `ready` re-reports the SAME block: the mirror moves + // to a byte-identical string. The peer entry the plane still mirrors + // proves no peer was re-paired: same ids, same digests, untouched. + await nodes.setSshFingerprint(n.id, JSON.stringify(block)); + expect((await nodes.findById(n.id))?.sshFingerprint).toBe(JSON.stringify(block)); + } finally { + await db.deleteFrom("identities").where("principalId", "=", `node:${n.id}`).execute(); + resetNodeRegistryForTests(); + } + }); + it("rotate: view-grantee 403, admin on an agent node 403", async () => { const n = await mkNode(aliceId, `rot-gate-${crypto.randomUUID().slice(0, 8)}`); await nodeShares.replaceForNode(n.id, [{ granteeUserId: bobId, permission: "edit" }], aliceId); diff --git a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts index 674ab47b4..84674d7dd 100644 --- a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts @@ -456,4 +456,86 @@ describe("/api/nodes service + runtime", () => { resetNodeRegistryForTests(); } }); + + // ── GET /api/nodes/:id sshTrust (spec 2026-10-08 §4.6) ────────────────── + + /** A canonical trust block: `SHA256:` + 43 unpadded base64url chars each. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + + it("sshTrust serializes under the runtime gate: live for owner/edit, never for view", async () => { + const id = await mkNode(); + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "view" }], aliceId); + // The `view` grantee is refused even with a MIRROR on the row: the gate is + // about WHO, not whether the node is live (spec §4.6/§6). + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const offline = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in offline).toBe(false); + goOnline(id, { ...runtime, sshFingerprint: TRUST_BLOCK }); + try { + const asOwner = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asOwner.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); + const asViewer = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in asViewer).toBe(false); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("an offline agent shows the mirrored block STALE-marked, never as live", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + // The mirror IS the card's content offline, and it says so: `stale: true` + // is what separates "this machine last said" from "this socket says". + expect(view.sshTrust).toEqual({ ...TRUST_BLOCK, stale: true }); + }); + + it("an online agent that reports no block falls back to the mirror, stale", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + goOnline(id, runtime); // live connection, runtime WITHOUT a trust block + try { + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { stale: boolean }; + }; + expect(view.sshTrust?.stale).toBe(true); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("sshTrust is never serialized for `local`, not even with a mirror set on the row", async () => { + // The control-plane host runs no relay agent and reports no block; a + // hand-set column must still not reach a view. `alice` holds the seeded + // Everyone/`edit` grant on `local`, so this is the config-capable case. + await nodes.setSshFingerprint("local", JSON.stringify(TRUST_BLOCK)); + try { + const view = (await (await req("GET", "/api/nodes/local", { cookie: aliceCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in view).toBe(false); + } finally { + await nodes.setSshFingerprint("local", null); + } + }); }); diff --git a/apps/server/api/src/api/nodes/get-node.route.ts b/apps/server/api/src/api/nodes/get-node.route.ts index 24216b601..f2cf1f599 100644 --- a/apps/server/api/src/api/nodes/get-node.route.ts +++ b/apps/server/api/src/api/nodes/get-node.route.ts @@ -2,7 +2,7 @@ import { BackendErrorCodes } from "@internal/backend-errors"; import { Elysia } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { loadNodeGate } from "@/api/nodes/node-gate.js"; -import { GetNodeResponseSchema, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; +import { GetNodeResponseSchema, sshTrustView, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { apiErrorBody } from "@/lib/api-error.js"; @@ -48,6 +48,13 @@ export const getNodeRoute = new Elysia() // stale by definition. `local` reports nothing: the control-plane host's // own deployment is the Service page's subject, not a node's. const runtime = gate.row.kind === "agent" ? getLive(gate.row.id)?.agent?.runtime : undefined; + // The §4.6 trust block, on the SAME config-capable gate as the two + // fields around it and for the same reason: the fingerprints name which + // machines this one has paired with, which is machine-relationship + // disclosure, not a launch need (spec 2026-10-08 §4.6/§6). Live report + // when the socket has one; the durable mirror, STALE-marked, otherwise; + // never `local`, never a `view` grantee. + const sshTrust = sshTrustView(gate.row, runtime?.sshFingerprint); // What entering maintenance would cost, on a NARROWER gate than the two // fields above (spec 2026-09-14 §5.5): `canManage`, not // `nodeCanConfigure`. Only a manager can flip that switch, so only a @@ -61,6 +68,7 @@ export const getNodeRoute = new Elysia() ...view, shares: await toNodeShareViews(gate.shares), ...(runtime ? { runtime } : {}), + ...(sshTrust ? { sshTrust } : {}), ...(runningSubshells === undefined ? {} : { runningSubshells }), }; }, diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index b199f2ad0..f69cb8155 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -1,3 +1,4 @@ +import { type NodeSshFingerprintReport, parseNodeSshFingerprintReport } from "@internal/subshell-protocol"; import { type Static, t } from "elysia"; import { db } from "@/db/index.js"; import { NodeAllowedDirsRepository } from "@/db/repositories/node-allowed-dirs.repository.js"; @@ -266,6 +267,40 @@ export const NodeRuntimeSchema = t.Object({ binaryPath: t.String({ description: "The node binary this process re-enters" }), }); +/** One machine's public relay halves as `SHA256:` fingerprints (spec 2026-10-08 §4.6). */ +const SshFingerprintPairSchema = t.Object({ + signing: t.String({ description: "Fingerprint of the ES256 machine-signing public key" }), + encryption: t.String({ description: "Fingerprint of the ECDH-ES encryption public key" }), +}); + +/** One pinned relay peer of that machine, named by node id. */ +const SshFingerprintPeerSchema = t.Object({ + nodeId: t.String({ description: "The pinned peer's node id" }), + signing: t.String({ description: "Fingerprint of that peer's signing key, as this machine pins it" }), + encryption: t.String({ description: "Fingerprint of that peer's encryption key, as this machine pins it" }), +}); + +/** + * The §4.6 trust card's payload: the protocol's fingerprint block plus ONE + * derived flag the node itself cannot honestly state about a future read. + * + * The fingerprints are public identifiers (display of the trust the node + * enforces as byte equality), never key material: they serialize under the + * SAME gate as the live `runtime` block they mostly come from (config-capable + * viewer, agent node), because a `view` grantee's business is launching a + * pane here, not auditing which machines this one has paired with. + */ +export const SshTrustSchema = t.Object({ + own: SshFingerprintPairSchema, + peers: t.Array(SshFingerprintPeerSchema, { + description: "Every relay peer this machine pins, id ascending; empty means none paired yet", + }), + stale: t.Boolean({ + description: + "true = the plane's durable MIRROR of this machine's last report (offline node, or one that reported no block), so the card is last-known truth, not current; false = what the live connection reported this connect", + }), +}); + /** `GET /api/nodes/:id` — the view plus the grant set, ONLY when the viewer can configure. */ export const GetNodeResponseSchema = t.Object({ ...NodeViewSchema.properties, @@ -276,6 +311,12 @@ export const GetNodeResponseSchema = t.Object({ "How the node runs, present only while the node is online, only for config-capable viewers, and only on machine nodes", }), ), + sshTrust: t.Optional( + t.Object(SshTrustSchema.properties, { + description: + "This machine's SSH trust block (own + pinned-peer fingerprints, spec 2026-10-08 §4.6): the live report's when the node is connected, else the durable mirror with `stale: true`. Same gate as `runtime`: config-capable viewers on agent nodes only, never `view`, never `local`", + }), + ), runningSubshells: t.Optional( t.Number({ description: @@ -324,6 +365,39 @@ function parseCapabilities(json: string | null): string[] { } } +/** The stored `nodes.ssh_fingerprint` JSON → block, or null. Never throws. */ +function readMirrorBlock(json: string | null): NodeSshFingerprintReport | null { + if (!json) return null; + try { + return parseNodeSshFingerprintReport(JSON.parse(json)); + } catch { + // A hand-edited or half-written column costs the trust card, never the + // node page: the view renders without the block rather than 500-ing on + // someone's stray bytes. (The same grammar the live report went through + // at ingest; re-running it here refuses whatever the column is not.) + return null; + } +} + +/** + * The detail view's `sshTrust` (spec 2026-10-08 §4.6), under the caller's + * already-checked config gate. The live connection's block wins when it has + * one (`stale: false`); otherwise the durable mirror renders marked + * (`stale: true`), because the offline card an operator compares during an + * incident is worth its last-known truth and the word that says so. `local` + * answers null at the call site's kind gate, and an absent mirror/absent + * live block means the field is ABSENT on the view, not null. + */ +export function sshTrustView( + row: NodeTable, + liveBlock: NodeSshFingerprintReport | undefined, +): Static | null { + if (row.kind !== "agent") return null; + if (liveBlock) return { ...liveBlock, stale: false }; + const mirror = readMirrorBlock(row.sshFingerprint); + return mirror ? { ...mirror, stale: true } : null; +} + /** * Everything but the harness merge — the one place row→view fields are mapped. * `local`'s os/arch fall back to this process (see the inline note) — the only diff --git a/apps/server/api/src/api/nodes/rotate-node-key.route.ts b/apps/server/api/src/api/nodes/rotate-node-key.route.ts index e2aae89f3..c6289a378 100644 --- a/apps/server/api/src/api/nodes/rotate-node-key.route.ts +++ b/apps/server/api/src/api/nodes/rotate-node-key.route.ts @@ -58,7 +58,8 @@ export const rotateNodeKeyRoute = new Elysia() }, })) as unknown as CreatedApiKey; - // 2. Flip the binding, clear the link pin, THEN 3. disable the old key. + // 2. Flip the binding, clear the link pin (and 2b, the signing slot), + // THEN 3. disable the old key. // `encrypt_public_key` goes to NULL with the bearer it was trusted // under (spec 2026-09-24 §5): the row falls back to legacy mode and // the agent's redial re-provisions a fresh encryption identity @@ -79,6 +80,23 @@ export const rotateNodeKeyRoute = new Elysia() .where("targetNodeId", "=", gate.row.id) .where("usedAt", "is", null) .execute(); + // 2b. The ES256 signing slot goes with the link pin (spec 2026-10-08 + // §13): it was filed under the OLD bearer's trust, and rotation + // is the operator saying that trust ends now. This clears the + // PLANE's record, never the machine's key: the redial's §4.3 + // bootstrap answers `ssh_register_identity` from the disk pair, + // the empty slot takes the SAME bytes back quietly, the node's + // own peer pins are untouched by anything the plane does, and + // `nodes.ssh_fingerprint` (the trust card's mirror) deliberately + // SURVIVES, because the fingerprints it holds are digests of + // keys that did not move. A machine that reports a DIFFERENT + // key into the refilled slot has rotated its identity without + // saying so, which §4.3's guard will keep refusing. + await tx + .updateTable("identities") + .set({ signingPublicKey: null }) + .where("principalId", "=", `node:${gate.row.id}`) + .execute(); if (gate.row.apiKeyId) { await sql`UPDATE apikey SET enabled = 0, "updatedAt" = ${new Date().toISOString()} WHERE id = ${gate.row.apiKeyId}`.execute( tx, diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 14045c278..3fd95ca53 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -180,6 +180,26 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Mirror (or clear) this node's §4.6 SSH trust block (spec 2026-10-08 + * §4.5/§4.6): the JSON of what the machine last reported about its own and + * its pinned peers' key fingerprints, so the trust card survives an offline + * node and a plane restart alike. + * + * The `ready` ingest calls this ONLY when the report carried a well-formed + * block: a report that says nothing never clears the mirror (absence is not + * evidence the keys are gone). `null` is for callers that DO know the block + * is dead (tests today; a future machine-side identity wipe would clear it + * by re-report, not by null). + */ + async setSshFingerprint(id: string, json: string | null): Promise { + await this.db + .updateTable("nodes") + .set({ sshFingerprint: json, updatedAt: new Date().toISOString() }) + .where("id", "=", id) + .execute(); + } + /** Cache a harness inventory snapshot (spec §6.2 TTL is the caller's). */ async applyInventory(id: string, inventoryJson: string): Promise { const now = new Date().toISOString(); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index bcf725ee7..0a5ea8c69 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -102,6 +102,8 @@ interface Harness { disabledOwners: Set; ready: { id: string; report: NodeReadyReport }[]; inventories: { id: string; json: string }[]; + /** JSON trust blocks `ready` mirror writes landed (spec 2026-10-08 §4.6) */ + sshFingerprints: { id: string; json: string | null }[]; touched: string[]; statuses: { id: string; status: string }[]; /** what deps.resolveResult saw: the connection handed to it + the event */ @@ -125,6 +127,7 @@ function makeHarness(): Harness { disabledOwners: new Set(), ready: [], inventories: [], + sshFingerprints: [], touched: [], statuses: [], results: [], @@ -164,6 +167,9 @@ function makeHarness(): Harness { setStatus: async (id, status) => { h.statuses.push({ id, status }); }, + setSshFingerprint: async (id, json) => { + h.sshFingerprints.push({ id, json }); + }, } as unknown as NodeWsDeps["nodes"], resolveResult: (conn, event) => { h.results.push({ conn, event }); @@ -623,6 +629,64 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => expect(ws.closed).toHaveLength(0); }); + /** + * A complete runtime report the strict validator accepts, so the frames + * below exercise the MIRROR rules rather than the parser's floor. + */ + const runtimeWith = (extra: Record) => ({ + startedAt: "2026-10-08T00:00:00.000Z", + supervised: false, + service: { + manager: "systemd", + installed: false, + definitionPath: null, + state: "running", + pid: null, + enabled: null, + linger: null, + paneSafety: "unknown", + }, + configPath: "/c", + agentLogPath: "/c/agent.log", + logPath: null, + logHint: null, + tmuxPath: null, + binaryPath: "/b", + logging: { debug: false, source: "default" }, + ...extra, + }); + + /** The canonical §4.6 trust block shapes; one good, one the grammar refuses. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [], + }; + + it("ready mirrors the ssh trust block onto the row; no block, no write, no clear", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: TRUST_BLOCK }) })), + ); + expect(h.sshFingerprints).toEqual([{ id: "n1", json: JSON.stringify(TRUST_BLOCK) }]); + // A later ready WITHOUT a block (the agent predates M2, or the read + // failed): the mirror must survive it. Absence is not evidence the + // machine's keys are gone; only a fresh true report moves the copy. + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(h.sshFingerprints).toHaveLength(1); + // And a malformed block is dropped by the frame parser BEFORE the switch, + // so it reaches no write at all (the same frame still comes online: the + // ready itself is kept, `runtime` with it, minus the junk block). + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: { own: "junk", peers: [] } }) })), + ); + expect(h.sshFingerprints).toHaveLength(1); + expect(h.ready).toHaveLength(3); + }); + /** * Harness detection when a node comes ONLINE — the half of freshness a * person cannot supply (spec 2026-09-10 §4 as extended; the periodic other diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 1fc2ee210..1e6100280 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -185,7 +185,7 @@ export interface NodeVerifiedKey { /** Repository slice the socket touches (full `NodesRepository` satisfies it). */ export type NodeWsNodesRepo = Pick< NodesRepository, - "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" + "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" | "setSshFingerprint" >; /** Everything the handler reaches outside its own module. */ @@ -879,6 +879,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: ...(event.runtime ? { runtime: event.runtime } : {}), }; } + // Mirror the §4.6 trust block the report carried onto the row (spec + // 2026-10-08 §4.5/§4.6) — the durable copy behind the offline, + // STALE-marked card, written on the same "record FIRST" posture as the + // facts stash above and on the same surviving-socket rule (the stillLive + // probe already returned for a disowned one). A ready WITHOUT a block + // — an agent that predates M2, or one whose block the grammar refused — + // writes NOTHING: absence is not evidence the machine's keys are gone, + // and only a fresh true report may move the plane's copy. + if (event.runtime?.sshFingerprint) { + await deps.nodes.setSshFingerprint(nodeId, JSON.stringify(event.runtime.sshFingerprint)); + } // The floor FIRST, because its refusal is the one a person can act on: // it names the version to install and the version found, where a bare // protocol number names neither. Identity is already persisted above, From b6008a8df9eab22d465938ac18fd5ab64ce98f75 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:26:02 -0700 Subject: [PATCH 133/170] feat(ssh-relay): machine trust card on the node page, owner/edit only, stale-marked mirror --- .../ssh/__tests__/trust-card.test.tsx | 110 ++++++++++++++++++ .../web/src/components/ssh/trust-card.tsx | 95 +++++++++++++++ apps/server/web/src/routes/nodes_.$id.tsx | 10 ++ packages/node-admin/src/index.ts | 1 + packages/node-admin/src/types/node.ts | 42 +++++++ 5 files changed, 258 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx create mode 100644 apps/server/web/src/components/ssh/trust-card.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx b/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx new file mode 100644 index 000000000..ff23f85a7 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { cleanup, render, screen } from "@testing-library/react"; +import { SshTrustCard } from "@/components/ssh/trust-card"; + +/** + * The node page's §4.6 machine trust card (spec 2026-10-08). The card is + * pure prop rendering: the GATE lives on the server (the field is absent for + * a `view` grantee and on `local`, never null), and the card's own check is + * belt-not-control - a payload that arrived against the rule must still not + * render. The fingerprints are public display of the trust the machines + * enforce as byte equality; the card exists for the out-of-band compare + * against what the PEER machine prints about itself. + */ + +const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; +const FP_OWN_ENC = `SHA256:${"B".repeat(43)}`; +const FP_PEER_SIGN = `SHA256:${"C".repeat(43)}`; +const FP_PEER_ENC = `SHA256:${"D".repeat(43)}`; +const PEER_ID = "0f8e2c1a-0000-4000-8000-000000000001"; + +function node(over: Partial = {}): NodeDetail { + return { + id: "node1", + name: "box", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "box", + status: "online", + lastSeenAt: null, + agentVersion: "1.0.0", + protocolVersion: 18, + access: "owner", + canManage: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...over, + }; +} + +const LIVE_TRUST = { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, +}; + +afterEach(cleanup); + +describe("SshTrustCard", () => { + it("owner with a live block: own and peer fingerprints, no stale marking", () => { + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + expect(screen.getByText("Machine trust")).toBeDefined(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + expect(screen.getByText(FP_OWN_ENC)).toBeDefined(); + expect(screen.getByText(`Peer ${PEER_ID}`)).toBeDefined(); + expect(screen.getByText(FP_PEER_SIGN)).toBeDefined(); + expect(screen.getByText(FP_PEER_ENC)).toBeDefined(); + expect(screen.queryByText(/stale/i)).toBeNull(); + }); + + it("an edit grantee sees the same card the owner sees", () => { + render(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + }); + + it("the stale mirror renders STALE-marked, distinguishable from live", () => { + render(); + // The flag carries both a badge and its own sentence: the §4.6 compare is + // exactly the thing a reader must not do against a last-known value + // thinking it current. + expect(screen.getByText(/offline/i)).toBeDefined(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + }); + + it("a view grantee never gets the card, absent field or not", () => { + // The server's gate leaves the field OFF the payload for this viewer; the + // card's own kind/access check is the belt, pinned here so a future + // refactor of the route's gate cannot silently widen what this renders. + const { container } = render(); + expect(container.firstChild).toBeNull(); + const belt = render(); + expect(belt.container.firstChild).toBeNull(); + }); + + it("never on `local`, not even with a block that must not have serialized", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("no block on the view means no card (older agent, nothing reported yet)", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("an empty peer set says so plainly instead of rendering a lone header", () => { + render(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + expect(screen.getByText(/no ssh relay peers pinned yet/i)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/trust-card.tsx b/apps/server/web/src/components/ssh/trust-card.tsx new file mode 100644 index 000000000..7cb83c558 --- /dev/null +++ b/apps/server/web/src/components/ssh/trust-card.tsx @@ -0,0 +1,95 @@ +import { Badge, Card, CardContent, CardHeader, CardTitle, CopyableValue, type NodeDetail } from "@internal/node-admin"; +import type { JSX } from "react"; + +/** + * The machine trust card (spec 2026-10-08 §4.6): this machine's own SSH relay + * key fingerprints and each pinned peer's, for the out-of-band compare. + * + * The check this serves is deliberately NOT a green tick: the only honest + * reading is a person comparing these strings against what the OTHER machine + * prints about the SAME keys on its own surface (the node dashboard side, fed + * from the machine's key files). Fingerprints are public display of the trust + * the machines enforce as byte equality; nothing here is key material, and + * nothing here proves anything until both machines have been read. + * + * Gated twice over, on purpose. The server omits `sshTrust` for a `view` + * grantee and on `local` entirely (it is machine-relationship disclosure, the + * same class as the runtime block), and this component refuses the same two + * cases so a payload that arrived against the rule still renders nothing. + * + * Staleness is not the gate: an OFFLINE agent renders the durable mirror, and + * says so plainly, because comparing last-known fingerprints during an + * incident is exactly when the card earns its keep and exactly when reading a + * stale value as current would mislead. + */ +export function SshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { + if (node.kind !== "agent" || (node.access !== "owner" && node.access !== "edit")) return null; + const trust = node.sshTrust; + if (!trust) return null; + return ( + + + + Machine trust + {trust.stale && Stale} + + + +

    + {trust.stale + ? "This machine is offline. These are the last fingerprints it reported." + : "Fingerprints of this machine's relay keys and every peer it pins. Compare them against what each machine prints about the same keys itself."} +

    +
    + + + {trust.peers.length === 0 ? ( +
    +
    Pinned peers
    +
    No SSH relay peers pinned yet.
    +
    + ) : ( + trust.peers.map((peer) => ( +
    +
    Peer {peer.nodeId}
    +
    + + +
    +
    + )) + )} +
    +
    +
    + ); +} + +/** One labelled fingerprint: `label` over a mono `detail`, the line-item shape. */ +function FingerprintFact({ label, value, copyLabel }: { label: string; value: string; copyLabel: string }) { + return ( +
    +
    {label}
    +
    + +
    +
    + ); +} + +/** The value plus its copy affordance, the thing a person takes to the other machine. */ +function FingerprintValue({ value, copyLabel }: { value: string; copyLabel: string }) { + return ( + + + + ); +} diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx index 0914a8432..5693f8e4c 100644 --- a/apps/server/web/src/routes/nodes_.$id.tsx +++ b/apps/server/web/src/routes/nodes_.$id.tsx @@ -28,6 +28,7 @@ import { osLabel } from "@/components/nodes/node-row"; import { managesNodeSections } from "@/components/nodes/node-section-nav"; import { NodeSharingDialog } from "@/components/nodes/node-sharing-dialog"; import { NodeUpdateCard } from "@/components/nodes/node-update-card"; +import { SshTrustCard } from "@/components/ssh/trust-card"; import { useDeleteNode, useRenameNode } from "@/hooks/use-nodes"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; @@ -261,6 +262,15 @@ function NodeDetailPage() { + {/* The §4.6 machine trust block (spec 2026-10-08): own and pinned + peers' key fingerprints, for the out-of-band compare each side + can verify against its own machine. The server serializes the + field only for owner/edit on agent nodes, so a `view` grantee + never receives it and the card renders nothing; the card's own + kind/access check is the belt. Live report while the machine is + connected, durable mirror marked stale when it is not. */} + {managesNodeSections(n) && } + {/* The node's RULES live on its page, not in a tab of one card: the allowlist gates launches on this machine including the viewer's own, and its empty-means-unrestricted shape is diff --git a/packages/node-admin/src/index.ts b/packages/node-admin/src/index.ts index 360eb771a..eaf95b0da 100644 --- a/packages/node-admin/src/index.ts +++ b/packages/node-admin/src/index.ts @@ -88,6 +88,7 @@ export type { NodeKind, NodeRuntime, NodeShare, + NodeSshTrust, NodeStatus, RotatedNodeKey, SetupKeyRow, diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index 941035cdc..c09edd284 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -312,6 +312,37 @@ export interface NodeRuntime { tmuxPath: string | null; /** The node binary this process re-enters */ binaryPath: string; + /** + * The §4.6 trust block as THIS report stated it (spec 2026-10-08): own and + * pinned-peer key fingerprints. Absent when the agent reported none. The + * detail view's `sshTrust` is the same fact with the one flag only the + * plane can state (whether it is live or the durable mirror); the runtime + * card renders nothing from this field, the trust card reads `sshTrust`. + */ + sshFingerprint?: { + own: { signing: string; encryption: string }; + peers: { nodeId: string; signing: string; encryption: string }[]; + }; +} + +/** + * The §4.6 SSH trust card's payload (spec 2026-10-08), mirroring the plane's + * `SshTrustSchema`: this machine's own key fingerprints and every pinned + * peer's, as `SHA256:` display strings. Fingerprints are public identifiers + * of the trust the machine enforces as byte equality; the card's whole purpose + * is the out-of-band compare against what the PEER computes from its own keys. + */ +export interface NodeSshTrust { + /** This machine's both halves */ + own: { signing: string; encryption: string }; + /** Every pinned peer, id ascending; empty means nothing paired yet */ + peers: { nodeId: string; signing: string; encryption: string }[]; + /** + * true = the plane's durable MIRROR of the last report (the node is offline, + * or its live connection reported no block), so the card is last-known truth + * and says so; false = what this connection reported at connect. + */ + stale: boolean; } /** `GET /api/nodes/:id` — the view plus the grant set, ONLY for config-capable viewers (then the key is absent, not null). */ @@ -352,6 +383,17 @@ export interface NodeDetail extends Node { * supports. */ runningSubshells?: number; + /** + * The machine's SSH trust block (§4.6): its own and its pinned peers' + * fingerprints, live report when the node is connected, durable mirror + * (`stale: true`) when it is not. + * + * Present only for a config-capable viewer on an AGENT node, the same gate + * as `runtime` and for the same kind of reason: which machines this one has + * paired with is machine-relationship disclosure, not something a `view` + * grantee's launch needs. Absent, never null, for everyone else. + */ + sshTrust?: NodeSshTrust; } /** From 32b3666d1a76d57e8e2bb4f7d866ee6a7ae56da6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:38:23 -0700 Subject: [PATCH 134/170] feat(ssh-relay): render the trust card on the node's own dashboard; cover the edit grantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T13 review MINORs: the card moves into @internal/node-admin as NodeSshTrustCard so the machine's own loopback dashboard (the §4.6 compare's LOCAL half) renders it from the agent view's sshTrust, and the get-node gate test gains the missing edit-grantee case. NITs: drop the tautological rotate-test tail, isPeerNodeId calls the real isNodeSubshellId guard, the rotate comment states what the cleared slot actually does (peers' pins block a silent re-key), and the dead NodeRuntime.sshFingerprint mirror field is removed. --- .../src/routes/__tests__/status-page.test.tsx | 121 ++++++++++++++++++ apps/node/web/src/routes/index.tsx | 6 +- .../nodes/__tests__/nodes-crud-route.test.ts | 6 - .../__tests__/service-node.route.test.ts | 8 ++ .../src/api/nodes/rotate-node-key.route.ts | 11 +- apps/server/web/src/routes/nodes_.$id.tsx | 4 +- .../src/__tests__/ssh-trust-card.test.tsx | 35 ++--- .../src/components/ssh-trust-card.tsx | 26 +++- packages/node-admin/src/index.ts | 11 +- packages/node-admin/src/types/node.ts | 18 +-- packages/subshell-protocol/src/node-frames.ts | 8 +- 11 files changed, 199 insertions(+), 55 deletions(-) create mode 100644 apps/node/web/src/routes/__tests__/status-page.test.tsx rename apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx => packages/node-admin/src/__tests__/ssh-trust-card.test.tsx (68%) rename apps/server/web/src/components/ssh/trust-card.tsx => packages/node-admin/src/components/ssh-trust-card.tsx (76%) diff --git a/apps/node/web/src/routes/__tests__/status-page.test.tsx b/apps/node/web/src/routes/__tests__/status-page.test.tsx new file mode 100644 index 000000000..88423aa7a --- /dev/null +++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StatusPage } from "../index"; + +/** + * The status page is the §4.6 out-of-band compare's LOCAL half (spec + * 2026-10-08): the check happens on the two machines' OWN loopback + * dashboards, because a fingerprint read off the plane proves nothing about + * the machine behind it. So this page must render the trust card, fed from + * the agent view's own `sshTrust` (the node-local key files, `stale: false` + * by definition on the machine's own surface). + * + * The fetcher is stubbed: the page's `useNode("self")` speaks the node's + * mirror of `/api/nodes/:id`, and `NodeLogCard` polls `/logs` even though + * this suite is about the trust block, not the tail. + */ + +const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; +const FP_OWN_ENC = `SHA256:${"B".repeat(43)}`; +const FP_PEER_SIGN = `SHA256:${"C".repeat(43)}`; +const FP_PEER_ENC = `SHA256:${"D".repeat(43)}`; +const PEER_ID = "0f8e2c1a-0000-4000-8000-000000000001"; + +/** This machine answering `GET /api/nodes/self` about itself, as `view.ts` shapes it. */ +function localView(): NodeDetail { + return { + id: "self", + name: "devbox", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "devbox", + status: "online", + lastSeenAt: "2026-10-08T10:00:00.000Z", + agentVersion: "1.0.0", + protocolVersion: 18, + // The loopback view's standing answers: no viewer identity exists here, + // and the human at this machine outranks any web grant (view.ts). + access: "owner", + canManage: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: true, + held: null, + serverUrl: "http://127.0.0.1:3080", + // No `runtime`: the page then skips the Runtime/Service cards, and this + // suite stays about the trust block. The trust card needs no runtime. + sshTrust: { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, + }, + runningSubshells: 0, + }; +} + +let realFetch: typeof globalThis.fetch; + +beforeEach(() => { + realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") ? { text: "", nextByte: 0, size: 0, truncated: false } : localView(); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; +}); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +} + +describe("status page — machine trust", () => { + it("renders own and peer fingerprints from the agent view, unmarked (the local truth is never stale)", async () => { + renderPage(); + expect(await screen.findByText("Machine trust")).toBeTruthy(); + expect(screen.getByText(FP_OWN_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_OWN_ENC)).toBeTruthy(); + expect(screen.getByText(`Peer ${PEER_ID}`)).toBeTruthy(); + expect(screen.getByText(FP_PEER_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_PEER_ENC)).toBeTruthy(); + // No stale marking on the machine's own surface: this page is the local + // truth, never a mirror of a departed connection. + expect(screen.queryByText(/stale/i)).toBeNull(); + }); + + it("renders no trust card when the node's own store reported no block", async () => { + // The unreadable-pin-set case: `view.ts` costs the FIELD, never the page. + const base = localView(); + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") + ? { text: "", nextByte: 0, size: 0, truncated: false } + : { ...base, sshTrust: undefined }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + renderPage(); + await screen.findByText("This machine"); + expect(screen.queryByText("Machine trust")).toBeNull(); + }); +}); diff --git a/apps/node/web/src/routes/index.tsx b/apps/node/web/src/routes/index.tsx index 262da8462..e50b1ec42 100644 --- a/apps/node/web/src/routes/index.tsx +++ b/apps/node/web/src/routes/index.tsx @@ -6,6 +6,7 @@ import { NodeMaintenanceCard, NodeRuntimeCard, NodeServiceCard, + NodeSshTrustCard, relativeElapsed, useNode, } from "@internal/node-admin"; @@ -25,7 +26,8 @@ export const Route = createFileRoute("/")({ component: StatusPage }); * not make you click between tabs. The cards are the shared ones, unmodified — * which is the point of the extraction. */ -function StatusPage() { +/** Exported for the dashboard test; the route wires it as its component. */ +export function StatusPage() { const node = useNode("self"); if (node.isError) { @@ -77,6 +79,8 @@ function StatusPage() {

    This node did not report how it runs.

    )} + + ); diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 16729f00a..9bb87c325 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -724,12 +724,6 @@ describe("/api/nodes registry CRUD", () => { // branch unreachable for an honest agent). expect(await deliverSigningKey(n.id, signingJwk)).toBe(signingJwk); expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBe(signingJwk); - - // And the redial's `ready` re-reports the SAME block: the mirror moves - // to a byte-identical string. The peer entry the plane still mirrors - // proves no peer was re-paired: same ids, same digests, untouched. - await nodes.setSshFingerprint(n.id, JSON.stringify(block)); - expect((await nodes.findById(n.id))?.sshFingerprint).toBe(JSON.stringify(block)); } finally { await db.deleteFrom("identities").where("principalId", "=", `node:${n.id}`).execute(); resetNodeRegistryForTests(); diff --git a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts index 84674d7dd..655ecee9e 100644 --- a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts @@ -493,6 +493,14 @@ describe("/api/nodes service + runtime", () => { unknown >; expect("sshTrust" in asViewer).toBe(false); + // The named gate has THREE actors: owner (above), `view` (above), and + // the `edit` grantee, who configures this machine and so sees the same + // disclosure the owner does, live (the same gate as `runtime`). + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "edit" }], aliceId); + const asEditor = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asEditor.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); } finally { resetNodeRegistryForTests(); } diff --git a/apps/server/api/src/api/nodes/rotate-node-key.route.ts b/apps/server/api/src/api/nodes/rotate-node-key.route.ts index c6289a378..447d8114c 100644 --- a/apps/server/api/src/api/nodes/rotate-node-key.route.ts +++ b/apps/server/api/src/api/nodes/rotate-node-key.route.ts @@ -89,9 +89,14 @@ export const rotateNodeKeyRoute = new Elysia() // own peer pins are untouched by anything the plane does, and // `nodes.ssh_fingerprint` (the trust card's mirror) deliberately // SURVIVES, because the fingerprints it holds are digests of - // keys that did not move. A machine that reports a DIFFERENT - // key into the refilled slot has rotated its identity without - // saying so, which §4.3's guard will keep refusing. + // keys that did not move. What the clearing does NOT do is + // police the redial: the slot stands empty, so §4.3's + // first-fill branch takes ANY reported key quietly (its + // same-bytes refusal guards a FILLED slot, which is the steady + // state an honest agent returns the key to). A machine that + // re-files a DIFFERENT identity is blocked by its peers' own + // pins instead: the relay's byte-equality check fails it, as + // §4.6 intends. await tx .updateTable("identities") .set({ signingPublicKey: null }) diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx index 5693f8e4c..9cec92b17 100644 --- a/apps/server/web/src/routes/nodes_.$id.tsx +++ b/apps/server/web/src/routes/nodes_.$id.tsx @@ -5,6 +5,7 @@ import { errMessage, NodeAllowedDirs, NodeMaintenanceCard, + NodeSshTrustCard, relativeElapsed, useNode, } from "@internal/node-admin"; @@ -28,7 +29,6 @@ import { osLabel } from "@/components/nodes/node-row"; import { managesNodeSections } from "@/components/nodes/node-section-nav"; import { NodeSharingDialog } from "@/components/nodes/node-sharing-dialog"; import { NodeUpdateCard } from "@/components/nodes/node-update-card"; -import { SshTrustCard } from "@/components/ssh/trust-card"; import { useDeleteNode, useRenameNode } from "@/hooks/use-nodes"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; @@ -269,7 +269,7 @@ function NodeDetailPage() { never receives it and the card renders nothing; the card's own kind/access check is the belt. Live report while the machine is connected, durable mirror marked stale when it is not. */} - {managesNodeSections(n) && } + {managesNodeSections(n) && } {/* The node's RULES live on its page, not in a tab of one card: the allowlist gates launches on this machine including the diff --git a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx similarity index 68% rename from apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx rename to packages/node-admin/src/__tests__/ssh-trust-card.test.tsx index ff23f85a7..c9a033a1e 100644 --- a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx +++ b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx @@ -1,16 +1,17 @@ import { afterEach, describe, expect, it } from "bun:test"; -import type { NodeDetail } from "@internal/node-admin"; import { cleanup, render, screen } from "@testing-library/react"; -import { SshTrustCard } from "@/components/ssh/trust-card"; +import { NodeSshTrustCard } from "../components/ssh-trust-card"; +import type { NodeDetail } from "../types/node"; /** - * The node page's §4.6 machine trust card (spec 2026-10-08). The card is - * pure prop rendering: the GATE lives on the server (the field is absent for - * a `view` grantee and on `local`, never null), and the card's own check is - * belt-not-control - a payload that arrived against the rule must still not - * render. The fingerprints are public display of the trust the machines - * enforce as byte equality; the card exists for the out-of-band compare - * against what the PEER machine prints about itself. + * The §4.6 machine trust card (spec 2026-10-08), shared by the plane's node + * page and the node's own dashboard. The card is pure prop rendering: the + * GATE lives on the server (the field is absent for a `view` grantee and on + * `local`, never null), and the card's own check is belt-not-control - a + * payload that arrived against the rule must still not render. The + * fingerprints are public display of the trust the machines enforce as byte + * equality; the card exists for the out-of-band compare against what the PEER + * machine prints about itself. */ const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; @@ -57,7 +58,7 @@ afterEach(cleanup); describe("SshTrustCard", () => { it("owner with a live block: own and peer fingerprints, no stale marking", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).not.toBeNull(); expect(screen.getByText("Machine trust")).toBeDefined(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); @@ -69,12 +70,12 @@ describe("SshTrustCard", () => { }); it("an edit grantee sees the same card the owner sees", () => { - render(); + render(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); }); it("the stale mirror renders STALE-marked, distinguishable from live", () => { - render(); + render(); // The flag carries both a badge and its own sentence: the §4.6 compare is // exactly the thing a reader must not do against a last-known value // thinking it current. @@ -86,24 +87,24 @@ describe("SshTrustCard", () => { // The server's gate leaves the field OFF the payload for this viewer; the // card's own kind/access check is the belt, pinned here so a future // refactor of the route's gate cannot silently widen what this renders. - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); - const belt = render(); + const belt = render(); expect(belt.container.firstChild).toBeNull(); }); it("never on `local`, not even with a block that must not have serialized", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("no block on the view means no card (older agent, nothing reported yet)", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("an empty peer set says so plainly instead of rendering a lone header", () => { - render(); + render(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); expect(screen.getByText(/no ssh relay peers pinned yet/i)).toBeDefined(); }); diff --git a/apps/server/web/src/components/ssh/trust-card.tsx b/packages/node-admin/src/components/ssh-trust-card.tsx similarity index 76% rename from apps/server/web/src/components/ssh/trust-card.tsx rename to packages/node-admin/src/components/ssh-trust-card.tsx index 7cb83c558..8064bc8b8 100644 --- a/apps/server/web/src/components/ssh/trust-card.tsx +++ b/packages/node-admin/src/components/ssh-trust-card.tsx @@ -1,5 +1,8 @@ -import { Badge, Card, CardContent, CardHeader, CardTitle, CopyableValue, type NodeDetail } from "@internal/node-admin"; import type { JSX } from "react"; +import type { NodeDetail } from "../types/node"; +import { Badge } from "../ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { CopyableValue } from "../ui/copyable-value"; /** * The machine trust card (spec 2026-10-08 §4.6): this machine's own SSH relay @@ -7,22 +10,31 @@ import type { JSX } from "react"; * * The check this serves is deliberately NOT a green tick: the only honest * reading is a person comparing these strings against what the OTHER machine - * prints about the SAME keys on its own surface (the node dashboard side, fed - * from the machine's key files). Fingerprints are public display of the trust - * the machines enforce as byte equality; nothing here is key material, and - * nothing here proves anything until both machines have been read. + * prints about the SAME keys on its own surface. That is why this card lives + * in the shared package: the §4.6 compare happens on the TWO MACHINES' OWN + * loopback dashboards, where the page is fed from the machine's own key + * files, and the plane shows the same card as a convenience copy of what a + * connected (or last-connected) agent reported. Fingerprints are public + * display of the trust the machines enforce as byte equality; nothing here is + * key material, and nothing here proves anything until both machines have + * been read. * * Gated twice over, on purpose. The server omits `sshTrust` for a `view` * grantee and on `local` entirely (it is machine-relationship disclosure, the * same class as the runtime block), and this component refuses the same two - * cases so a payload that arrived against the rule still renders nothing. + * cases so a payload that arrived against the rule still renders nothing. On + * the node's own dashboard the view says `kind: "agent"`, `access: "owner"` + * (there is no viewer identity at loopback, and the human here holds the CLI + * that outranks any web grant), and `stale: false` by construction: this page + * is the machine answering about itself, never a mirror of a departed + * connection. * * Staleness is not the gate: an OFFLINE agent renders the durable mirror, and * says so plainly, because comparing last-known fingerprints during an * incident is exactly when the card earns its keep and exactly when reading a * stale value as current would mislead. */ -export function SshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { +export function NodeSshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { if (node.kind !== "agent" || (node.access !== "owner" && node.access !== "edit")) return null; const trust = node.sshTrust; if (!trust) return null; diff --git a/packages/node-admin/src/index.ts b/packages/node-admin/src/index.ts index eaf95b0da..ffca24926 100644 --- a/packages/node-admin/src/index.ts +++ b/packages/node-admin/src/index.ts @@ -1,11 +1,11 @@ /** * `@internal/node-admin` — the shared admin UI for operating a Subshell node. * - * One copy of the six node cards (runtime, service, log, maintenance, control - * plane, allowed directories), the hooks and types they speak, and the UI - * primitives they render, so the control plane's Nodes pages and the node's - * OWN loopback dashboard show the same machine in the same words and answer - * to the same `/api/nodes/:id/*` contract from two backends. + * One copy of the node cards (runtime, service, log, maintenance, control + * plane, allowed directories, machine trust), the hooks and types they speak, + * and the UI primitives they render, so the control plane's Nodes pages and + * the node's OWN loopback dashboard show the same machine in the same words + * and answer to the same `/api/nodes/:id/*` contract from two backends. * * ## The relicensing this package is * @@ -41,6 +41,7 @@ export { NodeMaintenanceCard } from "./components/node-maintenance-card"; export { NodeRuntimeCard, supervisionLine } from "./components/node-runtime-card"; export { NodeServerUrlCard } from "./components/node-server-url-card"; export { NodeServiceCard } from "./components/node-service-card"; +export { NodeSshTrustCard } from "./components/ssh-trust-card"; export { nodeDetailQuery, useNode, diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index c09edd284..fd7a0927c 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -251,6 +251,13 @@ export interface NodeShare { * Facts about a process, never about the machine: the control plane holds * the report on the live socket and drops it when that goes, so a value here * is always current or absent. + * + * One deliberate narrowing from the protocol's report: the §4.6 + * `sshFingerprint` block is NOT mirrored here, because no view carries it. + * The plane's route serializes `runtime` through a schema that does not + * declare it, and the node's own dashboard passes its pre-merge report (the + * trust block goes to the SPA as the top-level `sshTrust`, which is where + * `NodeSshTrustCard` reads it). */ export interface NodeRuntime { /** ISO 8601 start time of this node process */ @@ -312,17 +319,6 @@ export interface NodeRuntime { tmuxPath: string | null; /** The node binary this process re-enters */ binaryPath: string; - /** - * The §4.6 trust block as THIS report stated it (spec 2026-10-08): own and - * pinned-peer key fingerprints. Absent when the agent reported none. The - * detail view's `sshTrust` is the same fact with the one flag only the - * plane can state (whether it is live or the durable mirror); the runtime - * card renders nothing from this field, the trust card reads `sshTrust`. - */ - sshFingerprint?: { - own: { signing: string; encryption: string }; - peers: { nodeId: string; signing: string; encryption: string }[]; - }; } /** diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 06b57bde7..251aa30c6 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -550,11 +550,13 @@ function isSshFingerprint(value: unknown): value is string { } /** - * Node ids as the plane mints them: uuid text (hex and hyphens). `isNodeSubshellId`'s - * shape, reused because a peer id reaching the block is the same registry id. + * Node ids as the plane mints them: uuid text (hex and hyphens). THE guard, + * called, not copied: a peer id reaching the block is the same registry id + * the path-interpolation policy admits, and one regex must not drift from the + * other. */ function isPeerNodeId(value: unknown): value is string { - return typeof value === "string" && /^[0-9a-fA-F-]{1,64}$/.test(value); + return typeof value === "string" && isNodeSubshellId(value); } /** The `{ signing, encryption }` pair, or null when either half is off. */ From c9c27a2993d3918a26e0d712d683e424b69afb81 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:46:57 -0700 Subject: [PATCH 135/170] fix(install.sh): the 404 advice branch prints --key , never the live key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Set up Subshell here' act (spec 2026-10-08 §7) captures this script's output through a non-interactive exec; the old 'subshell setup --key ' advice line let bash expand the unspent key into the captured output. The placeholder renders identically for every caller, and the absent/unknown/ spent/expired render stays byte-identical (never a setup-key oracle). --- .../src/api/__tests__/install-script.test.ts | 87 +++++++++++++++++++ apps/server/api/src/api/install-script.ts | 3 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 apps/server/api/src/api/__tests__/install-script.test.ts diff --git a/apps/server/api/src/api/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts new file mode 100644 index 000000000..f28a8e50c --- /dev/null +++ b/apps/server/api/src/api/__tests__/install-script.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { installScriptRoute } from "@/api/install-script.js"; +import { db } from "@/db/index.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * `GET /install.sh` render rules (spec 2026-08-31 §8; the placeholder rule + * added by spec 2026-10-08 §7, Task 14). The "Set up Subshell here" act runs + * this exact script through a non-interactive exec and shows only a parsed + * status verb, so ANY line the script prints must be safe to capture: the + * 404 advice branch used to echo `--key $KEY`, which bash expands to the live + * setup key at RUNTIME - invisible to the rendered-text tests, visible in the + * captured output. It now prints the literal `--key `: a copy- + * paste template, never a value. + * + * The oracle rule stays: absent, unknown, spent, and expired keys render + * byte-identically (the endpoint is not a setup-key oracle). + */ + +const app = new Elysia().use(errorHandlerPlugin).use(installScriptRoute); +const repo = new NodeSetupKeysRepository(db); +const email = `install-${crypto.randomUUID()}@subshell.local`; +let userId = ""; +const keyIds: string[] = []; + +async function mkKey(ttlMs?: number): Promise { + const row = await repo.create(userId, ttlMs); + keyIds.push(row.id); + return row.key; +} + +async function install(key?: string): Promise { + const q = key ? `?setup_key=${encodeURIComponent(key)}` : ""; + return app.fetch(new Request(`http://localhost:3080/install.sh${q}`)); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("install-1"), + role: "user", + }); +}); + +afterAll(async () => { + for (const id of keyIds) await repo.deleteById(id, userId).catch(() => {}); + await deleteUserByEmailOrId({ email }).catch(() => {}); +}); + +describe("install.sh 404 advice branch (spec 2026-10-08 §7)", () => { + it("prints the literal `--key ` placeholder and never the key value", async () => { + const key = await mkKey(); + const body = await (await install(key)).text(); + expect(body).toContain("--key "); + // The runtime-expanding spelling is gone: bash would interpolate $KEY into + // the advice line, and the "Set up Subshell here" parser captures that + // line verbatim on a D that cannot fetch the node binary. + expect(body).not.toContain("--key $KEY"); + // The key still appears EXACTLY once: the KEY= assignment the render doc + // names as its only home. + expect(body.split(key).length - 1).toBe(1); + }); +}); + +describe("install.sh failure rendering is one byte-identical answer (never an oracle)", () => { + it("absent, unknown, spent, and expired keys render the same usage script", async () => { + const absent = await (await install()).text(); + const unknown = await (await install(`nsk_${"x".repeat(32)}`)).text(); + const spentKey = await mkKey(); + await repo.consume(spentKey, crypto.randomUUID()); + const spent = await (await install(spentKey)).text(); + const expired = await (await install(await mkKey(-60_000))).text(); + expect(unknown).toBe(absent); + expect(spent).toBe(absent); + expect(expired).toBe(absent); + expect(absent).toContain("exit 2"); + }); +}); diff --git a/apps/server/api/src/api/install-script.ts b/apps/server/api/src/api/install-script.ts index a88140734..b2f7ff35c 100644 --- a/apps/server/api/src/api/install-script.ts +++ b/apps/server/api/src/api/install-script.ts @@ -361,7 +361,8 @@ else echo " cli-node-vX.Y.Z GitHub Release into that dir, or run" >&2 echo " 'bun run release:cli-node' from a checkout on the server host." >&2 echo " Or install the node for this machine another way and run setup directly:" >&2 - echo " subshell setup --server $SERVER --key $KEY\${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 + # Keep credentials out of captured setup output. The user supplies this placeholder. + echo " subshell setup --server $SERVER --key \${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 exit 1 ;; *) From e3692b8ae72666930ab8658464a755423db3c7b7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:51:23 -0700 Subject: [PATCH 136/170] feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment over the open session Task 14's machine half. The ssh_exec / ssh_exec_status pair runs the rendered installer as a SEPARATE short-lived 'ssh D ' on the connecting machine - never typed into the pane, so the minted setup key reaches neither the pane screen nor its log. Grammar: every field required, the command is one printable line, the relay/own-mode contradiction refused at the grammar. Executor: gate and maintenance speak first, the config path is byte-checked against this machine's own derivation (the launch doctrine restated), the relay socket is derived not claimed, the run goes OFF the command chain (an installer takes minutes), and the nsk_ redactor drops every key line BEFORE the tail truncation - redact then cut, never cut then redact. The per-act files leave with the act; the plane re-redacts what comes back. --- .../src/__tests__/commands-ssh-exec.test.ts | 303 ++++++++++++++++++ apps/node/agent/src/commands/index.ts | 10 +- apps/node/agent/src/commands/ssh-exec.ts | 199 ++++++++++++ .../src/__tests__/node-frames.test.ts | 153 ++++++++- .../src/__tests__/node-results-ssh.test.ts | 60 +++- packages/subshell-protocol/src/index.ts | 12 + packages/subshell-protocol/src/node-frames.ts | 2 + .../subshell-protocol/src/node-results.ts | 63 +++- packages/subshell-protocol/src/ssh-frames.ts | 156 ++++++++- packages/subshell-protocol/src/ssh-limits.ts | 46 +++ packages/subshell-protocol/src/ssh-results.ts | 35 ++ 11 files changed, 1027 insertions(+), 12 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-exec.test.ts create mode 100644 apps/node/agent/src/commands/ssh-exec.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts new file mode 100644 index 000000000..30643364d --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -0,0 +1,303 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + buildSshKnownHostsPath, + type SshProcessResult, +} from "@internal/pane-runtime"; +import { NODE_RESULT_MAINTENANCE, type SshExecCommand, type SshExecStatusCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshExec, execSshExecStatus, resetSshExecRunsForTests, type SshExecSeams } from "../commands/ssh-exec.js"; +import { writeMaintenance } from "../maintenance.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; +import { isSubshellId } from "../subshell-meta.js"; + +/** + * The `ssh_exec` non-interactive setup run (spec 2026-10-08 §7, Task 14): the + * "Set up Subshell here" act's separate short-lived `ssh D ''` on B, + * with the `nsk_` redactor standing between the installer's output and every + * surface that outlives the command. Posture copied from the sibling ssh + * arms: the gate speaks first, the config path is byte-checked against THIS + * machine's own derivation (the launch-frame doctrine restated for the exec), + * the run is off the command chain (an installer takes minutes; the chain + * must stay free), and the status answer carries only redacted, tail-truncated + * output. The runProcess seam replaces the real ssh child; this file owns the + * arm's decisions, not OpenSSH's behavior. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const EXEC_ID = "11111111-2222-4333-8444-555555555555"; +const KEY = "nsk_LYr6F2g63jWYpSF1g9MWZaqx6V9HDGJi"; + +let base: string; +let savedSshPath: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-exec-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; + // A stub ssh so the REAL runSshProcess cases can spawn something real. + const stub = join(base, "ssh-stub.sh"); + writeFileSync(stub, "#!/bin/sh\necho '==> done.'\n", { mode: 0o755 }); + resetSshExecRunsForTests(); +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + resetSshExecRunsForTests(); + rmSync(base, { recursive: true, force: true }); +}); + +// The exec registry is module state (one daemon, one map); tests share the +// EXEC_ID, so each one starts from an empty map. +beforeEach(() => resetSshExecRunsForTests()); + +function makeCtx(tag: string, opts: { gateOn?: boolean; maintenanceOn?: boolean } = {}): CommandContext { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (opts.gateOn !== false) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + if (opts.maintenanceOn) writeMaintenance(dir, { on: true, changedAt: STAMP }); + return { config: { dataDir: dir }, nowMs: () => Date.now() } as unknown as CommandContext; +} + +function execCmd(dir: string, over: Partial = {}): SshExecCommand { + return { + type: "ssh_exec", + execId: EXEC_ID, + configPath: buildSshConfigPath(dir, EXEC_ID), + fileContent: "Host *\n StrictHostKeyChecking yes\n", + presetFlags: ["-o", "BatchMode=yes", "-F", buildSshConfigPath(dir, EXEC_ID), "--", "d.example.test"], + command: `curl -fsSL "http://plane.test/install.sh?setup_key=${KEY}" | SUBSHELL_NODE_NAME="d" bash`, + relay: true, + agentSocketPath: null, + timeoutMs: 600_000, + ...over, + }; +} + +function statusCmd(execId = EXEC_ID): SshExecStatusCommand { + return { type: "ssh_exec_status", execId }; +} + +/** Bind the relay proxy socket path the way openBRelaySession left it (a file at the derived path). */ +function bindSocket(dir: string): string { + const path = buildAgentSocketPath(dir, EXEC_ID); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "stub"); + return path; +} + +/** A runProcess seam that answers a canned result and records the argv + env it was handed. */ +function fakeRun( + result: Partial, + opts: { defer?: boolean } = {}, +): { + seams: SshExecSeams; + calls: { argv: readonly string[]; env: Record; timeoutMs: number }[]; + finish: (r?: Partial) => void; +} { + const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; + let settle: ((r: SshProcessResult) => void) | null = null; + const seams: SshExecSeams = { + resolveSshBin: async () => "/usr/bin/fake-ssh", + runProcess: ((argv, env, timeoutMs) => { + calls.push({ argv, env, timeoutMs }); + const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; + if (opts.defer) { + return new Promise((resolve) => { + settle = (r) => resolve({ ...full, ...r }); + }); + } + return Promise.resolve(full); + }) as unknown as SshExecSeams["runProcess"], + }; + return { seams, calls, finish: (r) => settle?.(r ?? {}) }; +} + +describe("ssh_exec gate and path discipline", () => { + it("the gate speaks first: a mirror not ON refuses before any spawn", async () => { + const ctx = makeCtx("gate-off", { gateOn: false }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(calls).toHaveLength(0); + }); + + it("maintenance refuses the run (the machine is out of service for new work)", async () => { + const ctx = makeCtx("maint", { maintenanceOn: true }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: NODE_RESULT_MAINTENANCE }); + expect(calls).toHaveLength(0); + }); + + it("refuses a config path that is not this machine's own derivation, and writes nothing", async () => { + const ctx = makeCtx("path"); + const hostile = execCmd(ctx.config.dataDir, { configPath: "/home/u/.ssh/config" }); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, hostile, seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + expect(existsSync(buildSshConfigPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("refuses an exec id outside the path-composition guard", async () => { + const ctx = makeCtx("babadid"); + const bad = execCmd(ctx.config.dataDir, { execId: "not an id" }); + expect(isSubshellId(bad.execId)).toBe(false); + const { seams } = fakeRun({}); + expect(await execSshExec(ctx, bad, seams)).toMatchObject({ ok: false }); + }); + + it("relay mode refuses when the proxy socket is not bound (no half-running the install)", async () => { + const ctx = makeCtx("no-sock"); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + }); + + it("refuses when this machine has no ssh binary", async () => { + const ctx = makeCtx("nobin"); + bindSocket(ctx.config.dataDir); + const seams: SshExecSeams = { resolveSshBin: async () => null }; + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ + ok: false, + error: "ssh binary missing: ssh", + }); + }); +}); + +describe("ssh_exec kick + status", () => { + it("writes the config at the derived path, spawns ssh off-chain, and reports running then done", async () => { + const ctx = makeCtx("happy"); + const socket = bindSocket(ctx.config.dataDir); + // The relay-open also wrote the pinned known_hosts; the exec's cleanup must remove it too. + writeFileSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID), "d.example.test ssh-ed25519 AAAA\n", { + mode: 0o600, + }); + const { seams, calls, finish } = fakeRun({}, { defer: true }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick).toEqual({ ok: true, data: { started: true, execId: EXEC_ID } }); + + const configPath = buildSshConfigPath(ctx.config.dataDir, EXEC_ID); + expect(existsSync(configPath)).toBe(true); + // 0600 in a 0700 dir, the launch ssh-member rule (umask cannot leak past enforceMode). + expect(statSync(configPath).mode & 0o777).toBe(0o600); + + // One argv element for the command: ssh joins operands itself. + expect(calls[0]?.argv).toEqual([ + "/usr/bin/fake-ssh", + ...execCmd(ctx.config.dataDir).presetFlags, + execCmd(ctx.config.dataDir).command, + ]); + expect(calls[0]?.env.SSH_AUTH_SOCK).toBe(socket); // derived, never claimed + expect(calls[0]?.timeoutMs).toBe(600_000); + + // The run is OFF the chain: the status arm answers while ssh is still out there. + const dup = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(dup).toMatchObject({ ok: false }); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "running" } }); + + finish({ code: 0, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }); + await new Promise((r) => setTimeout(r, 5)); // let the off-chain completion land + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }, + }); + // The per-act files are gone; the dir itself is the hourly sweep's garbage. + expect(existsSync(configPath)).toBe(false); + expect(existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("a captured nsk_ line NEVER survives into the status answer", async () => { + const ctx = makeCtx("redact"); + bindSocket(ctx.config.dataDir); + const leaky = [ + "==> downloading subshell (linux-x64) from http://plane.test", + `curl: (22) The requested URL returned error: 404 with ${KEY} in hand`, + `KEY="${KEY}"`, + "subshell: setup ran with the key", + "==> done.", + ].join("\n"); + const { seams } = fakeRun({ code: 0, stdout: leaky, stderr: leaky }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + expect(status.ok).toBe(true); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(KEY); + }); + + it("redaction runs BEFORE the tail truncation: a leaking line ahead of the tail cannot be cut into a survivor", async () => { + const ctx = makeCtx("redact-tail"); + bindSocket(ctx.config.dataDir); + const filler = Array.from({ length: 4000 }, (_, i) => `filler line ${i}`).join("\n"); + const stdout = `setup_key=${KEY} at the head\n${filler}\n==> done.`; + const { seams } = fakeRun({ code: 0, stdout, stderr: "" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).toContain("==> done."); + }); + + it("the deadline is reported honestly: timedOut true, code null", async () => { + const ctx = makeCtx("timeout"); + bindSocket(ctx.config.dataDir); + const { seams } = fakeRun({ code: null, timedOut: true, stdout: "", stderr: "killed" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: null, timedOut: true, stdout: "", stderr: "killed" }, + }); + }); + + it("direct mode: no socket claim means no SSH_AUTH_SOCK; the snapshot's socket rides when named", async () => { + const ctx = makeCtx("direct"); + const named = fakeRun({}); + await execSshExec( + ctx, + execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: "/run/user/501/ssh-agent.sock" }), + named.seams, + ); + expect(named.calls[0]?.env.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + resetSshExecRunsForTests(); + const bare = fakeRun({}); + await execSshExec(ctx, execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: null }), bare.seams); + expect("SSH_AUTH_SOCK" in (bare.calls[0]?.env ?? {})).toBe(false); + }); + + it("status refuses an unknown exec id", async () => { + const ctx = makeCtx("unknown"); + expect( + await execSshExecStatus(ctx, statusCmd(), { + resolveSshBin: async () => "/usr/bin/fake-ssh", + }), + ).toMatchObject({ ok: false }); + }); + + it("dispatch routes both arms", async () => { + const ctx = makeCtx("dispatch"); + bindSocket(ctx.config.dataDir); + // The dispatch path runs the REAL binary lookup and spawn; point the + // ladder at a stub ssh that prints the success verb and exits. + process.env.SUBSHELL_SSH_PATH = join(base, "ssh-stub.sh"); + const kick = await dispatchCommand(ctx, execCmd(ctx.config.dataDir) as never); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + const st = await dispatchCommand(ctx, statusCmd() as never); + expect(st).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }, + }); + delete process.env.SUBSHELL_SSH_PATH; + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index c199a7dba..814896976 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshExec, execSshExecStatus } from "./ssh-exec.js"; import { execSshHostKey } from "./ssh-host-key.js"; import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; @@ -70,7 +71,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, - * because ending a session is not an SSH act). + * because ending a session is not an SSH act), and the non-interactive setup + * pair `ssh_exec` / `ssh_exec_status` (spec 2026-10-08 §7, Task 14 - the + * "Set up Subshell here" install runs on its own short-lived ssh, off the + * command chain, its captured output `nsk_`-redacted before it is kept). * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. @@ -162,6 +166,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": return execSshRelayClose(ctx, cmd); + case "ssh_exec": + return await execSshExec(ctx, cmd); + case "ssh_exec_status": + return await execSshExecStatus(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-exec.ts b/apps/node/agent/src/commands/ssh-exec.ts new file mode 100644 index 000000000..99942d8d1 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-exec.ts @@ -0,0 +1,199 @@ +import { existsSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + buildSshKnownHostsPath, + enforceMode, + runSshProcess, + type SshProcessResult, + sshChildPath, +} from "@internal/pane-runtime"; +import { + NODE_RESULT_MAINTENANCE, + redactSshSetupKeyLines, + SSH_EXEC_RETAIN_BYTES, + type SshExecCommand, + type SshExecStatusCommand, +} from "@internal/subshell-protocol"; +import { readMaintenance } from "../maintenance.js"; +import { pathAllowed, realpathRoots } from "../path-policy.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import { isSubshellId } from "../subshell-meta.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The `ssh_exec` non-interactive run (spec 2026-10-08 §7, Task 14): the + * "Set up Subshell here" act's SEPARATE short-lived `ssh D ''` on + * this machine - never typed into the interactive pane, so the minted setup + * key in the command string reaches neither the pane screen nor the pane log. + * The rules, each load-bearing: + * + * - **The gate and maintenance speak first**, like every SSH arm and the + * launch: a mirror not ON refuses {@link SSH_GATE_REFUSAL} before any + * lookup or spawn, and a machine out of service takes no new work. + * - **The config path is byte-checked against this machine's own + * derivation** (the launch frame's ssh-member doctrine restated): the exec + * id names `/ssh//`, and a frame claiming any other path + * is refused, so a signed command can never become an arbitrary write. + * - **Relay mode authenticates through the socket `ssh_relay_open` bound at + * this same derived path** - derived HERE, claimed by nothing on the wire; + * a kick whose proxy socket is not bound is refused rather than run with + * half its authentication. + * - **The run is OFF the command chain.** An installer downloads a ~100 MB + * binary; the serial chain that orders every other command must stay free + * for the pane's own machine, so the kick acks `started` immediately and + * the outcome is polled with `ssh_exec_status`. + * - **The `nsk_` redactor stands before anything is kept.** Every captured + * line carrying the setup-key marker is dropped BEFORE the answer-side + * truncation (line-wise first, tail second - a cut must never leave the + * key's tail bytes behind), and the per-act files (config, pinned + * `known_hosts`) are unlinked when the child finishes. The durable record + * of the act is the plane's audit, which names ids and stages only. + * + * State is the module's own map keyed by the (plane-minted, path-guarded) + * exec id, TTL-swept at every touch: a done result is kept long enough for + * the plane's poll, and a daemon that restarts forgets the act (the plane's + * poll then names the failure; a stale entry can never outlive the sweep). + */ + +/** How long a DONE exec answer is kept for the plane's poll (a running child is never swept). */ +const EXEC_RESULT_TTL_MS = 30 * 60_000; + +interface ExecEntry { + startedAt: number; + done?: { code: number | null; timedOut: boolean; stdout: string; stderr: string }; +} + +const runs = new Map(); + +/** Drop every expired done-entry (running children are kept whatever their age). */ +function sweepRuns(nowMs: number): void { + for (const [id, e] of runs) { + if (e.done !== undefined && e.startedAt <= nowMs - EXEC_RESULT_TTL_MS) runs.delete(id); + } +} + +/** + * Forget every tracked exec (daemon-test isolation). + * @internal test-only + */ +export function resetSshExecRunsForTests(): void { + runs.clear(); +} + +/** Test seams (production omits all three; the contract mirrors `HostKeySeams`). */ +export interface SshExecSeams { + /** Where ssh is; defaults to this machine's own lookup ladder. */ + resolveSshBin?: () => Promise; + /** One ssh run; defaults to {@link runSshProcess}. Absolute argv[0], full child env, deadline. */ + runProcess?: typeof runSshProcess; + /** Epoch clock for the result TTL (defaults to Date.now). */ + nowMs?: () => number; +} + +/** Redact per line, THEN keep the tail: the order is the leak rule (module doc). */ +function retained(stream: string): string { + const clean = redactSshSetupKeyLines(stream); + const bytes = Buffer.from(clean, "utf8"); + if (bytes.byteLength <= SSH_EXEC_RETAIN_BYTES) return clean; + const tail = bytes.subarray(bytes.byteLength - SSH_EXEC_RETAIN_BYTES); + // Drop a leading partial codepoint the byte cut may have produced. + return new TextDecoder("utf-8", { fatal: false }).decode(tail); +} + +/** Execute `ssh_exec`: gate, byte-check, bind, spawn off-chain, ack. */ +export async function execSshExec( + ctx: CommandContext, + cmd: SshExecCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const maintenance = readMaintenance(ctx.config.dataDir); + if (maintenance.kind === "unreadable" || (maintenance.kind === "state" && maintenance.state.on)) { + return { ok: false, error: NODE_RESULT_MAINTENANCE }; + } + // The path guard is this machine's own id rule (hex+hyphen, ≤64): every fs + // path below interpolates the exec id, so nothing past this line runs on an + // id the composition guards could not have produced. + if (!isSubshellId(cmd.execId)) return { ok: false, error: "invalid exec id" }; + const derivedConfig = buildSshConfigPath(ctx.config.dataDir, cmd.execId); + if (cmd.configPath !== derivedConfig) { + return { ok: false, error: "ssh config path refused: not the derived path" }; + } + if (!(await pathAllowed(derivedConfig, await realpathRoots([ctx.config.dataDir])))) { + return { ok: false, error: "ssh config path refused" }; + } + // Relay mode: the proxy socket lives at THIS machine's derivation of the + // same (dataDir, execId) pair the relay-open bound. No claim is read; the + // fact is checked. An unbound socket means no signing path to D - refuse + // loudly rather than run the install down a road that stops at auth. + const agentSocket = cmd.relay ? buildAgentSocketPath(ctx.config.dataDir, cmd.execId) : null; + if (agentSocket !== null && !existsSync(agentSocket)) { + return { ok: false, error: "relay agent socket is not bound on this machine" }; + } + const now = seams?.nowMs ?? Date.now; + sweepRuns(now()); + if (runs.has(cmd.execId)) return { ok: false, error: "an exec with this id is already tracked" }; + const sshBin = await (seams?.resolveSshBin ?? resolveSshBin)(); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + + // Place the config like the launch arm does: own dir at 0700, file at 0600, + // enforceMode after both (a umask can only clear bits, never past this). + const dir = dirname(derivedConfig); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(derivedConfig, cmd.fileContent, { mode: 0o600 }); + await enforceMode(derivedConfig, 0o600); + + // The scoped agent env, one key, for the ssh invocation only (never a + // whole-env inheritance - runSshProcess takes the COMPLETE child env). + const env: Record = { + HOME: process.env.HOME ?? "", + PATH: await sshChildPath(), + }; + if (agentSocket !== null) env.SSH_AUTH_SOCK = agentSocket; + else if (cmd.agentSocketPath !== null) env.SSH_AUTH_SOCK = cmd.agentSocketPath; + + const entry: ExecEntry = { startedAt: now() }; + runs.set(cmd.execId, entry); + // OFF the chain by construction: the run's promise is carried, never + // awaited; the finish handler redacts, retains, stores, and sweeps files. + void (seams?.runProcess ?? runSshProcess)([sshBin, ...cmd.presetFlags, cmd.command], env, cmd.timeoutMs).then( + (r: SshProcessResult) => { + entry.done = { + code: r.spawnError ? null : r.code, + timedOut: r.timedOut, + stdout: retained(r.stdout), + stderr: retained(r.stderr), + }; + // The act's own files leave with the act; the dir joins the hourly + // orphan sweep (`ssh-dir-retention`), which owns every leftover name. + void rm(derivedConfig, { force: true }).catch(() => {}); + void rm(buildSshKnownHostsPath(ctx.config.dataDir, cmd.execId), { force: true }).catch(() => {}); + }, + () => { + // runSshProcess never rejects today; the shape is the guard, so a + // future throw cannot strand the entry as "running" forever. + entry.done = { code: null, timedOut: false, stdout: "", stderr: "the ssh process could not be tracked" }; + }, + ); + return { ok: true, data: { started: true, execId: cmd.execId } }; +} + +/** Execute `ssh_exec_status`: the polled state of one kicked run. */ +export async function execSshExecStatus( + ctx: CommandContext, + cmd: SshExecStatusCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + if (!isSubshellId(cmd.execId)) return { ok: false, error: "invalid exec id" }; + sweepRuns((seams?.nowMs ?? Date.now)()); + const e = runs.get(cmd.execId); + if (e === undefined) return { ok: false, error: "no exec with this id on this machine" }; + if (e.done === undefined) return { ok: true, data: { state: "running" } }; + return { ok: true, data: { state: "done", ...e.done } }; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 1278098f8..92ce9acf0 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -28,11 +28,19 @@ import { import { isSshGrantFingerprints, isSshPaneId, + redactSshSetupKeyLines, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, + type SshExecCommand, type SshRelayOpenCommand, } from "../ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; +import { + SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_TIMEOUT_MAX_MS, + SSH_MAX_GRANT_FINGERPRINTS, +} from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -1069,18 +1077,20 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these seven types, and the count is the + // The family's census: exactly these nine types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_agent_identities", "ssh_discover_aliases", + "ssh_exec", + "ssh_exec_status", "ssh_host_key", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(7); + expect(SSH_COMMAND_TYPES).toHaveLength(9); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -1092,7 +1102,11 @@ describe("ssh command arms and the launch ssh block", () => { ? { type: t, ref: "r-1", reason: "lifetime-expiry" } : t === "ssh_host_key" ? { type: t, host: "git.example.test", port: 22, user: null } - : { type: t }; + : t === "ssh_exec" + ? execCmdFixture() + : t === "ssh_exec_status" + ? { type: t, execId: EXEC_ID } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -1384,6 +1398,137 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => }); }); +/** + * A complete `ssh_exec` kick (spec 2026-10-08 §7, Task 14): the non-interactive + * "Set up Subshell here" run on the connecting machine. Every field is + * required - the node byte-checks the config path against its own derivation, + * so a frame guessing any half is refused rather than executed with a hole. + */ +const EXEC_ID = "11111111-2222-4333-8444-555555555555"; +const execCmdFixture = (): SshExecCommand => ({ + type: "ssh_exec", + execId: EXEC_ID, + configPath: `/home/u/.subshell/ssh/${EXEC_ID}/config`, + fileContent: "Host *\n StrictHostKeyChecking yes\n", + presetFlags: [ + "-o", + "BatchMode=yes", + "-F", + `/home/u/.subshell/ssh/${EXEC_ID}/config`, + "-p", + "22", + "--", + "d.example.test", + ], + command: 'curl -fsSL "http://plane.test/install.sh?setup_key=nsk_x" | SUBSHELL_NODE_NAME="d" bash', + relay: true, + agentSocketPath: null, + timeoutMs: 300_000, +}); + +describe("ssh_exec / ssh_exec_status arms (spec 2026-10-08 §7, Task 14)", () => { + it("narrows a complete kick, every field carried through", () => { + expect(parseNodeCommandBody(structuredClone(execCmdFixture()))).toEqual(execCmdFixture()); + // Direct mode: no relay, the snapshot's own agent socket rides (or null). + expect(parseNodeCommandBody({ ...execCmdFixture(), relay: false, agentSocketPath: null })).toMatchObject({ + relay: false, + agentSocketPath: null, + }); + expect( + parseNodeCommandBody({ ...execCmdFixture(), relay: false, agentSocketPath: "/run/user/501/agent.sock" }), + ).toMatchObject({ + agentSocketPath: "/run/user/501/agent.sock", + }); + }); + + it("refuses a kick missing any field", () => { + for (const drop of [ + "execId", + "configPath", + "fileContent", + "presetFlags", + "command", + "relay", + "agentSocketPath", + "timeoutMs", + ] as const) { + const partial = structuredClone(execCmdFixture()) as unknown as Record; + delete partial[drop]; + expect(parseNodeCommandBody(partial)).toBeNull(); + } + }); + + it("refuses malformed kick fields: ids, paths, flags, command, and the relay contradiction", () => { + expect(parseNodeCommandBody({ ...execCmdFixture(), execId: "not an id" })).toBeNull(); // path-id shape + expect(parseNodeCommandBody({ ...execCmdFixture(), execId: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), configPath: "ssh/x/config" })).toBeNull(); // absolute only + expect( + parseNodeCommandBody({ ...execCmdFixture(), fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES + 1) }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: "not-an-array" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: [] })).toBeNull(); // the launch needs at least the -F tail + expect( + parseNodeCommandBody({ + ...execCmdFixture(), + presetFlags: Array.from({ length: SSH_EXEC_MAX_PRESET_FLAGS + 1 }, (_, i) => `-${i}`), + }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: ["-F", "path with space"] })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: ["-x", "nl\n"] })).toBeNull(); // control chars refused + expect(parseNodeCommandBody({ ...execCmdFixture(), command: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), command: "a\x00b" })).toBeNull(); // one printable line + expect( + parseNodeCommandBody({ ...execCmdFixture(), command: "c".repeat(SSH_EXEC_COMMAND_MAX_CHARS + 1) }), + ).toBeNull(); + // relay true names the DERIVED socket; an agentSocketPath claim beside it + // is a contradiction, and agentSocketPath null in direct mode is legal. + expect( + parseNodeCommandBody({ + ...execCmdFixture(), + relay: true, + agentSocketPath: "/home/u/.subshell/ssh/other/agent.sock", + }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: 0 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: SSH_EXEC_TIMEOUT_MAX_MS + 1 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: 1.5 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), relay: "yes" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), agentSocketPath: "relative/sock" })).toBeNull(); + // A stray member drops; the narrowed body is rebuilt from the checked fields. + const withExtra = parseNodeCommandBody({ ...execCmdFixture(), smuggled: "x" }); + expect(withExtra).not.toBeNull(); + expect(Object.keys(withExtra as Record).sort()).toEqual(Object.keys(execCmdFixture()).sort()); + }); + + it("the status arm is one path id", () => { + expect(parseNodeCommandBody({ type: "ssh_exec_status", execId: EXEC_ID })).toEqual({ + type: "ssh_exec_status", + execId: EXEC_ID, + }); + expect(parseNodeCommandBody({ type: "ssh_exec_status", execId: "bad id" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_exec_status" })).toBeNull(); + }); + + it("redactSshSetupKeyLines drops EVERY line carrying the setup-key marker", () => { + const out = redactSshSetupKeyLines( + [ + "==> downloading subshell (linux-x64) from http://plane.test", + "curl: (7) could not reach http://plane.test/install.sh?setup_key=nsk_SECRET_VALUE_00000000000000000000", + 'KEY="nsk_SECRET_VALUE_00000000000000000000"', + "==> done.", + ].join("\n"), + ); + expect(out).not.toContain("nsk_"); + expect(out).not.toContain("SECRET"); + expect(out).toContain("==> downloading"); + expect(out).toContain("==> done."); + // A line whose key marker was cut mid-token at the truncation seam still + // drops when the `nsk_` prefix survives; the function is whole-line by law. + expect(redactSshSetupKeyLines("")).toBe(""); + expect(redactSshSetupKeyLines("clean\nlines\nonly")).toBe("clean\nlines\nonly"); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index 64a814c1b..4dd46babd 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "../node-results.js"; -import { SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; +import { SSH_EXEC_RESULT_MAX_CHARS, SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -153,3 +155,59 @@ describe("parseNodeSshHostKey (spec 2026-10-08 §9, Task 12)", () => { ).not.toBeNull(); }); }); + +describe("parseNodeSshExecKick / parseNodeSshExecStatus (spec 2026-10-08 §7, Task 14)", () => { + const EXEC_ID = "11111111-2222-4333-8444-555555555555"; + test("the kick ack narrows to the started marker plus the id", () => { + expect(parseNodeSshExecKick({ started: true, execId: EXEC_ID })).toEqual({ started: true, execId: EXEC_ID }); + expect(parseNodeSshExecKick({ started: false, execId: EXEC_ID })).toBeNull(); + expect(parseNodeSshExecKick({ started: true })).toBeNull(); + expect(parseNodeSshExecKick({ started: true, execId: "bad id" })).toBeNull(); + expect(parseNodeSshExecKick("pong")).toBeNull(); + }); + test("the status answer narrows both states", () => { + expect(parseNodeSshExecStatus({ state: "running" })).toEqual({ state: "running" }); + expect( + parseNodeSshExecStatus({ state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }), + ).toEqual({ state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }); + // A signal-killed child reports a null code honestly. + expect(parseNodeSshExecStatus({ state: "done", code: null, timedOut: true, stdout: "", stderr: "" })).toEqual({ + state: "done", + code: null, + timedOut: true, + stdout: "", + stderr: "", + }); + }); + test("the status answer refuses malformed shapes", () => { + expect(parseNodeSshExecStatus({ state: "half" })).toBeNull(); + expect(parseNodeSshExecStatus({ state: "running", smuggled: 1 })).toBeNull(); // rebuilt from checked fields + expect(parseNodeSshExecStatus({ state: "done" })).toBeNull(); // done states ALL four facts + expect(parseNodeSshExecStatus({ state: "done", code: 0.5, timedOut: false, stdout: "", stderr: "" })).toBeNull(); + expect(parseNodeSshExecStatus({ state: "done", code: 0, stdout: "", stderr: "" })).toBeNull(); + expect( + parseNodeSshExecStatus({ state: "done", code: 0, timedOut: false, stdout: "a\x00b", stderr: "" }), + ).toBeNull(); // control chars other than newlines/tabs/CR are refused + expect( + parseNodeSshExecStatus({ + state: "done", + code: 0, + timedOut: false, + stdout: "x".repeat(SSH_EXEC_RESULT_MAX_CHARS + 1), + stderr: "", + }), + ).toBeNull(); + }); + test("the narrowed status answer drops unknown members (rebuild, not cast)", () => { + const parsed = parseNodeSshExecStatus({ + state: "done", + code: 1, + timedOut: false, + stdout: "s", + stderr: "e", + nodeKey: "leak?", + }); + expect(parsed).not.toHaveProperty("nodeKey"); + expect(parsed).toEqual({ state: "done", code: 1, timedOut: false, stdout: "s", stderr: "e" }); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index f336cbc4c..9461a05ac 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -157,6 +157,8 @@ export { parseNodePromptDeliver, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -262,10 +264,13 @@ export { isSshKnownHostsPinLine, isSshPaneId, parseSshNodeCommandBody, + redactSshSetupKeyLines, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, + type SshExecCommand, + type SshExecStatusCommand, type SshHostKeyCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, @@ -277,6 +282,11 @@ export { } from "./ssh-frames.js"; export { SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_RESULT_MAX_CHARS, + SSH_EXEC_RETAIN_BYTES, + SSH_EXEC_TIMEOUT_MAX_MS, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_KEY_LINES, @@ -322,6 +332,8 @@ export type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshExecKickResult, + NodeSshExecStatusResult, NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 251aa30c6..47c611e0e 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1996,6 +1996,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_host_key": case "ssh_relay_open": case "ssh_relay_close": + case "ssh_exec": + case "ssh_exec_status": // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 020544239..1f8f3a934 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,12 +23,19 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { isSshGrantFingerprint, isSshKnownHostsPinLine } from "./ssh-frames.js"; -import { SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_HOST_KEY_LINES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { isSshGrantFingerprint, isSshKnownHostsPinLine, isSshPaneId } from "./ssh-frames.js"; +import { + SSH_EXEC_RESULT_MAX_CHARS, + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_HOST_KEY_LINES, + SSH_NAME_MAX_CHARS, +} from "./ssh-limits.js"; import type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshExecKickResult, + NodeSshExecStatusResult, NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, @@ -661,3 +668,55 @@ export function parseNodeSshIdentity(data: unknown): NodeSshIdentityResult | nul } return { signingPublicKey: data.signingPublicKey }; } + +/** + * Validates and narrows an `ssh_exec` KICK's `result{data}` (spec 2026-10-08 + * §7, Task 14). The ack is two fields and no opinion: a machine that did not + * start answers `ok:false` (a refusal), never a `{started:false}`. + * @param data - the `data` member of a successful result frame + * @returns the narrowed ack, or null when malformed + */ +export function parseNodeSshExecKick(data: unknown): NodeSshExecKickResult | null { + if (!isRecord(data)) return null; + if (data.started !== true) return null; + if (!isSshPaneId(data.execId)) return null; + return { started: true, execId: data.execId }; +} + +/** + * Validates and narrows an `ssh_exec_status` answer (spec 2026-10-08 §7, + * Task 14). Two closed states, and `done` states ALL FOUR facts (a code, a + * timeout flag, and both streams): a machine answering half the outcome + * would let the plane guess which half the installer broke on. The streams + * are bounded printable text - newlines, tabs, and CR survive; every other + * control character refuses the answer (an escape sequence reaching a + * renderable field is not "output", and the plane re-redacts anyway). + * @param data - the `data` member of a successful result frame + * @returns the narrowed status, or null when malformed + */ +export function parseNodeSshExecStatus(data: unknown): NodeSshExecStatusResult | null { + if (!isRecord(data)) return null; + if (data.state === "running") { + // Running is running: nothing may ride it (a smuggled half-outcome would + // be exactly the partial truth the "done states all four" rule refuses). + if (Object.keys(data).length !== 1) return null; + return { state: "running" }; + } + if (data.state !== "done") return null; + if (!("code" in data) || !(data.code === null || isInt(data.code))) return null; + if (!isBool(data.timedOut)) return null; + if (!isExecStreamStr(data.stdout) || !isExecStreamStr(data.stderr)) return null; + return { + state: "done", + code: data.code === null ? null : (data.code as number), + timedOut: data.timedOut, + stdout: data.stdout, + stderr: data.stderr, + }; +} + +/** A captured stream: bounded text whose only control characters are newlines, tabs, and CR. */ +function isExecStreamStr(value: unknown): value is string { + if (!isStr(value) || value.length > SSH_EXEC_RESULT_MAX_CHARS) return false; + return !/\p{Cc}/u.test(value.replace(/[\n\r\t]/g, "")); +} diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 0bda2cd7a..7e8db4301 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -19,7 +19,11 @@ * becomes the pin the relay-open carries to B), and the brokered pair * `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN hands one * machine the whole pairing plus the destination's host-key pin, the CLOSE - * names why it ends). What + * names why it ends), and the non-interactive setup-exec pair `ssh_exec` / + * `ssh_exec_status` (spec 2026-10-08 §7, Task 14: the "Set up Subshell here" + * act runs the rendered installer over its own short-lived `ssh` connection, + * never the pane, and the answer carries output only through the setup-key + * redactor). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which * carries no per-message signing because the link already authenticates. @@ -36,8 +40,17 @@ * data (the relay pair answer with a plain ack - no envelope to validate). */ -import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; -import { SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_PIN_LINE_CHARS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { + SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_TIMEOUT_MAX_MS, + SSH_MAX_GRANT_FINGERPRINTS, + SSH_MAX_HOST_PIN_LINE_CHARS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, +} from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -227,6 +240,57 @@ export interface SshRelayCloseCommand { reason: SshRelayCloseReason; } +/** + * The non-interactive setup exec (spec 2026-10-08 §7, Task 14): run + * `ssh -- ''` on THIS machine as one short- + * lived, output-capturing connection - the "Set up Subshell here" act's + * separate channel, NEVER typed into the interactive pane. The command string + * carries the minted setup key; every refusal and every captured byte on the + * answer side obeys the redaction rule ({@link redactSshSetupKeyLines}), so + * the key lands nowhere the pane, the pane log, or the plane's trail can + * read. + * + * The shape mirrors the `launch` frame's ssh member, with one sharper rule + * repeated: `configPath` is a CLAIM. The node re-derives + * `buildSshConfigPath(dataDir, execId)` from the exec id and refuses a + * single-byte mismatch, so a signed command can never name a config path + * outside the ephemeral per-act directory the machine's own sweeps own. + * + * `relay: true` is relay mode: the ssh child's `SSH_AUTH_SOCK` is the agent + * proxy socket the earlier `ssh_relay_open` bound at + * `buildAgentSocketPath(dataDir, execId)` - derived HERE, claimed by nothing, + * which is why a non-null `agentSocketPath` beside `relay: true` is a + * contradiction the grammar refuses. `relay: false` is M1's direct posture: + * the snapshot's own agent socket rides as `agentSocketPath` (the same scoped + * env the direct pane launch carries) or nothing does. + */ +export interface SshExecCommand { + type: "ssh_exec"; + /** The ephemeral act id (path-composition shape): names `/ssh//`. */ + execId: string; + /** The rendered-config path the plane derived; the node byte-checks its own. */ + configPath: string; + /** The rendered ssh config bytes (relay render: pinned, `StrictHostKeyChecking yes`). */ + fileContent: string; + /** The ssh option tokens through the destination: `-F … … -- host`. */ + presetFlags: string[]; + /** The remote one-liner (the installer command; the setup key lives HERE, transiently). */ + command: string; + /** True: authenticate through the exec's own relay proxy socket (relay mode). */ + relay: boolean; + /** Direct mode only: the snapshot's agent socket, or null; MUST be null when relay is true. */ + agentSocketPath: string | null; + /** The node-side hard deadline for the run; the plane's RPC deadline rides past it. */ + timeoutMs: number; +} + +/** Ask one machine for the state of a kicked `ssh_exec` (running, or done with its captured answer). */ +export interface SshExecStatusCommand { + type: "ssh_exec_status"; + /** The same ephemeral act id the kick carried. */ + execId: string; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ export type SshNodeCommandBody = | SshDiscoverAliasesCommand @@ -235,7 +299,9 @@ export type SshNodeCommandBody = | SshAgentIdentitiesCommand | SshHostKeyCommand | SshRelayOpenCommand - | SshRelayCloseCommand; + | SshRelayCloseCommand + | SshExecCommand + | SshExecStatusCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ export const SSH_COMMAND_TYPES = [ @@ -246,6 +312,8 @@ export const SSH_COMMAND_TYPES = [ "ssh_host_key", "ssh_relay_open", "ssh_relay_close", + "ssh_exec", + "ssh_exec_status", ] as const; /* ------------------------------------------------------------------ */ @@ -471,7 +539,87 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(value.reason)) return null; return { type: "ssh_relay_close", ref: value.ref, reason: value.reason as SshRelayCloseReason }; } + case "ssh_exec": { + // Every field is required (the §7 redaction rule has no omitted half + // to paper over): the exec id takes the SAME path-composition shape + // the relay's paneId takes, the config member takes the launch frame's + // ssh rules verbatim (absolute bounded path, bounded content), the + // flag list and the command are the argv this machine will spawn, so + // they are bounded TEXT, control characters refused (a smuggled + // newline would be a second command on the destination), and the + // relay/own-mode contradiction is refused at the grammar rather than + // guessed by the executor. + if (!isSshPaneId(value.execId)) return null; + if ( + !isStr(value.configPath) || + !value.configPath.startsWith("/") || + value.configPath.length > SSH_PATH_MAX_CHARS + ) { + return null; + } + if (!isStr(value.fileContent) || value.fileContent.length > SSH_CONFIG_FILE_MAX_BYTES) return null; + if (!isStrArray(value.presetFlags) || value.presetFlags.length === 0) return null; + if (value.presetFlags.length > SSH_EXEC_MAX_PRESET_FLAGS) return null; + for (const flag of value.presetFlags) { + if (!isExecToken(flag)) return null; + } + if (!isStr(value.command) || value.command.length === 0) return null; + if (value.command.length > SSH_EXEC_COMMAND_MAX_CHARS) return null; + if (/\p{Cc}/u.test(value.command)) return null; // one printable line; a control char is a smuggled second command + if (!isBool(value.relay)) return null; + if (!("agentSocketPath" in value)) return null; + if (!(value.agentSocketPath === null || isAbsPathStr(value.agentSocketPath))) return null; + if (value.relay && value.agentSocketPath !== null) return null; // relay derives its own socket + if (!isInt(value.timeoutMs) || (value.timeoutMs as number) < 1) return null; + if ((value.timeoutMs as number) > SSH_EXEC_TIMEOUT_MAX_MS) return null; + return { + type: "ssh_exec", + execId: value.execId, + configPath: value.configPath, + fileContent: value.fileContent, + presetFlags: [...value.presetFlags], + command: value.command, + relay: value.relay, + agentSocketPath: value.agentSocketPath, + timeoutMs: value.timeoutMs as number, + }; + } + case "ssh_exec_status": { + // One path-shaped id selects the act; the answer's shape is + // `node-results.ts`'s business, and a stray member drops here. + if (!isSshPaneId(value.execId)) return null; + return { type: "ssh_exec_status", execId: value.execId }; + } default: return null; } } + +/** A preset flag: bounded, non-empty, whitespace-free, control-char-free. */ +function isExecToken(value: string): boolean { + return value.length > 0 && value.length <= SSH_NAME_MAX_CHARS && !/\s|\p{Cc}/u.test(value); +} + +/** An absolute path claim the grammar may pass on (the launch frame's ssh rule, restated). */ +function isAbsPathStr(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS; +} + +/** + * Drop every line of captured installer output that carries the setup-key + * marker (`nsk_`, the mint shape's fixed prefix). Whole lines go, never bytes + * spliced: a line that mentions the key at all is a line the pane's machine, + * the plane's retained copy, and every downstream surface must not have. + * Deliberately broader than the key grammar - ANY `nsk_` substring drops - + * because the rule's job is to survive the mint's format changing under it. + * The node applies it before retaining or answering with ANY captured byte; + * the plane applies it again as the belt that does not trust the machine's + * hygiene (the "never trust a machine answer" doctrine, applied to output). + */ +export function redactSshSetupKeyLines(text: string): string { + if (!text.includes("nsk_")) return text; + return text + .split("\n") + .filter((line) => !line.includes("nsk_")) + .join("\n"); +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index ae52fdc62..854e5482a 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -205,3 +205,49 @@ export const SSH_MAX_HOST_KEY_LINES = 32; * the stream nothing but an event. */ export const SSH_SESSION_PUMP_CHUNK_BYTES = 192 * 1024; + +/* ------------------------------------------------------------------ */ +/* the non-interactive setup exec (spec 2026-10-08 §7, Task 14) */ +/* ------------------------------------------------------------------ */ + +/** + * Largest `ssh_exec` preset-flag token list. The compose emits `-F`, + * `BatchMode`, the port, `--`, and the destination: far short of this, and + * the bound is the grammar's, so a hostile plane cannot grow the argv the + * node spawns. + */ +export const SSH_EXEC_MAX_PRESET_FLAGS = 64; + +/** + * The remote one-liner an `ssh_exec` runs on the destination (the rendered + * `install.sh` one-liner with a minted setup key). One printable line - the + * grammar refuses control characters, so a smuggled second command is not + * representable. The cap bounds the text the destination's login shell + * parses; the real installer line is a few hundred characters. + */ +export const SSH_EXEC_COMMAND_MAX_CHARS = 8192; + +/** + * Upper bound on the node-side deadline of ONE non-interactive `ssh_exec` + * run. The act streams a node-binary download to the destination over the + * pane's own connection; ten minutes bounds the worst honest install, and a + * longer one is a refusal to the plane (which raises a fresh act), never an + * unbounded child. + */ +export const SSH_EXEC_TIMEOUT_MAX_MS = 600_000; + +/** + * Bytes of one captured stream the node RETAINS after the `nsk_` redaction + * (tail-first: the status verbs that end the installer's output are the + * lines the plane parses). Redaction runs BEFORE truncation, so no cut can + * leave a key's tail bytes behind. + */ +export const SSH_EXEC_RETAIN_BYTES = 8192; + +/** + * Longest captured stream one `ssh_exec_status` answer may carry (each of + * stdout and stderr, in characters). Comfortably above the node's own + * {@link SSH_EXEC_RETAIN_BYTES} tail; past it the answer is malformed, and + * the plane never sees a machine that simply did not cap itself. + */ +export const SSH_EXEC_RESULT_MAX_CHARS = 32_768; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index cc233d19d..f04ccc3ef 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -116,3 +116,38 @@ export interface NodeSshHostKeyResult { /** Every `known_hosts` line matching the destination's lookup; verbatim, at most SSH_MAX_HOST_KEY_LINES. */ lines: string[]; } + +/** + * The ack of a kicked `ssh_exec` (spec 2026-10-08 §7, Task 14): the run was + * accepted and started off the command chain (an installer takes minutes; a + * chain-occupying executor would stall the machine the act is meant to leave + * untouched). The outcome arrives through {@link NodeSshExecStatusResult}. + */ +export interface NodeSshExecKickResult { + /** True is the ONLY legal value: a kick that did not start answers `ok:false`, not this shape. */ + started: true; + /** The act id echoed back, so a reply is matched to its kick by equality. */ + execId: string; +} + +/** + * The pollable state of one `ssh_exec` (Task 14). `running` says the child is + * alive and nothing is kept of it yet; `done` carries the terminal facts: the + * exit code (null when a signal or a failed spawn left none), whether the + * deadline ended it, and the installer's captured output AFTER the node's + * own `nsk_` redaction and tail truncation. The plane redacts again; what + * reaches the pane, the log, or the trail is never a machine's raw word. + */ +export type NodeSshExecStatusResult = + | { state: "running" } + | { + state: "done"; + /** The child's exit code; null on signal death or a spawn that never ran. */ + code: number | null; + /** True when the node-side deadline ended the run (the child is gone). */ + timedOut: boolean; + /** Captured stdout, redacted and tail-truncated ({@link SSH_EXEC_RETAIN_BYTES}). */ + stdout: string; + /** Captured stderr, redacted and tail-truncated ({@link SSH_EXEC_RETAIN_BYTES}). */ + stderr: string; + }; From a90c6968ed9fd895646c86e6fd32d2c2a7933d27 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:15:02 -0700 Subject: [PATCH 137/170] feat(ssh-setup-here): the destination upgrade service - mint, exec, redact, await ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14's plane half. Migration 0052 stores the relay pane's key home on the subshell row (the 30 s relay session is plane-memory; the act re-opens it minutes later and §6.3 disambiguates A only by what the launch chose), with the row update-shape omitting the column: immutable after create. setupHere walks the launcher's own doors (gate B, gate A, A-online named BEFORE any key or session), mints the ordinary single-use setup key, composes EXACTLY like the pane launch (relay render, exec's own derived dir), and drives ssh_exec through B's real command chain, polling status to the terminal answer. Output is re-redacted plane-side and parsed into controlled stage verbs; the egress refusal is its own code (SSH_UPGRADE_EGRESS, named cause distinct from any key error, pane untouched - the act never writes one, pinned). Success is the new node's ready frame observed in a bounded wait through the key row's consumedNodeId and the registry's agent facts, never the installer's cheer line. Every exit writes one node.ssh_upgrade.run row naming ids, destination, grant, exec id, and the outcome word; the key appears nowhere in it, in any refusal, or in a returned byte (belt test: a machine answering its own redaction failure still leaks nothing). Unspent keys are revoked on the way out. --- .../src/api/__tests__/install-script.test.ts | 2 +- .../__tests__/backup-cli-subprocess.test.ts | 17 +- apps/server/api/src/db/migrate.ts | 5 + .../db/migrations/0052-subshell-key-home.ts | 22 + .../__tests__/0052-subshell-key-home.test.ts | 100 ++++ .../__tests__/repositories.test.ts | 2 + .../__tests__/workspaces-repository.test.ts | 2 + .../db/repositories/subshells.repository.ts | 3 + .../api/src/db/types/subshells.db-types.ts | 14 +- .../__tests__/ssh-setup-here.service.test.ts | 545 ++++++++++++++++++ .../subshell-manager-maintenance.test.ts | 2 + .../__tests__/subshell-manager-mcp.test.ts | 2 + .../__tests__/subshell-manager-remote.test.ts | 2 + .../subshell-manager-titling.test.ts | 2 + .../subshell-manager.service.test.ts | 2 + .../api/src/services/backups/database.ts | 2 +- apps/server/api/src/services/nodes/ssh-rpc.ts | 62 ++ .../api/src/services/ssh-launch.service.ts | 31 +- .../src/services/ssh-setup-here.service.ts | 466 +++++++++++++++ .../src/services/subshell-manager.service.ts | 10 + packages/backend-errors/src/error-codes.ts | 24 + 21 files changed, 1299 insertions(+), 18 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0052-subshell-key-home.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts create mode 100644 apps/server/api/src/services/ssh-setup-here.service.ts diff --git a/apps/server/api/src/api/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts index f28a8e50c..456e59417 100644 --- a/apps/server/api/src/api/__tests__/install-script.test.ts +++ b/apps/server/api/src/api/__tests__/install-script.test.ts @@ -53,7 +53,7 @@ beforeAll(async () => { afterAll(async () => { for (const id of keyIds) await repo.deleteById(id, userId).catch(() => {}); - await deleteUserByEmailOrId({ email }).catch(() => {}); + await deleteUserByEmailOrId(email).catch(() => {}); }); describe("install.sh 404 advice branch (spec 2026-10-08 §7)", () => { diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 20f49f0d0..0a559682e 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049+0050+0051 together: this era's five migrations are the - // tail of the registered ledger, so the prefix runs contiguously through 0046 - // and boot re-applies all five. Advancing LATEST_BACKUP_MIGRATION (0051) + // WHY 0047+0048+0049+0050+0051+0052 together: this era's six migrations are + // the tail of the registered ledger, so the prefix runs contiguously through + // 0046 and boot re-applies all six. Advancing LATEST_BACKUP_MIGRATION (0052) // means the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') ORDER BY name", ) .all(), ).toEqual([ @@ -439,6 +439,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0049-ssh-relay-identity" }, { name: "0050-ssh-grants" }, { name: "0051-ssh-request-destination" }, + { name: "0052-subshell-key-home" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index d043abfdf..cc71e22fe 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -51,6 +51,7 @@ import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-h import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; /** * Runs all pending Kysely migrations against the app database. @@ -154,6 +155,10 @@ export async function runMigrations(): Promise { // approval that creates the grant can capture the host-key pin at the // exact `user@host:port` the launch dialed (spec 2026-10-08 §9). "0051-ssh-request-destination": sshRequestDestinationMigration, + // The relay pane's key home (spec 2026-10-08 §7): "Set up Subshell + // here" re-opens the pairing that authorized the pane, so the row + // carries the A it launched with. + "0052-subshell-key-home": subshellKeyHomeMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0052-subshell-key-home.ts b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts new file mode 100644 index 000000000..0c1a191ab --- /dev/null +++ b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely"; + +/** + * The relay pane remembers its key home (spec 2026-10-08 §7, Task 14). A + * relay launch chose its key home A at the door that could name it, and the + * relay session itself is plane-memory with a 30 s lifetime; "Set up Subshell + * here" RE-OPENS that pairing minutes or hours later, and the only durable + * fact of which A authorized the pane was the launch's audit row. The act may + * not re-derive A from the grant table (several key homes can match one + * selector, and §6.3 disambiguates them by the A chosen at launch), so the + * pane row carries it: NULL on every direct (M1) pane, the A node id on every + * relay pane. Immutable by construction (it joins the omit-list of the row + * update shape), because no later act rewrites which machine authorized the + * connection it opened with. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("subshells").addColumn("key_home_node_id", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("key_home_node_id").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts new file mode 100644 index 000000000..2ffee7b28 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + subshells: Record; + user: Record; +} + +/** + * Task 14's one storage change: `subshells` gains `key_home_node_id`, the + * relay pane's key home A - the fact "Set up Subshell here" needs to re-open + * the pairing that authorized this pane, minutes or hours after the 30 s + * relay session itself is gone. NULL on every direct pane; the re-open act + * names A-online as its own refusal cause, so the column must exist for that + * reading, not be re-derived from the grant table. + */ +describe("migration 0052-subshell-key-home", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0052-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + await sshLaunchMigration.up(db); + await keyHomeMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds key_home_node_id as a nullable column on the subshell row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('subshells') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("key_home_node_id"); + expect(cols.rows.find((r) => r.name === "key_home_node_id")?.notnull).toBe(0); // direct panes carry null + }); + + it("a row written without the column reads null; a relay row round-trips the A id", async () => { + await db + .insertInto("subshells") + .values({ + id: "pane-direct", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-direct", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + } as never) + .execute(); + await db + .insertInto("subshells") + .values({ + id: "pane-relay", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-relay", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + key_home_node_id: "node-a", + } as never) + .execute(); + const rows = await db.selectFrom("subshells").select(["id", "key_home_node_id"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "pane-direct", key_home_node_id: null }, + { id: "pane-relay", key_home_node_id: "node-a" }, + ]), + ); + }); + + it("down() removes only the new column", async () => { + await keyHomeMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("key_home_node_id"); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts index 5f180feb7..e68417732 100644 --- a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts @@ -15,6 +15,7 @@ import * as presetsMigration from "@/db/migrations/0027-presets.js"; import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { RecentPathsRepository } from "@/db/repositories/recent-paths.repository.js"; @@ -53,6 +54,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts index 373bd7788..e99bed474 100644 --- a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts @@ -17,6 +17,7 @@ import * as workspaceDraftsMigration from "@/db/migrations/0029-workspace-drafts import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -77,6 +78,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/subshells.repository.ts b/apps/server/api/src/db/repositories/subshells.repository.ts index 7edb7f6de..9c8195d51 100644 --- a/apps/server/api/src/db/repositories/subshells.repository.ts +++ b/apps/server/api/src/db/repositories/subshells.repository.ts @@ -64,6 +64,9 @@ export class SubshellsRepository extends BaseRepository { // Node pin defaults in the DB (migration 0017); mirror it so the typed // insert is complete and the row reads back whole. nodeId: subshell.nodeId ?? LOCAL_NODE_ID, + // The relay key home (migration 0052); a direct pane's row carries the + // column's own NULL default. + keyHomeNodeId: subshell.keyHomeNodeId ?? null, // Cross-agent provenance defaults to "human" (migration 0039); mirror it. crossAgent: subshell.crossAgent ?? 0, createdAt: new Date().toISOString(), diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index aba033df6..b36b40e81 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -85,6 +85,15 @@ export interface SubshellTable { * answer included. See migration 0048. */ ssh: string | null; + /** + * For a relay-mode ssh pane: the key home whose agent signed the + * connection, chosen at launch and IMMUTABLE after (spec 2026-10-08 §7, + * migration 0052). "Set up Subshell here" re-opens that pairing to reach + * the destination again, and the 30 s relay session itself is plane-memory + * by then - this column is the pane's only durable word on which A + * authorized it. NULL on every direct (M1) pane. + */ + keyHomeNodeId: string | null; /** * DEPRECATED (spec 2026-09-03): the terminal history cap moved per-USER * (`user_meta.terminal_replay_lines`, migration 0020). This column is @@ -121,6 +130,7 @@ export type NewSubshell = Omit< | "nodeId" | "crossAgent" | "ssh" + | "keyHomeNodeId" | "terminalReplayLines" > & { /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ @@ -131,6 +141,8 @@ export type NewSubshell = Omit< crossAgent?: number; /** SSH snapshot JSON for an ssh pane; omitted = NULL = not an ssh pane (migration 0048) */ ssh?: string | null; + /** Relay pane's key home; omitted = NULL = direct (migration 0052) */ + keyHomeNodeId?: string | null; /** Per-subshell terminal replay cap; omitted = NULL = instance default */ terminalReplayLines?: number | null; harnessSessionId?: string | null; @@ -158,4 +170,4 @@ export type NewSubshell = Omit< * the restart refusal's trigger, and the delete sweep's pointer. No later * act may rewrite or clear it, so no update can name it. */ -export type SubshellUpdate = Partial>; +export type SubshellUpdate = Partial>; diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts new file mode 100644 index 000000000..6c00f2580 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -0,0 +1,545 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { buildAgentSocketPath, buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire, SshExecCommand } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { setSshSetupHereDepsForTests, setupHere } from "@/services/ssh-setup-here.service.js"; +import { attachScriptedNode, SCRIPTED_DATA_DIR, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * The "Set up Subshell here" service (spec 2026-10-08 §7, Task 14). What + * this suite exists to pin, the brief's five arms first: + * + * (b) a non-egressing D answers the NAMED egress cause (its own code) and + * the working pane row is untouched - this act never writes one; + * (c) relay mode RE-OPENS the relay (the broker sees a fresh pairing keyed + * to the exec's ephemeral id, not the pane's), and an A that is offline + * refuses naming the key home's online-ness before any key or session + * exists; + * (d) success is the new node's `ready` (the registry fact) observed inside + * the bounded wait, not the installer's cheer line; + * (e) a captured line containing `nsk_` never reaches the returned answer, + * any refusal copy, or the `node.ssh_upgrade.run` audit row. + * + * B answers over the REAL sendCommand chain through the scripted node (the + * emitted frames pass the real wire grammar); the relay broker is the fake + * the grants suite uses; every wait runs on injected seams. The key the test + * recovers from the exec command string is the mint's plaintext - which is + * what makes the leak assertions mean something. + */ + +const HOST = "d.example.test"; +const PIN_LINE = "d.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const DEST = sshCanonicalDestination({ host: HOST, port: 22, user: null }); +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: HOST, + host: HOST, + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const emails: string[] = []; +const createdPanes: string[] = []; +const createdNodes: string[] = []; +const nodes = new NodesRepository(db); +const keys = new NodeSetupKeysRepository(db); +let owner = ""; +let stranger = ""; +const NODE_A = "setup-node-a"; +const NODE_B = "setup-node-b"; + +/** The relay broker fake: opens recorded, closes recorded, socket answer derived. */ +let openCalls: { paneId: string; aNode: string; bNode: string; grantId: string; hostPin: string }[] = []; +let closeCalls: { paneId: string; reason: string }[] = []; +let fakeBroker: RelayBroker; + +function makeFakeBroker(): RelayBroker { + return { + openRelay: async (input: { paneId: string; aNode: string; bNode: string; grantId: string; hostPin: string }) => { + openCalls.push(input); + return { + relayId: `relay-${openCalls.length}`, + ref: `ref-${openCalls.length}`, + expiresAt: new Date(Date.now() + 30_000).toISOString(), + // In-contract answer: the plane byte-checks this against the same + // derivation the node's relay-open bound. + socketPath: buildAgentSocketPath(SCRIPTED_DATA_DIR, input.paneId), + }; + }, + closeForPane: async (paneId: string, reason: string) => { + closeCalls.push({ paneId, reason }); + return 1; + }, + } as unknown as RelayBroker; +} + +function installDeps(): void { + setSshSetupHereDepsForTests({ + nowMs: () => Date.now(), + sleepMs: () => new Promise((r) => setTimeout(r, 1)), + execTimeoutMs: 600_000, + execPollMarginMs: 50, + readyBudgetMs: 1_000, + pollMs: 1, + broker: () => fakeBroker, + }); +} + +async function mkNode(id: string): Promise { + await nodes.create({ id, ownerUserId: owner, name: id, kind: "agent", status: "offline" }); + createdNodes.push(id); + await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-08T09:00:00.000Z" }); + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +async function mkPane(opts: { keyHome: boolean; ssh?: string | null }): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId: owner, + harnessId: "ssh", + name: `pane-${id.slice(0, 8)}`, + workingDir: "/home/scripted", + tmuxSocket: null, + nodeId: NODE_B, + // `??` would read a deliberate null as absent; the test's null IS the value. + ssh: opts.ssh === undefined ? JSON.stringify(SNAP()) : opts.ssh, + keyHomeNodeId: opts.keyHome ? NODE_A : null, + }); + createdPanes.push(id); + return id; +} + +/** The standing grant + pin + identities every relay leg reads (fixture, once). */ +async function authorizeRelay(): Promise { + const now = new Date().toISOString(); + await new SshGrantsRepository(db).insertGrant({ + id: crypto.randomUUID(), + ownerUserId: owner, + name: HOST, + keyHomeNodeId: NODE_A, + resolvedSelector: HOST, + fingerprints: JSON.stringify(["SHA256:AAAA"]), + createdVia: "manual", + createdAt: now, + updatedAt: now, + }); + await db + .insertInto("sshHostPins") + .values({ + id: crypto.randomUUID(), + ownerUserId: owner, + destination: DEST, + hostKey: PIN_LINE, + createdAt: now, + updatedAt: now, + }) + .execute(); + const identities = new IdentitiesRepository(db); + for (const [n, half] of [ + [NODE_A, "AAA"], + [NODE_B, "EEE"], + ] as const) { + await identities.register({ + principalId: `node:${n}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + signingPublicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + displayName: n, + }); + } +} + +/** The key the act minted, recovered from the exec command the scripted machine saw. */ +function mintedKey(cmd: SshExecCommand | undefined): string { + const m = cmd?.command.match(/setup_key=(nsk_[A-Za-z0-9_-]{32})/); + if (!m) throw new Error("the exec command carried no minted key"); + return m[1]; +} + +async function upgradeRows(): Promise<{ action: string; metadataJson: string | null }[]> { + return await db + .selectFrom("auditEvents") + .select(["action", "metadataJson"]) + .where("action", "=", "node.ssh_upgrade.run") + .execute(); +} + +/** + * Attach the scripted machines: B answers the two exec arms from the caller's + * controller (kick recorded for the status arm to read back). A is attached + * silently ONLY when the test wants a live key home. + */ +function attachPair(impl: { + onKick?: (cmd: SshExecCommand) => void | Promise; + status: (kick: SshExecCommand | undefined) => unknown; + withA: boolean; +}): { b: ScriptedNode; a: ScriptedNode | null; kick: () => SshExecCommand | undefined } { + let seen: SshExecCommand | undefined; + const a = impl.withA ? attachScriptedNode(NODE_A, {}) : null; + const b = attachScriptedNode(NODE_B, { + ssh_exec: async (cmd) => { + if (cmd.type !== "ssh_exec") throw new Error("wrong arm"); + seen = cmd; + await impl.onKick?.(cmd); + return { started: true, execId: cmd.execId }; + }, + ssh_exec_status: (cmd) => { + if (cmd.type !== "ssh_exec_status") throw new Error("wrong arm"); + return impl.status(seen); + }, + }); + return { b, a, kick: () => seen }; +} + +/** The install's success answer as the scripted machine states it. */ +const DONE_OK = { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" } as const; + +/** Stand-in for D's enrollment completing: spend the minted key, bring D's `ready` up. */ +async function enrollD(key: string): Promise<{ id: string; handle: ScriptedNode }> { + const id = crypto.randomUUID(); + await nodes.create({ id, ownerUserId: owner, name: `d-${id.slice(0, 8)}`, kind: "agent", status: "offline" }); + const spent = await keys.consume(key, id); + if (!spent) throw new Error("the fixture could not consume the minted key"); + return { id, handle: attachScriptedNode(id, {}) }; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + const mk = async (email: string): Promise => { + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("setup-here-1"), + role: "user", + }); + }; + owner = await mk(`setup-owner-${crypto.randomUUID()}@subshell.local`); + stranger = await mk(`setup-other-${crypto.randomUUID()}@subshell.local`); + await mkNode(NODE_A); + await mkNode(NODE_B); + await authorizeRelay(); +}); + +afterAll(async () => { + setSshSetupHereDepsForTests(null); + setRelayBrokerForTests(null); + resetNodeRegistryForTests(); + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("identities").execute(); + await db.deleteFrom("nodeSetupKeys").execute(); + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); + for (const id of createdNodes) await nodes.deleteById(id).catch(() => {}); + for (const e of emails) await deleteUserByEmailOrId(e).catch(() => {}); +}); + +beforeEach(() => { + openCalls = []; + closeCalls = []; + fakeBroker = makeFakeBroker(); + setRelayBrokerForTests(fakeBroker); + installDeps(); +}); + +/** Each act's trail row is consumed by the test that wrote it; start clean. */ +async function clearUpgradeRows(): Promise { + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); +} + +describe("setupHere door order (pane first, gates before any key)", () => { + it("a foreign or absent pane is the 404 the ownership axis demands", async () => { + const paneId = await mkPane({ keyHome: true }); + const foreign = await setupHere({ viewerId: stranger, paneId }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + const absent = await setupHere({ viewerId: owner, paneId: crypto.randomUUID() }); + expect(absent.ok).toBe(false); + if (absent.ok) return; + expect(absent.refusal.status).toBe(404); + }); + + it("a non-ssh pane refuses by name", async () => { + const paneId = await mkPane({ keyHome: false, ssh: null }); + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.message).toContain("SSH-terminal pane"); + }); +}); + +describe("setupHere relay mode", () => { + it("a non-egressing D answers the NAMED egress cause and leaves the pane untouched", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: true, + status: () => ({ + state: "done", + code: 1, + timedOut: false, + stdout: "", + stderr: + "subshell: could not reach http://plane.invalid; nothing was installed (/home/u/.local/bin/subshell untouched).", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.status).toBe(409); + if (res.refusal.status !== 409) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(res.refusal.message).toContain("could not reach"); + expect(res.refusal.message).toContain("untouched"); + // The working pane: the row is unchanged, and B saw ONLY the exec arms + // - no input, no terminate, no launch, no touch of any kind. + const row = await new SubshellsRepository(db).findById(paneId); + expect(row?.alive).toBe(1); + expect(row?.status).toBe("running"); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + // The relay was opened once and closed with the named child-exit word. + expect(openCalls).toHaveLength(1); + expect(closeCalls).toEqual([{ paneId: pair.kick()?.execId, reason: "child-exit" }]); + // The unspent key is revoked on the way out; the trail names the cause. + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("an offline A refuses naming the key home online, before any key or relay exists", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + // A has NO scripted connection: getLive(A) is null, the offline class. + const pair = attachPair({ withA: false, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(res.refusal.message).toContain(NODE_A); + expect(res.refusal.message).toMatch(/online/i); + expect(openCalls).toHaveLength(0); // never re-opened + expect(pair.b.cmdTypes()).toEqual([]); // B was never even asked to exec + expect((await keys.listByUser(owner)).length).toBe(keysBefore); // no key minted + } finally { + pair.b.detach(); + } + }); + + it("success is the new node's ready: the relay re-opens under the exec id, the bounded wait observes the fact", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + let polls = 0; + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: true, + onKick: (cmd) => { + // D's enrollment lands a beat AFTER the kick, while the act is + // already polling: the wait observes it, no test-side sleep races. + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => { + polls += 1; + // Keep the exec "running" for two polls; the ready wait then finds + // the spend + the live `ready` facts through the service's own loop. + if (polls <= 2) return { state: "running" }; + return DONE_OK; + }, + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const nodeId = (d as { id: string; handle: ScriptedNode } | null)?.id ?? ""; + expect(nodeId).not.toBe(""); + expect(res.value).toEqual({ nodeId }); + // The relay was RE-OPENED for this act, keyed to the exec's ephemeral + // id (never the pane's), carrying the destination's pin; closed with + // the named word once the exec's child was gone. + const execId = pair.kick()?.execId; + expect(execId).toBeDefined(); + expect(execId).not.toBe(paneId); + expect(openCalls).toHaveLength(1); + expect(openCalls[0]?.paneId).toBe(execId); + expect(openCalls[0]?.aNode).toBe(NODE_A); + expect(openCalls[0]?.bNode).toBe(NODE_B); + expect(openCalls[0]?.hostPin).toBe(PIN_LINE); + expect(closeCalls).toEqual([{ paneId: execId, reason: "child-exit" }]); + // The composed run: the exec's OWN config dir, BatchMode first, the + // installer one-liner with the destination host as the scripted name. + const kick = pair.kick(); + expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + expect(kick?.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); + expect(kick?.presetFlags).toContain("--"); + expect(kick?.relay).toBe(true); + expect(kick?.agentSocketPath).toBeNull(); + expect(kick?.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); + expect(kick?.command).toContain("SUBSHELL_NODE_NAME="); + expect(kick?.command).toContain(HOST); + expect(polls).toBeGreaterThanOrEqual(3); + // Audit: exactly one enrolled row naming ids, never the key. + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + const meta = JSON.parse(audits[0]?.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ + paneId, + bNodeId: NODE_B, + aNodeId: NODE_A, + destination: DEST, + outcome: "enrolled", + cause: "ready", + newNodeId: nodeId, + }); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(JSON.stringify(res)).not.toContain("nsk_"); + d?.handle.detach(); + await nodes.deleteById(nodeId).catch(() => {}); + } finally { + d?.handle.detach(); + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("a status answer carrying the minted key leaks NOTHING: belt redaction covers refusal, response, and trail", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const pair = attachPair({ + withA: true, + status: (kick) => ({ + state: "done", + code: 1, + timedOut: false, + // The machine ANSWERS with its own hygiene failing: the plane's belt + // must hold anyway - no `nsk_` byte is kept, returned, or repeated. + stdout: `KEY="${mintedKey(kick)}" here it is\nsubshell: the install exploded`, + stderr: "", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const key = mintedKey(pair.kick()); + const serialized = JSON.stringify(res); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(key); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(audits[0]?.metadataJson).not.toContain(key); + expect(audits[0]?.metadataJson).toContain("install-exit-1"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("install finished but the key never enrolled: named failure, key revoked", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ withA: true, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(res.refusal.message).toContain("without enrolling"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + await clearUpgradeRows(); + pair.a?.detach(); + pair.b.detach(); + } + }); +}); + +describe("setupHere direct mode (B's own keys, no relay)", () => { + it("a direct pane's exec carries no relay, and the snapshot's socket is the one scoped key", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ + keyHome: false, + ssh: JSON.stringify(SNAP({ authAgentSocket: "/run/user/700/agent.sock" })), + }); + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: false, // direct: A must NEVER be part of this act + onKick: (cmd) => { + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => DONE_OK, + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const kick = pair.kick(); + expect(kick?.relay).toBe(false); + expect(kick?.agentSocketPath).toBe("/run/user/700/agent.sock"); + expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + // The direct pane's exec never touched the relay broker, and A's live + // absence was irrelevant to it (no key-home gate was walked). + expect(openCalls).toHaveLength(0); + expect(closeCalls).toHaveLength(0); + d?.handle.detach(); + await nodes.deleteById(d?.id ?? "").catch(() => {}); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts index 7529a2151..8aa336e65 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts @@ -25,6 +25,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -187,6 +188,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 69ead9c15..df4fdfed5 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -23,6 +23,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -147,6 +148,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts index f05409fbc..0ff6b6c89 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts @@ -21,6 +21,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -89,6 +90,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index d25377ae8..3e627248c 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -23,6 +23,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -116,6 +117,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index 72802b5f2..a518b7020 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -24,6 +24,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -156,6 +157,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh — SubshellsRepository.create writes it on EVERY create now (null for a non-ssh pane), and the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(db); subshellsRepo = new SubshellsRepository(db); subshellManager = new SubshellManagerService({ diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 9c23198d3..0b8dc26f9 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0051-ssh-request-destination"; +export const LATEST_BACKUP_MIGRATION = "0052-subshell-key-home"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index b5534b9e7..dce984a87 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,10 +1,14 @@ import { type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, + type NodeSshExecKickResult, + type NodeSshExecStatusResult, type NodeSshHostKeyResult, type NodeSshResolveOutcomeWire, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -180,3 +184,61 @@ export async function sshAgentIdentities(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_exec", ...args }, { timeoutMs: 30_000 }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecKick(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec ack`, nodeId); + } + return parsed; +} + +/** + * Poll one machine for the state of a kicked setup run. The answer's streams + * are the machine's OWN redacted capture (Task 14's node-side rule); the + * CALLER redacts again before keeping or speaking a word of them - a machine + * answer about its own hygiene is never trusted (the socket-path doctrine, + * applied to output). + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshExecStatus(nodeId: string, execId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecStatus(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec status`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index f6d22f13d..c9e2ba8a0 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -216,8 +216,13 @@ export function sweepLocalSshDir(row: Pick> { +/** + * The acting user's SSH-usable gate on one node, or the refusal that closes + * the request. Exported because the §7 setup-here act re-runs EXACTLY this + * door (B and A alike) before it touches either machine - one gate, one + * spelling of every cause. + */ +export async function gateSshNode(viewerId: string, nodeId: string): Promise> { // Invisible and absent collapse to one 404 (the node-gate doctrine: an // invisible node must never answer 403, so ids cannot be probed). const gate = await loadNodeGate(viewerId, nodeId); @@ -256,8 +261,13 @@ function gateCause(row: NodeTable, serverAccountEnabled: boolean): string { return `Subshell SSH on ${machine} is reserved to its owner; a shared machine stays the owner's to SSH from.`; } -/** The machine refused or could not answer; the code family, never the agent's own text. */ -function rpcRefusal(err: SshRpcError): SshAnswer { +/** + * The machine refused or could not answer; the code family, never the + * agent's own text. Exported for the §7 setup-here act, whose ssh_exec RPCs + * must map failures through the SAME doors (spec §12's naming doctrine: one + * cause, one sentence, wherever it was reached). + */ +export function rpcRefusal(err: SshRpcError): SshAnswer { if (err.kind === "offline") { return codedRefusal( 409, @@ -339,8 +349,8 @@ async function nodeResolve(row: NodeTable, destination: string): Promise'` that reuses the pane's authorization and leaves the + * pane itself untouched. The plan, in the order it runs: + * + * 1. THE PANE: owner's row (a foreign id is the 404 the ownership axis + * demands), its approved snapshot (D), and its key home column (0052): the + * pane was a relay pane exactly when it names an A. + * 2. THE GATES: B re-gated through the launcher's own door; in relay mode A + * too, AND live - the act signs with A's agent, so an offline key home is + * refused with A-online NAMED before a key is minted or a pane is touched. + * 3. THE KEY: a fresh single-use setup key, minted by the ordinary + * repository, owned by the caller (the new node's future owner). It is + * never typed anywhere a pane or a log can read: it rides the signed + * node-link command, then B's short-lived ssh argv, then D's install URL - + * the three accepted postures §7 names - and nothing else. The act's + * refusal copy, its audit rows, and every retained byte pass through the + * redactor (belt on the plane: the machine's own redaction is the first + * station, this one does not trust it). + * 4. THE RUN: `ssh_exec` on B - compose exactly like the pane launch + * (relay-mode render pins D's host key the same way), kick it off B's + * command chain, and poll `ssh_exec_status` to the terminal answer. The + * answer's output is PARSED into a controlled status verb; raw installer + * output is never echoed to a caller. + * 5. THE EGRESS CAUSE: an install that could not reach the plane answers + * `SSH_UPGRADE_EGRESS` - a code of its own, distinct from any key error, + * per §7. The pane is untouched either way (this act never writes one). + * 6. SUCCESS: the new node's `ready` frame, awaited with a bounded wait + * (the setup key's row carries the node id enrollment consumed it for; + * the registry's `agent` facts appear only when `ready` lands). Success + * is that fact, not the installer's own cheer line. + * + * Every exit writes ONE `node.ssh_upgrade.run` audit row naming ids, the + * destination, the grant, and the OUTCOME/stage - never the key, never the + * installer's words. + */ + +/** How long B's ssh child may run before the node-side deadline ends it. An installer pulls a ~100 MB binary; this is minutes, honestly. */ +export const SSH_SETUP_EXEC_TIMEOUT_MS = 8 * 60_000; + +/** The plane's poll margin past the node's own deadline (the deadline kills the child; the margin only decides when we stop asking). */ +const EXEC_POLL_MARGIN_MS = 60_000; + +/** The bounded wait for the new node to enroll (spend the key) and report `ready` after the installer exited 0. */ +export const SETUP_READY_BUDGET_MS = 60_000; + +/** The poll cadence for both waits; tests collapse it to zero with the injected clock. */ +const SETUP_POLL_MS = 1_500; + +/** The service's world. Production at {@link defaultDeps}; tests install a fake. */ +export interface SshSetupHereDeps { + /** Epoch-ms clock driving both bounded waits. */ + nowMs(): number; + /** Sleep between polls (production: real timers). */ + sleepMs(ms: number): Promise; + /** The node's ssh child deadline for this act (production: {@link SSH_SETUP_EXEC_TIMEOUT_MS}). */ + execTimeoutMs: number; + /** The plane's poll margin past that deadline (production: {@link EXEC_POLL_MARGIN_MS}). */ + execPollMarginMs: number; + /** The enroll+ready budget after a clean install (production: {@link SETUP_READY_BUDGET_MS}). */ + readyBudgetMs: number; + /** Poll cadence (production: {@link SETUP_POLL_MS}). */ + pollMs: number; + /** The relay broker the act's own pairing is closed through. */ + broker(): RelayBroker; +} + +let depsOverride: SshSetupHereDeps | null = null; + +function setupDeps(): SshSetupHereDeps { + if (depsOverride) return depsOverride; + return { + nowMs: () => Date.now(), + sleepMs: (ms) => new Promise((r) => setTimeout(r, ms)), + execTimeoutMs: SSH_SETUP_EXEC_TIMEOUT_MS, + execPollMarginMs: EXEC_POLL_MARGIN_MS, + readyBudgetMs: SETUP_READY_BUDGET_MS, + pollMs: SETUP_POLL_MS, + broker: () => getRelayBroker(), + }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - no suite waits eight minutes for a child or sixty + * seconds for a `ready` frame. + */ +export function setSshSetupHereDepsForTests(deps: SshSetupHereDeps | null): void { + depsOverride = deps; +} + +function coded(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +/** What a completed act answers: the enrolled node's id, learned from `ready`. */ +export interface SetupHereResult { + nodeId: string; +} + +/** + * Run the act for one pane. The caller is the pane's owner (a cookie actor; + * a foreign or absent pane is one 404, the ordinary invisibility). Refusals + * name their stage; the key never rides one. + */ +export async function setupHere(args: { viewerId: string; paneId: string }): Promise> { + const deps = setupDeps(); + const pane = await db.selectFrom("subshells").selectAll().where("id", "=", args.paneId).executeTakeFirst(); + if (!pane || pane.userId !== args.viewerId) { + return coded(404, BackendErrorCodes.NOT_FOUND_ERROR, "Subshell not found"); + } + if (pane.ssh === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Setting up Subshell here is an act on an SSH-terminal pane; this pane is not one.", + ); + } + let snapshot: SshConnectionSnapshotWire | null = null; + try { + snapshot = parseSshConnectionSnapshot(JSON.parse(pane.ssh) as unknown); + } catch { + snapshot = null; + } + if (snapshot === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This pane's approved destination could not be read back from its row; open the connection again and run the act fresh.", + ); + } + + // Gates first (B, then A in relay mode): every refusal below writes no key, + // opens no session, and leaves the pane running. + const gateB = await gateSshNode(args.viewerId, pane.nodeId); + if (!gateB.ok) return gateB; + if (gateB.value.row.kind !== "agent") { + // The server host has no agent socket to carry an exec; §7's act runs on + // the Subshell app's machine, so a direct pane launched FROM the plane + // host refuses by name rather than inventing an in-process path. + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Setting up Subshell here runs from the Subshell app on the connecting machine; the server host is not a connecting machine for it.", + ); + } + const dataDirB = targetDataDir(gateB.value.row); + if (dataDirB === null) { + return coded( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has not reported its data directory yet; retry once it is fully connected.", + ); + } + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + + // The act's own ephemeral id: the config dir, the pinned-file path, the + // relay socket, and the exec registry key ALL derive from it. It is not the + // pane's id - the pane's config and socket stay exactly as they are. + const execId = crypto.randomUUID(); + + let grantId: string | null = null; + let aNodeId: string | null = null; + const keyHome = pane.keyHomeNodeId; + if (keyHome !== null && keyHome !== undefined) { + aNodeId = keyHome; + const gateA = await gateSshNode(args.viewerId, keyHome); + if (!gateA.ok) return gateA; + if (gateA.value.row.kind !== "agent") { + return coded( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + "The server host itself cannot hold a relay key home; this pane was not opened through one.", + ); + } + if (!getLive(keyHome)) { + return coded( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + `Setting up Subshell here signs with ${gateA.value.row.name}'s keys over the relay, and that key home must be online for it. Bring it online and run the act again.`, + ); + } + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: gateA.value.row.id, name: gateA.value.row.name }, + bNodeId: pane.nodeId, + resolvedHost: snapshot.host, + destination, + // The pairing serves the EXEC, so the ephemeral id is its paneId: the + // proxy socket and the delivered host pin land in the exec's own + // directory, never the pane's. + paneId: execId, + }); + if (!leg.ok) return leg; + grantId = leg.value.grantId; + } + + // The single-use key, minted only once every door above has opened. + const keys = new NodeSetupKeysRepository(db); + const keyRow = await keys.create(args.viewerId); + + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDirB, + subshellId: execId, + ...(grantId !== null ? { hostPinPath: buildSshKnownHostsPath(dataDirB, execId) } : {}), + }); + // BatchMode first: the exec must never fall back to a password or a + // host-key PROMPT - auth failure or pin mismatch is a fast refusal, an + // interactive prompt would sit out the deadline on a terminal no one holds. + const presetFlags = ["-o", "BatchMode=yes", ...composed.presetFlags]; + // The installer one-liner, exactly the copy-paste act §7 describes, with + // the scripted name (the pane's destination host) because the exec has no + // terminal to answer `setup`'s naming question. shellQuote every token; + // `bash -c` so pipefail exists and a failed fetch is not hidden by the + // empty-stdin pipe. + const installUrl = `${APP_BASE_URL}/install.sh?setup_key=${keyRow.key}`; + const nodeName = snapshot.host; + const remoteCommand = `bash -c ${shellQuote( + `set -o pipefail; curl -fsSL ${shellQuote(installUrl)} | SUBSHELL_NODE_NAME=${shellQuote(nodeName)} bash`, + )}`; + + // The audit this act OWNS: written at every exit with ids and the outcome + // only. Built once per path; the key never enters it (never interpolated). + const runAudit = async (outcome: "enrolled" | "refused", cause: string, newNodeId?: string): Promise => { + await audit({ + actorUserId: args.viewerId, + action: "node.ssh_upgrade.run", + targetType: "node", + targetId: pane.nodeId, + metadataJson: JSON.stringify({ + paneId: pane.id, + bNodeId: pane.nodeId, + ...(aNodeId !== null ? { aNodeId } : {}), + ...(grantId !== null ? { grantId } : {}), + destination, + execId, + outcome, + cause, // a STAGE word chosen by this file, never machine text + ...(newNodeId !== undefined ? { newNodeId } : {}), + }), + }); + }; + /** Best-effort: an unspent key is revoked when the act gives up; a spent one stands as enroll's record. */ + const revokeUnspentKey = async (): Promise => { + try { + const state = await keys.peekByKey(keyRow.key); + if (state && state.usedAt === null) await keys.deleteById(keyRow.id, args.viewerId); + } catch (err) { + logger.withError(err).warn(`setup-here: could not revoke the unspent key for pane ${pane.id}`); + } + }; + /** Close the exec's own relay pairing (the act's child is gone; the socket should be). */ + const closeExecRelay = async (): Promise => { + if (grantId === null) return; + try { + await deps.broker().closeForPane(execId, "child-exit"); + } catch (err) { + logger.withError(err).warn(`setup-here: relay close for exec ${execId} failed`); + } + }; + + // KICK. A refusal here has minted a key and opened a relay and must leave + // both as it found them (revoke the unspent key, cut the fresh session). + try { + await sshExec(pane.nodeId, { + execId, + configPath: composed.configPath, + fileContent: composed.fileContent, + presetFlags, + command: remoteCommand, + relay: grantId !== null, + agentSocketPath: grantId === null ? snapshot.authAgentSocket : null, + timeoutMs: deps.execTimeoutMs, + }); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + if (err instanceof SshRpcError) { + const r = rpcRefusal(err); + await runAudit("refused", `exec-${err.kind}`); + return r; + } + throw err; + } + + // POLL to the terminal answer. The node holds the deadline; the plane only + // stops asking past its own margin. + const pollDeadline = deps.nowMs() + deps.execTimeoutMs + deps.execPollMarginMs; + let terminal: Awaited>; + for (;;) { + try { + terminal = await sshExecStatus(pane.nodeId, execId); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + if (err instanceof SshRpcError) { + await runAudit("refused", `exec-lost-${err.kind}`); + return rpcRefusal(err); + } + throw err; + } + if (terminal.state === "done") break; + if (deps.nowMs() > pollDeadline) { + await closeExecRelay(); + await revokeUnspentKey(); + await runAudit("refused", "install-deadline"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install did not finish within its deadline on the connecting machine; nothing was enrolled by this act.", + ); + } + await deps.sleepMs(deps.pollMs); + } + await closeExecRelay(); + + // The belt redaction: the machine's answer is the first station's word, + // this plane keeps NOTHING raw. Output informs the verb and then goes. + const output = redactSshSetupKeyLines(`${terminal.stdout}\n${terminal.stderr}`); + const installOk = terminal.code === 0 && !terminal.timedOut; + + if (!installOk) { + await revokeUnspentKey(); + // Awaited, not fire-and-forget: the trail of the act that just refused + // must be readable when the caller sees the refusal (the `ssh.launch` + // posture inverted - there success audits last, here every exit audits + // BEFORE the answer leaves). + const fail = async (cause: string, refusal: SshAnswer): Promise> => { + await runAudit("refused", cause); + return refusal; + }; + if (terminal.timedOut) { + return await fail( + "install-timeout", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install ran out of time on the connecting machine; nothing was enrolled by this act.", + ), + ); + } + // The named causes, each from a phrase the rendered installer prints (the + // script's OWN words are the parser's contract; none of them is echoed). + if (output.includes("could not reach") || output.includes("could not fetch the checksum")) { + // §7's egress refusal: its own code, distinct from any key error, and + // the pane keeps running untouched. + return await fail( + "egress", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_EGRESS, + `The destination could not reach ${APP_BASE_URL}, so it cannot install Subshell. Nothing was enrolled; the connection and its pane are untouched.`, + ), + ); + } + if (output.includes("setup key was rejected")) { + return await fail( + "enroll-key", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The enrollment key was rejected during the install (invalid, expired, or already used); nothing was enrolled.", + ), + ); + } + if (output.includes("could not provide a")) { + return await fail( + "artifact", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This server had no node build for the destination's platform; supply it from the server's machine page and run the act again.", + ), + ); + } + if (output.includes("tmux is still missing") || output.includes("refuse to enroll")) { + return await fail( + "preflight-tmux", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The destination has no tmux, which every node needs; install it there and run the act again.", + ), + ); + } + if (output.includes("already have a node named")) { + return await fail( + "enroll-name", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "You already own a node named for this destination; delete or rename that machine entry and run the act again.", + ), + ); + } + return await fail( + `install-exit-${terminal.code ?? "signal"}`, + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install on the destination stopped before enrolling; nothing was enrolled by this act.", + ), + ); + } + + // Success is the new node's `ready`, not the installer's cheer. The setup + // key's row learns the node id enrollment spent it for; the registry's + // agent facts appear exactly when that node's `ready` frame lands. + const readyDeadline = deps.nowMs() + deps.readyBudgetMs; + for (;;) { + const spent = await keys.findById(keyRow.id); + const newNodeId = spent?.consumedNodeId ?? null; + if (newNodeId !== null && getLive(newNodeId)?.agent !== undefined) { + await runAudit("enrolled", "ready", newNodeId); + return { ok: true, value: { nodeId: newNodeId } }; + } + if (deps.nowMs() > readyDeadline) break; + await deps.sleepMs(deps.pollMs); + } + const spent = await keys.findById(keyRow.id); + if (spent?.consumedNodeId) { + // Enrolled but not connected: the daemon on D starts under its fresh + // service; the remedy is patience and the machine page, not a rerun. + await runAudit("refused", "ready-timeout", spent.consumedNodeId); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The destination enrolled, but it has not connected to this server yet; watch its machine page and retry only if it stays offline.", + ); + } + await revokeUnspentKey(); + await runAudit("refused", "enroll-missing"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install finished without enrolling a machine with the key this act minted; nothing was enrolled.", + ); +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 8e706a090..29467577c 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -146,6 +146,13 @@ export interface SshLaunchPlumbing { fileContent: string; /** The snapshot the resolver approved (grammar-validated at each boundary already). */ snapshot: SshConnectionSnapshotWire; + /** + * RELAY MODE (spec 2026-10-08 §7): the key home whose agent signed the + * connection, stored on the row (migration 0052) so "Set up Subshell here" + * can re-open the pairing after the relay session's own memory is gone. + * Absent on every direct launch. + */ + keyHomeNodeId?: string; } /** @@ -493,6 +500,9 @@ export class SubshellManagerService { // read its presence. NULL for every pane this call did not compose from // an approved snapshot. ssh: ssh ? JSON.stringify(ssh.snapshot) : null, + // The relay pane's key home, recorded with the row (migration 0052): + // immutable thereafter, and no direct pane carries it. + keyHomeNodeId: ssh?.keyHomeNodeId ?? null, }); // The token is minted AFTER the row exists (issueSubshellToken writes the diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index b9fc93fad..fd65d874a 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -302,6 +302,22 @@ export enum BackendErrorCodes { * smuggled newline. Refused before any write (spec 2026-10-08 §9). */ SSH_HOST_PIN_INVALID = "SSH_HOST_PIN_INVALID", + /** + * The "Set up Subshell here" act's named EGRESS refusal (spec 2026-10-08 + * §7): the destination cannot reach the plane URL, so the install cannot + * run and no key was ever spent on an enrollment. Deliberately a code of + * its own: the spec requires this cause to read differently from any key + * error, and the working pane is untouched either way. + */ + SSH_UPGRADE_EGRESS = "SSH_UPGRADE_EGRESS", + /** + * The "Set up Subshell here" act stopped at a NAMED stage short of an + * enrolled, connected node (spec 2026-10-08 §7): the install failed, the + * enrollment was refused, or the new node never reported `ready` within + * the bounded wait. The message names which stage; the setup key is + * nowhere in it, and the interactive pane is untouched. + */ + SSH_UPGRADE_FAILED = "SSH_UPGRADE_FAILED", } /** @@ -608,4 +624,12 @@ export const BackendErrorCodeDefs = { message: "That host-key entry is not a valid known_hosts line", statusCode: 400, }, + [BackendErrorCodes.SSH_UPGRADE_EGRESS]: { + message: "The destination cannot reach this server, so it cannot install Subshell", + statusCode: 409, + }, + [BackendErrorCodes.SSH_UPGRADE_FAILED]: { + message: "Setting up Subshell here did not finish", + statusCode: 409, + }, }; From 06269279371854baf00f136e34c82fd63f3c7508 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:15:06 -0700 Subject: [PATCH 138/170] feat(ssh-setup-here): POST /api/ssh/setup-here, the destination-upgrade door The route is thin by design: cookie actor at the door (the launcher family's posture - a bearer cannot enroll machines), the service's refusal rendered as-is, and a 200 that answers the new node's id only because its ready frame landed. The 422 outcome arm cannot reach this surface (the destination was approved when the pane opened) and answers loudly rather than silently. docs/security.md 10 gains the node.ssh_upgrade.run family (ids, destination, grant, exec id, outcome word; never the setup key, never installer output), mirrored in the working rules summary. --- .claude/rules/security-context.md | 2 +- .../ssh/__tests__/setup-here-route.test.ts | 125 ++++++++++++++++++ apps/server/api/src/api/ssh/index.ts | 8 +- .../api/src/api/ssh/setup-here.route.ts | 74 +++++++++++ docs/security.md | 8 ++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts create mode 100644 apps/server/api/src/api/ssh/setup-here.route.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 0de45ab7d..7f8778387 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A/grant ids, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts new file mode 100644 index 000000000..00d0d59ec --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `POST /api/ssh/setup-here` - the HTTP surface of the destination upgrade + * (spec 2026-10-08 §7, Task 14). The act itself - re-open, exec, redaction, + * the bounded `ready` wait - is pinned by `ssh-setup-here.service.test.ts` + * against the real RPC chain; this file owns the doors in front of it: the + * surface is browser sessions only (the launcher family's posture), a body + * that is not a pane id is a 400, and a foreign pane is the 404 the + * ownership axis demands, identical to an absent one. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); +const port = "api/ssh/setup-here" as const; + +function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}): Promise { + const headers: Record = { "content-type": "application/json" }; + if (opts.cookie) headers.cookie = `better-auth.session_token=${opts.cookie}`; + if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`; + return app.fetch( + new Request(`http://localhost:3099/${port}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +const pw = "setup-route-1"; +const ownerEmail = `sh-route-o-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `sh-route-x-${crypto.randomUUID()}@subshell.local`; +let owner = ""; +let _other = ""; +let ownerCookie = ""; +let otherCookie = ""; +const createdPanes: string[] = []; + +async function mkPane(userId: string): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId, + harnessId: "terminal", + name: `route-probe-${id.slice(0, 8)}`, + workingDir: "/tmp", + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdPanes.push(id); + return id; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + owner = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + _other = await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + await ensureLocalNode(db); +}); + +afterAll(async () => { + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await new NodesRepository(db).deleteById(LOCAL_NODE_ID).catch(() => {}); + await deleteUserByEmailOrId(ownerEmail).catch(() => {}); + await deleteUserByEmailOrId(otherEmail).catch(() => {}); +}); + +describe("POST /api/ssh/setup-here doors", () => { + it("an empty cookie is a failed authentication (401), like every launcher door", async () => { + expect((await post({ paneId: "whatever" }, { cookie: "" })).status).toBe(401); + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + const paneId = await mkPane(owner); + const key = await issueSubshellToken(paneId, owner); + const res = await post({ paneId }, { bearer: key }); + expect(res.status).toBe(403); + }); + + it("a body without a pane id is a 400 INPUT_VALIDATION_ERROR, not a service call", async () => { + const res = await post({}, { cookie: ownerCookie }); + expect(res.status).toBe(400); + }); + + it("a foreign pane and an absent pane are one 404 (the ordinary invisibility)", async () => { + const paneId = await mkPane(owner); + const foreign = await post({ paneId }, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + const absent = await post({ paneId: crypto.randomUUID() }, { cookie: ownerCookie }); + expect(absent.status).toBe(404); + }); + + it("a non-ssh pane refuses by name through the service's own door", async () => { + const paneId = await mkPane(owner); + const res = await post({ paneId }, { cookie: ownerCookie }); + expect(res.status).toBe(409); + const body = (await res.json()) as { code?: string; message?: string }; + expect(body.message).toContain("SSH-terminal pane"); + }); +}); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 9c5351566..7e6988758 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -5,6 +5,7 @@ import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; +import { sshSetupHereRoute } from "@/api/ssh/setup-here.route.js"; import { sshTrustRoutes } from "@/api/ssh/trust.route.js"; /** @@ -31,4 +32,9 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) // pins a relay launch verifies D against. Mounted inside this group for the // same Elysia App-type-depth reason the grant tier is; these are the // grant layer's destination-trust half. - .use(sshTrustRoutes); + .use(sshTrustRoutes) + // The destination upgrade (spec 2026-10-08 §7, Task 14): "Set up Subshell + // here" turns an open pane's destination into an enrolled node. Mounted + // inside this group like every other ssh tier (App-type-depth rule); it is + // the launcher's own act on the launcher's own pane. + .use(sshSetupHereRoute); diff --git a/apps/server/api/src/api/ssh/setup-here.route.ts b/apps/server/api/src/api/ssh/setup-here.route.ts new file mode 100644 index 000000000..5cac4b174 --- /dev/null +++ b/apps/server/api/src/api/ssh/setup-here.route.ts @@ -0,0 +1,74 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { setupHere } from "@/services/ssh-setup-here.service.js"; + +/** + * `POST /api/ssh/setup-here` `{paneId}` - "Set up Subshell here" (spec + * 2026-10-08 §7, Task 14): enroll the destination of an open SSH-terminal + * pane as a node by running the ordinary install over a SEPARATE + * non-interactive connection, leaving the pane itself untouched. The act is + * owner-only by construction (a foreign pane is the service's 404), cookie- + * only like every launcher door, and its answer is the new node's id ONLY + * once that node's `ready` frame has landed - the installer's cheer line is + * not success, the connection is. The minted setup key exists nowhere in + * this response, its refusals, or the audit; the machine ran the install + * where the key is allowed to live (spec §7's accepted postures) and the + * plane kept only the parsed outcome. + */ +export const sshSetupHereRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/setup-here", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH setup acts are restricted to browser sessions"); + const answer = await setupHere({ viewerId: user.id, paneId: body.paneId }); + if (!answer.ok) { + const r = answer.refusal; + if (r.status === 422) { + // Unreachable: no arm of this act carries a resolve outcome (the + // destination was approved when the pane opened). An unreachable + // arm is answered loudly, never swallowed - the grants create's + // posture restated. + return throwCodedRefusal({ + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The setup surface cannot answer that refusal shape.", + }); + } + return throwCodedRefusal(r); + } + return answer.value; + }, + { + body: t.Object({ + paneId: t.String({ + minLength: 1, + maxLength: 64, + description: "The open SSH-terminal pane whose destination is to become a node (owner-only)", + }), + }), + response: { + 200: t.Object({ + nodeId: t.String({ description: "The newly enrolled node's id (its `ready` frame has landed)" }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshSetupHere", + tags: ["ssh"], + description: + "Runs the ordinary node enrollment install on an open SSH pane's destination over a separate non-interactive connection and answers the new node's id once it is online; the pane keeps running and the enrollment key never appears in the pane, its log, or any returned text", + }, + }, + ); diff --git a/docs/security.md b/docs/security.md index d45dda8a0..c0c78df43 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2305,6 +2305,14 @@ Audit events are written, grouped by family: the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: null`, because the human's act is the launch/grant trail), + `node.ssh_upgrade.run` (spec §7, Task 14: one row per "Set up Subshell + here" act, naming the pane, B, A and the grant on a relay run, the + canonical destination, the act's exec id, and the OUTCOME with a STAGE + word chosen by the service; the minted setup key appears nowhere - never + the metadata, never a refusal's copy, and no installer output at all: + the act parses the machine's redacted capture into the stage verb and + keeps nothing else; the single-use key row is the only durable record of + the credential, matching the `setup_key.create` no-metadata precedent), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart From a94138d1d939fe0e3e9a3e94e717c789bd1cdc67 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:18:08 -0700 Subject: [PATCH 139/170] feat(web): 'Set up Subshell here' action on the SSH pane The owner-only menu item (live SSH panes only) opens a one-question dialog that POSTs the pane id to /api/ssh/setup-here and holds open through the install. The server's own words answer it: the new node's page on success, the named refusal verbatim on failure, and the act button returns so a refusal can be retried. --- .../__tests__/setup-here-dialog.test.tsx | 149 ++++++++++++++++++ .../__tests__/subshell-actions-menu.test.tsx | 34 ++++ .../web/src/components/setup-here-dialog.tsx | 103 ++++++++++++ .../src/components/subshell-actions-menu.tsx | 21 +++ 4 files changed, 307 insertions(+) create mode 100644 apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx create mode 100644 apps/server/web/src/components/setup-here-dialog.tsx diff --git a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx new file mode 100644 index 000000000..e8ef0b884 --- /dev/null +++ b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SetupHereDialog } from "@/components/setup-here-dialog"; +import type { SubshellView } from "@/types/subshell"; + +/** Minimal ssh pane view (the menu fixture's shape) with overrides. */ +function makeSshPane(overrides: Partial = {}): SubshellView { + return { + id: "pane-1", + presetId: null, + harnessId: "ssh", + nodeId: "mac-mini", + nodeOffline: false, + name: "deploy box", + nameLocked: false, + workingDir: "/home/scripted", + status: "running", + createdAt: "2026-10-08T00:00:00.000Z", + endedAt: null, + lastOutputAt: null, + activity: "active", + alive: true, + exitCode: null, + startedAt: null, + backoffCount: 0, + restartOnExit: false, + nextRestartAt: null, + notify: false, + waitingSince: null, + unseenPush: false, + access: "owner", + ssh: true, + ...overrides, + }; +} + +describe("SetupHereDialog", () => { + afterEach(cleanup); + + /** fetch recorder; the setup POST answers per the case's script. */ + function mockFetch(answer: { status?: number; body: unknown }) { + const calls: { url: string; body: unknown }[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined }); + return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })) as never; + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; + } + + async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + } + + async function renderDialog(subshell: SubshellView, onOpenChange = (_: boolean) => {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const nodeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/nodes/$id", + component: () => null, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nodeRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + } + + it("asks once with a static title and two sentences, then posts the pane id", async () => { + const { calls, restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + await renderDialog(makeSshPane()); + expect(screen.getByText("Set up Subshell here?")).toBeTruthy(); + expect(screen.getByText(/keep running/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + const post = calls.find((c) => c.url === "/api/ssh/setup-here"); + expect(post?.body).toEqual({ paneId: "pane-1" }); + } finally { + restore(); + } + }); + + it("success names the enrolled machine and links to its page", async () => { + const { restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + let closed = false; + await renderDialog(makeSshPane(), () => (closed = true)); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.queryByText(/Installing on the destination/)).toBeNull()); + const link = screen.getByRole("link", { name: "Open its page" }) as HTMLAnchorElement; + expect(link.getAttribute("href")).toContain("/nodes/new-node-1"); + // Done leaves; the finished answer does not outlive the dialog (mount- + // while-open posture, checked at the menu level). + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(closed).toBe(true); + } finally { + restore(); + } + }); + + it("the server's named refusal is shown verbatim, in destructive, and the act can be retried", async () => { + const { calls, restore } = mockFetch({ + status: 409, + body: { + code: "SSH_UPGRADE_EGRESS", + message: + "The destination could not reach http://plane.test, so it cannot install Subshell. Nothing was enrolled; the connection and its pane are untouched.", + }, + }); + try { + await renderDialog(makeSshPane()); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.getByText(/could not reach/)).toBeTruthy()); + expect(screen.getByText(/untouched/)).toBeTruthy(); + // The act button comes back: a refused act is retriable (the key was + // minted and revoked fresh per run). + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + expect(calls.filter((c) => c.url === "/api/ssh/setup-here")).toHaveLength(2); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index 8e7702805..a9cb808b3 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -657,3 +657,37 @@ describe("SubshellActionsMenu — prompt input availability", () => { } }); }); + +describe("SubshellActionsMenu — Set up Subshell here (spec 2026-10-08 §7)", () => { + afterEach(cleanup); + + it("offers it on the owner's live SSH pane and opens the dialog", async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell({ ssh: true })); + await openMenu("subshell"); + fireEvent.click(screen.getByRole("menuitem", { name: "Set up Subshell here…" })); + expect(await screen.findByText("Set up Subshell here?")).toBeTruthy(); + } finally { + restore(); + } + }); + + for (const hidden of [ + { ssh: false }, + { ssh: true, access: "edit" as const }, + { ssh: true, access: "view" as const }, + { ssh: true, nodeOffline: true }, + ]) { + it(`does not offer it for ${JSON.stringify(hidden)}`, async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell(hidden)); + await openMenu("subshell"); + expect(screen.queryByRole("menuitem", { name: "Set up Subshell here…" })).toBeNull(); + } finally { + restore(); + } + }); + } +}); diff --git a/apps/server/web/src/components/setup-here-dialog.tsx b/apps/server/web/src/components/setup-here-dialog.tsx new file mode 100644 index 000000000..3863c2c48 --- /dev/null +++ b/apps/server/web/src/components/setup-here-dialog.tsx @@ -0,0 +1,103 @@ +import { apiFetch, Button, NODES_QUERY_KEY } from "@internal/node-admin"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { JSX } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { SubshellView } from "@/types/subshell"; + +/** + * "Set up Subshell here" (spec 2026-10-08 §7, Task 14): the secondary act + * on an open SSH-terminal pane that turns its destination into an enrolled + * node. The server runs the ordinary enrollment install over a separate + * non-interactive connection - the pane keeps running, and the enrollment + * key never enters it. The dialog asks once, holds itself open through the + * install (minutes are possible and the request answers only when the new + * node is ONLINE), then says which of the two happened in the server's own + * named words: the machine's id and page on success, the refusal's sentence + * on failure. Every stage the server can name (no egress, tmux missing, + * the key home offline, no ready) is a refusal sentence, so the dialog + * never needs its own failure taxonomy. + * + * Owner-only by the menu's gate (an SSH pane is input-reserved to its + * owner, and this act enrolls a machine on the owner's account); the server + * re-checks both doors. + */ +export function SetupHereDialog({ + subshell, + open, + onOpenChange, +}: { + /** The SSH-terminal pane whose destination is being set up */ + subshell: SubshellView; + /** Controlled open state, owned by the actions menu (clone-dialog posture) */ + open: boolean; + onOpenChange: (open: boolean) => void; +}): JSX.Element { + const queryClient = useQueryClient(); + const upgrade = useMutation({ + mutationFn: () => + apiFetch<{ nodeId: string }>("/api/ssh/setup-here", { + method: "POST", + body: JSON.stringify({ paneId: subshell.id }), + }), + onSuccess: () => { + // The new row belongs to the machine list; the panes queries are + // untouched because the act never writes the pane. + void queryClient.invalidateQueries({ queryKey: NODES_QUERY_KEY }); + }, + }); + const enrolled = upgrade.data; + + return ( + !upgrade.isPending && onOpenChange(next)}> + e.stopPropagation()}> + + Set up Subshell here? + {!enrolled && !upgrade.error && ( + + Runs the Subshell installer on the connected machine using this pane's authorization. It enrolls as a node + you own; the connection and this pane keep running. + + )} + + {upgrade.isPending && ( +

    + Installing on the destination; the download can take a few minutes. +

    + )} + {upgrade.error &&

    {(upgrade.error as Error).message}

    } + {enrolled && ( +

    + The machine enrolled and connected.{" "} + onOpenChange(false)} + > + Open its page + {" "} + to see what it can launch. +

    + )} + + + {!enrolled && ( + + )} + +
    +
    + ); +} diff --git a/apps/server/web/src/components/subshell-actions-menu.tsx b/apps/server/web/src/components/subshell-actions-menu.tsx index 4f1ee45dd..2cae1522e 100644 --- a/apps/server/web/src/components/subshell-actions-menu.tsx +++ b/apps/server/web/src/components/subshell-actions-menu.tsx @@ -10,6 +10,7 @@ import { Keyboard, QrCode, RotateCcw, + ServerCog, Share2, SlidersHorizontal, TextCursorInput, @@ -20,6 +21,7 @@ import { type ActionItem, ActionsMenu } from "@/components/actions-menu"; import { CloneSubshellDialog } from "@/components/clone-subshell-dialog"; import { InjectPromptDialog } from "@/components/prompts/inject-prompt-dialog"; import { QrLinkDialog } from "@/components/qr-link-dialog"; +import { SetupHereDialog } from "@/components/setup-here-dialog"; import { SharingDialog } from "@/components/sharing-dialog"; import { SwitchPresetDialog } from "@/components/switch-preset-dialog"; import { TitleDialog } from "@/components/ui/title-dialog"; @@ -79,6 +81,7 @@ export function SubshellActionsMenu({ const [titleOpen, setTitleOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [cloneOpen, setCloneOpen] = useState(false); + const [setupHereOpen, setSetupHereOpen] = useState(false); const [switchPresetOpen, setSwitchPresetOpen] = useState(false); const [injectOpen, setInjectOpen] = useState(false); const navigate = useNavigate(); @@ -255,6 +258,21 @@ export function SubshellActionsMenu({ }, ] : []), + // "Set up Subshell here" (spec 2026-10-08 §7): the destination upgrade, + // an act the OWNER performs on an SSH-terminal pane. The ssh flag is the + // pane's kind fact and the server re-checks it (a non-ssh pane refuses by + // name); the offline gate is the restart item's rule, because this act + // needs the connecting machine online to carry the install - promising it + // offline would end in a refusal the menu could have named first. + ...(isOwner && subshell.ssh === true && !subshell.nodeOffline + ? [ + { + icon: ServerCog, + label: "Set up Subshell here…", + onSelect: () => setSetupHereOpen(true), + }, + ] + : []), ...(preset ? [ { @@ -310,6 +328,9 @@ export function SubshellActionsMenu({ half-typed custom TEXT deliberately outlives the unmount (the picker's sessionStorage draft, ruling 2026-09-29). */} {injectOpen && } + {/* Mount-while-open, the menu's dialog posture: every open starts from + the question, and a finished act's answer leaves with the close. */} + {setupHereOpen && } Date: Thu, 8 Oct 2026 18:21:12 -0700 Subject: [PATCH 140/170] test(ssh-setup-here): keep the new suites clean under verify-types The refusal union needs its coded arm named before reading message/code, the closure-filled D handle needs its widening re-stated, the mocked fetch keeps the working cast shape, and the fake child result stays a partial from the test's side. --- .../src/__tests__/commands-ssh-exec.test.ts | 4 +- .../ssh/__tests__/setup-here-route.test.ts | 2 +- .../__tests__/ssh-setup-here.service.test.ts | 80 ++++++++++++------- .../__tests__/setup-here-dialog.test.tsx | 2 +- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts index 30643364d..3f1b89b32 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -102,7 +102,7 @@ function fakeRun( finish: (r?: Partial) => void; } { const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; - let settle: ((r: SshProcessResult) => void) | null = null; + let settle: ((r: Partial) => void) | null = null; const seams: SshExecSeams = { resolveSshBin: async () => "/usr/bin/fake-ssh", runProcess: ((argv, env, timeoutMs) => { @@ -110,7 +110,7 @@ function fakeRun( const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; if (opts.defer) { return new Promise((resolve) => { - settle = (r) => resolve({ ...full, ...r }); + settle = (r) => resolve({ ...full, ...r } as SshProcessResult); }); } return Promise.resolve(full); diff --git a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts index 00d0d59ec..70b453e4a 100644 --- a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts @@ -26,7 +26,7 @@ import { issueSubshellToken } from "@/services/subshell-tokens.js"; const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); const port = "api/ssh/setup-here" as const; -function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}): Promise { +function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}) { const headers: Record = { "content-type": "application/json" }; if (opts.cookie) headers.cookie = `better-auth.session_token=${opts.cookie}`; if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`; diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts index 6c00f2580..9243bfd66 100644 --- a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -15,6 +15,7 @@ import { UsersRepository } from "@/db/repositories/users.repository.js"; import type { NodeTable } from "@/db/types/nodes.db-types.js"; import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { setSshSetupHereDepsForTests, setupHere } from "@/services/ssh-setup-here.service.js"; import { attachScriptedNode, SCRIPTED_DATA_DIR, type ScriptedNode } from "@/test-helpers/scripted-node.js"; @@ -192,6 +193,12 @@ function mintedKey(cmd: SshExecCommand | undefined): string { return m[1]; } +/** The refusal's coded facts; the 422 outcome arm is not an answer this act ever gives. */ +function coded(refusal: SshRefusal): { status: number; code: BackendErrorCodes; message: string } { + if (refusal.status === 422) throw new Error(`unexpected outcome refusal: ${JSON.stringify(refusal)}`); + return refusal; +} + async function upgradeRows(): Promise<{ action: string; metadataJson: string | null }[]> { return await db .selectFrom("auditEvents") @@ -304,7 +311,7 @@ describe("setupHere door order (pane first, gates before any key)", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.message).toContain("SSH-terminal pane"); + expect(coded(res.refusal).message).toContain("SSH-terminal pane"); }); }); @@ -328,11 +335,11 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.status).toBe(409); - if (res.refusal.status !== 409) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); - expect(res.refusal.message).toContain("could not reach"); - expect(res.refusal.message).toContain("untouched"); + const refusal = coded(res.refusal); + expect(refusal.status).toBe(409); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(refusal.message).toContain("could not reach"); + expect(refusal.message).toContain("untouched"); // The working pane: the row is unchanged, and B saw ONLY the exec arms // - no input, no terminate, no launch, no touch of any kind. const row = await new SubshellsRepository(db).findById(paneId); @@ -340,8 +347,10 @@ describe("setupHere relay mode", () => { expect(row?.status).toBe("running"); expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); // The relay was opened once and closed with the named child-exit word. + const exec = pair.kick(); + if (!exec) throw new Error("the act never kicked an exec"); expect(openCalls).toHaveLength(1); - expect(closeCalls).toEqual([{ paneId: pair.kick()?.execId, reason: "child-exit" }]); + expect(closeCalls).toEqual([{ paneId: exec.execId, reason: "child-exit" }]); // The unspent key is revoked on the way out; the trail names the cause. expect((await keys.listByUser(owner)).length).toBe(keysBefore); const audits = await upgradeRows(); @@ -364,9 +373,10 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); - expect(res.refusal.message).toContain(NODE_A); - expect(res.refusal.message).toMatch(/online/i); + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(refusal.message).toContain(NODE_A); + expect(refusal.message).toMatch(/online/i); expect(openCalls).toHaveLength(0); // never re-opened expect(pair.b.cmdTypes()).toEqual([]); // B was never even asked to exec expect((await keys.listByUser(owner)).length).toBe(keysBefore); // no key minted @@ -397,18 +407,23 @@ describe("setupHere relay mode", () => { return DONE_OK; }, }); + // `d` is filled by the onKick closure; the accessor re-widens what flow + // analysis narrows at the declaration. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; try { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(true); if (!res.ok) return; - const nodeId = (d as { id: string; handle: ScriptedNode } | null)?.id ?? ""; + const machine = seenD(); + const nodeId = machine?.id ?? ""; expect(nodeId).not.toBe(""); expect(res.value).toEqual({ nodeId }); // The relay was RE-OPENED for this act, keyed to the exec's ephemeral // id (never the pane's), carrying the destination's pin; closed with // the named word once the exec's child was gone. - const execId = pair.kick()?.execId; - expect(execId).toBeDefined(); + const kick = pair.kick(); + if (!kick) throw new Error("the act never kicked an exec"); + const execId = kick.execId; expect(execId).not.toBe(paneId); expect(openCalls).toHaveLength(1); expect(openCalls[0]?.paneId).toBe(execId); @@ -418,15 +433,14 @@ describe("setupHere relay mode", () => { expect(closeCalls).toEqual([{ paneId: execId, reason: "child-exit" }]); // The composed run: the exec's OWN config dir, BatchMode first, the // installer one-liner with the destination host as the scripted name. - const kick = pair.kick(); - expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); - expect(kick?.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); - expect(kick?.presetFlags).toContain("--"); - expect(kick?.relay).toBe(true); - expect(kick?.agentSocketPath).toBeNull(); - expect(kick?.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); - expect(kick?.command).toContain("SUBSHELL_NODE_NAME="); - expect(kick?.command).toContain(HOST); + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, execId)); + expect(kick.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); + expect(kick.presetFlags).toContain("--"); + expect(kick.relay).toBe(true); + expect(kick.agentSocketPath).toBeNull(); + expect(kick.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); + expect(kick.command).toContain("SUBSHELL_NODE_NAME="); + expect(kick.command).toContain(HOST); expect(polls).toBeGreaterThanOrEqual(3); // Audit: exactly one enrolled row naming ids, never the key. const audits = await upgradeRows(); @@ -443,10 +457,10 @@ describe("setupHere relay mode", () => { }); expect(audits[0]?.metadataJson).not.toContain("nsk_"); expect(JSON.stringify(res)).not.toContain("nsk_"); - d?.handle.detach(); + seenD()?.handle.detach(); await nodes.deleteById(nodeId).catch(() => {}); } finally { - d?.handle.detach(); + seenD()?.handle.detach(); pair.a?.detach(); pair.b.detach(); } @@ -495,8 +509,9 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); - expect(res.refusal.message).toContain("without enrolling"); + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(refusal.message).toContain("without enrolling"); expect((await keys.listByUser(owner)).length).toBe(keysBefore); } finally { await clearUpgradeRows(); @@ -523,20 +538,23 @@ describe("setupHere direct mode (B's own keys, no relay)", () => { }, status: () => DONE_OK, }); + // Same closure-filled `d`: the accessor re-widens the declaration's flow type. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; try { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(true); if (!res.ok) return; const kick = pair.kick(); - expect(kick?.relay).toBe(false); - expect(kick?.agentSocketPath).toBe("/run/user/700/agent.sock"); - expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + if (!kick) throw new Error("the act never kicked an exec"); + expect(kick.relay).toBe(false); + expect(kick.agentSocketPath).toBe("/run/user/700/agent.sock"); + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); // The direct pane's exec never touched the relay broker, and A's live // absence was irrelevant to it (no key-home gate was walked). expect(openCalls).toHaveLength(0); expect(closeCalls).toHaveLength(0); - d?.handle.detach(); - await nodes.deleteById(d?.id ?? "").catch(() => {}); + seenD()?.handle.detach(); + await nodes.deleteById(seenD()?.id ?? "").catch(() => {}); } finally { await clearUpgradeRows(); pair.b.detach(); diff --git a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx index e8ef0b884..faa1e4207 100644 --- a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx @@ -52,7 +52,7 @@ describe("SetupHereDialog", () => { globalThis.fetch = ((input: unknown, init?: RequestInit) => { const url = new URL(String(input), "http://localhost"); calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined }); - return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })) as never; + return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })); }) as typeof fetch; return { calls, restore: () => (globalThis.fetch = original) }; } From a081310d7afe3caa0cf72e78828bf4e2a028c1e4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:31:09 -0700 Subject: [PATCH 141/170] fix(ssh-relay): classify dark-destination curl failure as the named egress cause --- .../__tests__/ssh-setup-here.service.test.ts | 80 +++++++++++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 8 +- .../src/services/ssh-setup-here.service.ts | 48 +++++++++-- .../__tests__/subshell-actions-menu.test.tsx | 2 +- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts index 9243bfd66..7a98af825 100644 --- a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -5,6 +5,7 @@ import type { SshConnectionSnapshotWire, SshExecCommand } from "@internal/subshe import { hashPassword } from "better-auth/crypto"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { APP_BASE_URL } from "@/constants.js"; import { db } from "@/db/index.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; @@ -560,4 +561,83 @@ describe("setupHere direct mode (B's own keys, no relay)", () => { pair.b.detach(); } }); + + // The dominant no-egress shape (spec §7/§13): a DARK destination never + // runs install.sh, so none of the script's sentences exist in the answer; + // the act's FIRST curl is what fails, and the named cause must come from + // curl's own words/exit codes, not the generic install-exit stage. + const darkD = (code: number, stderr: string) => ({ + state: "done" as const, + code, + timedOut: false, + stdout: "", + stderr, + }); + + it("a dark destination (DNS failure on the first fetch) answers the NAMED egress cause, not install-exit", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: false, + status: () => darkD(6, "curl: (6) Could not resolve host: plane.invalid\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(refusal.message).toContain("could not reach"); + expect(refusal.message).toContain(APP_BASE_URL); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("a dark destination (refused TCP connect) answers the egress cause", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const pair = attachPair({ + withA: false, + status: () => darkD(7, "curl: (7) Failed to connect to 10.9.9.9 port 80 after 3004 ms: Connection refused\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + const audits = await upgradeRows(); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("curl's connect-failure exit codes classify without curl's sentence (belt: no installer banner)", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + // The belt arm: an older or stripped curl that exits 6/7 but words + // nothing. No `==>` banner means install.sh never ran, so the only + // network actor that failed is the act's first curl. + const pair = attachPair({ withA: false, status: () => darkD(7, "") }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); }); diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index dce984a87..456f10984 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -231,7 +231,13 @@ export async function sshExec( export async function sshExecStatus(nodeId: string, execId: string): Promise { let data: unknown; try { - data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }); + // 30 s, not the 10 s default: the ANSWER is cheap (the run itself is + // off-chain by design) but the frame rides the node's serial command + // chain, so a slow earlier command can park this poll behind it. A + // timed-out poll costs more than a late one: the caller names + // exec-lost-timeout, revokes the unspent setup key, and reports a live + // install as lost. The headroom is the whole honest mitigation. + data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }, { timeoutMs: 30_000 }); } catch (err) { if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); throw err; diff --git a/apps/server/api/src/services/ssh-setup-here.service.ts b/apps/server/api/src/services/ssh-setup-here.service.ts index 6d2bf9ee2..ff0af51f5 100644 --- a/apps/server/api/src/services/ssh-setup-here.service.ts +++ b/apps/server/api/src/services/ssh-setup-here.service.ts @@ -57,9 +57,12 @@ import { logger } from "@/utils/logger.js"; * the registry's `agent` facts appear only when `ready` lands). Success * is that fact, not the installer's own cheer line. * - * Every exit writes ONE `node.ssh_upgrade.run` audit row naming ids, the - * destination, the grant, and the OUTCOME/stage - never the key, never the - * installer's words. + * Every exit AFTER THE KEY EXISTS writes ONE `node.ssh_upgrade.run` audit + * row naming ids, the destination, the grant, and the OUTCOME/stage - never + * the key, never the installer's words; an unexpected internal throw records + * its own `internal-error` stage on the way out (best effort) before + * propagating. Refusals raised by the gates write no row - there is no key + * or session to account for there. */ /** How long B's ssh child may run before the node-side deadline ends it. An installer pulls a ~100 MB binary; this is minutes, honestly. */ @@ -120,6 +123,26 @@ function coded(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, mes return { ok: false, refusal: { status, code, message } }; } +/** + * Does this failed-install answer say "D could not reach the plane" (§7's + * NAMED egress stage)? Two shapes carry that fact, and both matter: the + * rendered installer's own sentence (it was running and a fetch failed + * mid-way), and curl's own connect/DNS failure - the act's FIRST fetch is + * install.sh itself, so a DARK destination never prints a script line at + * all; its answer is curl's stderr and curl's exit code alone (6 resolve, + * 7 connect, 28 timeout). A key or artifact failure never looks like this: + * those answer an HTTP status (curl 22, or the script's own advice), not a + * failed connect, and the belt arm keys on the missing `==>` banner, which + * only the pre-script curl failure lacks. + */ +function couldNotReachPlane(output: string, code: number | null): boolean { + if (output.includes("could not reach") || output.includes("could not fetch the checksum")) return true; + if (/\bcurl: \((6|7|28)\)/.test(output)) return true; + const lower = output.toLowerCase(); + if (lower.includes("could not resolve host") || /\bfailed( to)? connect\b/.test(lower)) return true; + return (code === 6 || code === 7) && !output.includes("==>"); +} + /** What a completed act answers: the enrolled node's id, learned from `ready`. */ export interface SetupHereResult { nodeId: string; @@ -277,6 +300,18 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro logger.withError(err).warn(`setup-here: could not revoke the unspent key for pane ${pane.id}`); } }; + /** + * The post-mint invariant: even an unexpected internal throw (not the + * machine refusing, something HERE failing) leaves its terminal row. + * Best-effort: the audit system must never replace the original error. + */ + const auditInternalFailure = async (): Promise => { + try { + await runAudit("refused", "internal-error"); + } catch (auditErr) { + logger.withError(auditErr).warn(`setup-here: could not audit the internal failure for pane ${pane.id}`); + } + }; /** Close the exec's own relay pairing (the act's child is gone; the socket should be). */ const closeExecRelay = async (): Promise => { if (grantId === null) return; @@ -308,6 +343,7 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro await runAudit("refused", `exec-${err.kind}`); return r; } + await auditInternalFailure(); throw err; } @@ -325,6 +361,7 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro await runAudit("refused", `exec-lost-${err.kind}`); return rpcRefusal(err); } + await auditInternalFailure(); throw err; } if (terminal.state === "done") break; @@ -368,8 +405,9 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro ); } // The named causes, each from a phrase the rendered installer prints (the - // script's OWN words are the parser's contract; none of them is echoed). - if (output.includes("could not reach") || output.includes("could not fetch the checksum")) { + // script's OWN words are the parser's contract; none of them is echoed), + // or from curl's own connect-failure shape when the script never ran. + if (couldNotReachPlane(output, terminal.code)) { // §7's egress refusal: its own code, distinct from any key error, and // the pane keeps running untouched. return await fail( diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index a9cb808b3..1922bdf9b 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -658,7 +658,7 @@ describe("SubshellActionsMenu — prompt input availability", () => { }); }); -describe("SubshellActionsMenu — Set up Subshell here (spec 2026-10-08 §7)", () => { +describe("SubshellActionsMenu - Set up Subshell here (spec 2026-10-08 §7)", () => { afterEach(cleanup); it("offers it on the owner's live SSH pane and opens the dialog", async () => { From f0b52d574eab4d3acea676f9a3a918eab67f05dd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:03 -0700 Subject: [PATCH 142/170] feat(ssh-relay): client shapes and hooks for grants, approvals, and host pins The Task 15 operator screens read T10/T11/T12 through this lib+hooks half: the grant/approval/host-pin wire mirrors beside their route definitions, the TanStack-Query hooks with one key per list, and the pure client spelling of the SSH_MAX_GRANT_FINGERPRINTS hard cap (the screen shows the server's own refusal before it, so a truncation can never be mistaken for the answer). Co-Authored-By: Claude --- apps/server/web/src/hooks/use-ssh.ts | 148 ++++++++++++++++++ apps/server/web/src/lib/__tests__/ssh.test.ts | 28 ++++ apps/server/web/src/lib/ssh.ts | 85 +++++++++- 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 apps/server/web/src/lib/__tests__/ssh.test.ts diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts index 3bd151114..24073a5a3 100644 --- a/apps/server/web/src/hooks/use-ssh.ts +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -1,7 +1,11 @@ import { apiFetch, apiPost } from "@internal/node-admin"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + SshAgentIdentity, SshAliasesView, + SshGrant, + SshGrantRequest, + SshHostPin, SshLaunchRequest, SshLaunchResponse, SshSavedHost, @@ -109,3 +113,147 @@ export function useLaunchSsh() { onSuccess: () => void invalidate(), }); } + +/* ------------------------------------------------------------------ */ +/* key grants, first-use approvals, destination host-key pins */ +/* (spec 2026-10-08 §6, §8-§9; the operator screens of Task 15) */ +/* ------------------------------------------------------------------ */ + +export const SSH_GRANTS_QUERY_KEY = ["ssh-grants"] as const; +export const SSH_GRANT_REQUESTS_QUERY_KEY = ["ssh-grant-requests"] as const; +export const SSH_HOST_PINS_QUERY_KEY = ["ssh-host-pins"] as const; +/** The roster is per-request: the key home answers live, so no two cards share an entry. */ +export const sshRosterQueryKey = (requestId: string) => ["ssh-grant-roster", requestId] as const; + +/** The owner's standing key grants, newest first. */ +export function useSshGrants() { + return useQuery({ + queryKey: SSH_GRANTS_QUERY_KEY, + queryFn: () => apiFetch<{ grants: SshGrant[] }>("/api/ssh/grants"), + }); +} + +/** + * `DELETE /api/ssh/grants/:id` - the instant both-ways cut: the row goes and + * every live relay that ran under it is torn down server-side. 204 no body; + * a foreign row is the same 404 as an absent one. + */ +export function useRevokeSshGrant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/ssh/grants/${encodeURIComponent(id)}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_GRANTS_QUERY_KEY }), + }); +} + +/** The owner's outstanding first-use approvals, newest first (the GET sweeps expired rows first). */ +export function useSshGrantRequests() { + return useQuery({ + queryKey: SSH_GRANT_REQUESTS_QUERY_KEY, + queryFn: () => apiFetch<{ requests: SshGrantRequest[] }>("/api/ssh/grant-requests"), + }); +} + +/** + * The key home's live agent roster to approve FROM. `enabled` gates on the + * card actually being open: an offline or refusing key home answers a named + * error and the request stays pending, never a fabricated empty roster, so + * the roster is fetched on demand rather than for every queued question. + */ +export function useSshGrantRoster(requestId: string, enabled: boolean) { + return useQuery({ + queryKey: sshRosterQueryKey(requestId), + queryFn: () => + apiFetch<{ identities: SshAgentIdentity[] }>( + `/api/ssh/grant-requests/${encodeURIComponent(requestId)}/identities`, + ), + enabled, + }); +} + +/** The approve body: the selected fingerprints, and an optional display name for the new grant. */ +export interface SshGrantApproval { + requestId: string; + fingerprints: string[]; + /** Display name override; absent keeps the server's default naming */ + name?: string; +} + +/** + * `POST /api/ssh/grant-requests/:id/approve` - answers YES and writes the + * standing grant. Both lists move: the row leaves the queue, the grant joins + * the ledger. An over-cap selection is a hard refusal server-side, never a + * truncation; the screen shows the same sentence before sending. + */ +export function useApproveSshGrantRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (draft: SshGrantApproval) => + apiPost<{ grant: SshGrant }>(`/api/ssh/grant-requests/${encodeURIComponent(draft.requestId)}/approve`, { + fingerprints: draft.fingerprints, + ...(draft.name ? { name: draft.name } : {}), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SSH_GRANT_REQUESTS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: SSH_GRANTS_QUERY_KEY }); + }, + }); +} + +/** + * `POST /api/ssh/grant-requests/:id/deny` - answers NO. No grant and no pin + * exists for a denial; a later relaunch simply asks again, so denying needs + * no confirm. + */ +export function useDenySshGrantRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (requestId: string) => + apiPost<{ requestId: string }>(`/api/ssh/grant-requests/${encodeURIComponent(requestId)}/deny`, {}), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_GRANT_REQUESTS_QUERY_KEY }), + }); +} + +/** The owner's destination host-key pins, newest first (key bytes are display-excluded by construction). */ +export function useSshHostPins() { + return useQuery({ + queryKey: SSH_HOST_PINS_QUERY_KEY, + queryFn: () => apiFetch<{ pins: SshHostPin[] }>("/api/ssh/host-pins"), + }); +} + +/** The explicit-pin body (spec §9's "or an explicit pin" door): canonical destination + one known_hosts line. */ +export interface SshHostPinDraft { + /** Canonical resolved destination `user@host:port` (the spelling the launch keys the pin by) */ + destination: string; + /** The pinned entry in OpenSSH known_hosts form */ + hostKey: string; +} + +/** + * `POST /api/ssh/host-pins` - supplies an explicit pin for a destination the + * key home has not connected to yet. A destination already pinned to a + * DIFFERENT key is the named hard block (409, nothing written): delete the + * pin first, that is the whole recovery flow. + */ +export function useCreateSshHostPin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (draft: SshHostPinDraft) => apiPost<{ pin: SshHostPin }>("/api/ssh/host-pins", draft), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_HOST_PINS_QUERY_KEY }), + }); +} + +/** + * `DELETE /api/ssh/host-pins/:destination` - the TOFU recovery's first half; + * the next capture at a fresh key re-decides trust. The destination is the + * key, so it rides the path encoded. + */ +export function useDeleteSshHostPin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (destination: string) => + apiFetch<{ deleted: boolean }>(`/api/ssh/host-pins/${encodeURIComponent(destination)}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_HOST_PINS_QUERY_KEY }), + }); +} diff --git a/apps/server/web/src/lib/__tests__/ssh.test.ts b/apps/server/web/src/lib/__tests__/ssh.test.ts new file mode 100644 index 000000000..343559123 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { grantSelectionError } from "@/lib/ssh"; + +/** + * The client-side half of the grant selection rule (spec 2026-10-08 §5.4, + * §6.2). The server refuses an over-cap selection as a HARD error, never a + * truncation; the screen shows the same sentence before the POST so a person + * learns the cap from a red line, not from a lost click. The tests pin the + * boundary at SSH_MAX_GRANT_FINGERPRINTS itself: at the cap is legal. + */ +describe("grantSelectionError (the over-cap hard error)", () => { + const fp = (i: number) => `SHA256:${"A".repeat(40)}${i}`; + const many = (n: number) => Array.from({ length: n }, (_, i) => fp(i)); + + it("accepts a selection at the cap", () => { + expect(grantSelectionError(many(SSH_MAX_GRANT_FINGERPRINTS))).toBeNull(); + }); + + it("accepts an empty selection (an empty set is legal, it serves nothing)", () => { + expect(grantSelectionError([])).toBeNull(); + }); + + it("names the cap once the selection exceeds it", () => { + const error = grantSelectionError(many(SSH_MAX_GRANT_FINGERPRINTS + 1)); + expect(error).toContain(String(SSH_MAX_GRANT_FINGERPRINTS)); + }); +}); diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts index c36040491..54330ed12 100644 --- a/apps/server/web/src/lib/ssh.ts +++ b/apps/server/web/src/lib/ssh.ts @@ -1,13 +1,15 @@ /** - * The client shapes of the `/api/ssh` surface (spec 2026-10-07 §5–§7), the - * same mirror `lib/prompts.ts` keeps for the prompt library: local types that - * state the wire field for field, read through `apiFetch`. The definitions - * live beside the routes that answer them + * The client shapes of the `/api/ssh` surface (specs 2026-10-07 and + * 2026-10-08), the same mirror `lib/prompts.ts` keeps for the prompt library: + * local types that state the wire field for field, read through `apiFetch`. The + * definitions live beside the routes that answer them * (`apps/server/api/src/api/ssh/ssh-views.ts`); the copy here restates that * contract for the SPA, and the two are kept honest by review like every * other mirror type in this app. */ +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; + /** One ProxyJump hop as the frozen snapshot spells it. */ export interface SshHop { /** Hop hostname (bracketed IPv6 literal kept as given) */ @@ -155,3 +157,78 @@ export interface SshLaunchRequest { export interface SshLaunchResponse { subshell: { id: string }; } + +/** + * One standing key grant as the grants screen reads it (spec 2026-10-08 §6.1, + * §8). The fingerprint set is public `SHA256:` identifiers and it is + * deliberately on this wire: the screen is the owner's own, and "which keys + * serve" is exactly what the owner manages. + */ +export interface SshGrant { + /** Grant row id (uuid) */ + id: string; + /** Display name the operator gave the grant */ + name: string; + /** Key home machine: its ssh-agent signs for this grant */ + keyHomeNodeId: string; + /** Destination selector, stored RESOLVED: a concrete hostname or a '*' host pattern */ + resolvedSelector: string; + /** The selected public identities (at most SSH_MAX_GRANT_FINGERPRINTS); an empty set serves nothing */ + fingerprints: string[]; + /** Which door created the row: an approved first use, or the grants screen */ + createdVia: "first-use" | "manual"; + /** ISO 8601 creation stamp (the match tie-break: oldest wins) */ + createdAt: string; + /** ISO 8601 of the last operator edit */ + updatedAt: string; +} + +/** One first-use approval request as the queue reads it (spec 2026-10-08 §6.2). */ +export interface SshGrantRequest { + /** Request row id (uuid) - the opaque ref the refusal and the notification name */ + id: string; + /** The key home whose approval is asked */ + keyHomeNodeId: string; + /** The resolved destination hostname the launch would have dialed */ + resolvedSelector: string; + /** Pre-selection riding the request, or null (the approver picks) */ + requestedFingerprints: string[] | null; + /** The asking pane's id (the requester the screen names) */ + paneId: string; + /** The connecting machine of the asking launch */ + bNodeId: string; + /** ISO 8601 deadline (24 h); past it the sweep answers the row expired */ + expiresAt: string; + /** Lifecycle state; only pending can be answered */ + status: "pending" | "approved" | "denied" | "expired"; + /** ISO 8601 creation stamp */ + createdAt: string; +} + +/** One key home agent identity from the approval roster (blobs are withheld by construction). */ +export interface SshAgentIdentity { + /** Public agent identity in the canonical SHA256: notation (the approval sends it back verbatim) */ + fingerprint: string; + /** OpenSSH's label for the key, as the agent reports it (display only) */ + comment: string; +} + +/** One pinned destination host key (spec 2026-10-08 §9): destination + public fingerprint, never key bytes. */ +export interface SshHostPin { + /** Pin row id (uuid) */ + id: string; + /** Canonical resolved destination `user@host:port` */ + destination: string; + /** The pinned key's SHA256: display fingerprint (public identifier) */ + fingerprint: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match */ + updatedAt: string; +} + +/** The client-side spelling of the server's hard selection cap (spec 2026-10-08 §5.4). */ +export function grantSelectionError(fingerprints: readonly string[]): string | null { + if (fingerprints.length <= SSH_MAX_GRANT_FINGERPRINTS) return null; + return `A grant can serve at most ${SSH_MAX_GRANT_FINGERPRINTS} keys. Deselect some before approving.`; +} From 84dba145ab7028825e2e2d8dede109a1c1dd471d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:06 -0700 Subject: [PATCH 143/170] feat(ssh-relay): grants screen with the instant-both-ways revoke List-and-revoke only, on purpose: which keys serve is immutable server-side, so the screen offers no key picker and the roster fetch exists only on the approval door. Revoke confirms under a static title with the grant's name in the body (ruling 2026-09-30) and a refused DELETE renders red on its row. Co-Authored-By: Claude --- .../ssh/__tests__/grants-screen.test.tsx | 162 ++++++++++++++++++ .../web/src/components/ssh/grants-screen.tsx | 108 ++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx create mode 100644 apps/server/web/src/components/ssh/grants-screen.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx b/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx new file mode 100644 index 000000000..997a8ff66 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { type ConfirmOptions, setConfirmHandler } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { GrantsScreen } from "@/components/ssh/grants-screen"; + +/** + * The grants screen (spec 2026-10-08 §6.1, §8): the owner's standing key + * grants, their selected agent identities, and the instant-both-ways revoke. + * The revoke is the screen's one destructive act, so its confirm is pinned: + * a STATIC title (the ruling 2026-09-30: the name rides the body) and a + * refusal of the DELETE rendered red on the row that failed. + */ + +const FP_ONE = `SHA256:${"A".repeat(43)}`; +const FP_TWO = `SHA256:${"B".repeat(43)}`; + +const GRANT = { + id: "g1", + name: "prod keys", + keyHomeNodeId: "nodeA", + resolvedSelector: "*.prod.example.com", + fingerprints: [FP_ONE, FP_TWO], + createdVia: "first-use", + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-01T10:00:00Z", +}; + +interface Sent { + method: string; + path: string; +} + +function stubFetch( + restore: (undo: () => void) => void, + opts: { revokeStatus?: number; revokeMessage?: string } = {}, +): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + sent.push({ method, path: url.pathname }); + if (url.pathname === "/api/ssh/grants") { + return new Response(JSON.stringify({ grants: [GRANT] }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response(JSON.stringify({ nodes: [{ id: "nodeA", name: "vault" }] }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grants/g1" && method === "DELETE") { + if (opts.revokeStatus) { + return new Response(JSON.stringify({ message: opts.revokeMessage ?? "gone" }), { + status: opts.revokeStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response(null, { status: 204 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +function mockConfirm(answer: boolean) { + const seen: ConfirmOptions[] = []; + const previous = setConfirmHandler((options) => { + seen.push(options); + return Promise.resolve(answer); + }); + return { seen, restore: () => setConfirmHandler(previous) }; +} + +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render( + + + , + ); +} + +describe("GrantsScreen", () => { + it("lists each grant with its machine, selector and selected fingerprints", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText("prod keys")).toBeDefined(); + // The key home reads by NAME (rail doctrine: nothing rendered derives from the id). + // The row's one detail line carries machine and selector, `machine · selector`. + expect(screen.getByText(/vault/)).toBeDefined(); + expect(screen.getByText(/\*\.prod\.example\.com/)).toBeDefined(); + expect(screen.getByText(FP_ONE)).toBeDefined(); + expect(screen.getByText(FP_TWO)).toBeDefined(); + }); + + it("says so plainly when there is nothing standing", async () => { + const original = globalThis.fetch; + restores.push(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown) => { + const path = new URL(String(input), "http://localhost").pathname; + const body = path === "/api/ssh/grants" ? { grants: [] } : { nodes: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }) as typeof globalThis.fetch; + renderScreen(); + expect(await screen.findByText(/No key grants yet/)).toBeDefined(); + }); + + it("revokes only after a confirm whose title is static, DELETEing the row", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + // Static title (design ruling 2026-09-30): the grant's name rides the body, never the heading. + expect(confirm.seen[0]?.title).toBe("Revoke this grant?"); + expect(confirm.seen[0]?.description).toContain("prod keys"); + await waitFor(() => expect(sent.some((s) => s.method === "DELETE" && s.path === "/api/ssh/grants/g1")).toBe(true)); + }); + + it("sends nothing when the confirm is declined", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(false); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + expect(sent.some((s) => s.method === "DELETE")).toBe(false); + }); + + it("renders a refused revoke as the row's own red line", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { revokeStatus: 404, revokeMessage: "No such grant" }, + ); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("No such grant"); + expect(alert.className).toContain("text-destructive"); + }); +}); diff --git a/apps/server/web/src/components/ssh/grants-screen.tsx b/apps/server/web/src/components/ssh/grants-screen.tsx new file mode 100644 index 000000000..1aaa04064 --- /dev/null +++ b/apps/server/web/src/components/ssh/grants-screen.tsx @@ -0,0 +1,108 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + confirmAction, + errMessage, +} from "@internal/node-admin"; +import { Trash2 } from "lucide-react"; +import { useNodes } from "@/hooks/use-nodes"; +import { useRevokeSshGrant, useSshGrants } from "@/hooks/use-ssh"; +import type { SshGrant } from "@/lib/ssh"; + +/** + * The grants screen (spec 2026-10-08 §6.1, §8): the owner's standing key + * grants, the selected public identities that carry them, and the revoke. + * The rows are line items, `label` over `detail`: the name, then machine and + * selector, then the fingerprints (public `SHA256:` identifiers, deliberately + * on the owner's own screen; "which keys serve" is what the owner manages). + * + * Which keys serve is IMMUTABLE by design: the server refuses to widen a + * standing selection (spec §6.1), so this screen has no key picker at all - + * the way to change a selection is revoke and re-ask, and the screen must + * not offer what the routes refuse. Revoke is the screen's one destructive + * act: it cuts both ways instantly (the row goes, live relays under it are + * torn down), so it confirms, with the consequence said in the body and a + * STATIC title. + */ +export function GrantsScreen() { + const { data: view } = useSshGrants(); + const { data: nodeData } = useNodes(); + const revoke = useRevokeSshGrant(); + const grants = view?.grants ?? []; + + const nameById = new Map( + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).map((n) => [n.id, n.name]), + ); + + async function revokeGrant(grant: SshGrant) { + const ok = await confirmAction({ + // Static title (ruling 2026-09-30); the body carries the name. + title: "Revoke this grant?", + description: `"${grant.name}" stops signing right away, and every session already running under it is cut. You can grant again from the next approval.`, + confirmLabel: "Revoke", + danger: true, + }); + if (!ok) return; + revoke.mutate(grant.id); + } + + return ( + + + Key grants + + Which machine's agent keys sign for which destinations. Revoking cuts the grant and every live session + running under it at once. + + + + {grants.length === 0 ? ( +

    + No key grants yet. A first-use approval you answer yes becomes a standing grant here. +

    + ) : ( +
      + {grants.map((grant) => ( +
    • +
      +
      {grant.name}
      +
      + {[nameById.get(grant.keyHomeNodeId) ?? grant.keyHomeNodeId, grant.resolvedSelector].join(" · ")} +
      + {grant.fingerprints.map((fingerprint) => ( +
      + {fingerprint} +
      + ))} +
      + {grant.createdVia === "first-use" ? "From a first-use approval" : "Added by hand"} ·{" "} + {grant.createdAt.slice(0, 10)} +
      + {revoke.isError && revoke.variables === grant.id && ( +

      + {errMessage(revoke.error, "The grant could not be revoked.")} +

      + )} +
      + +
    • + ))} +
    + )} +
    +
    + ); +} From 169a4bd00847cbf7d12f80e9fb0bf64c4a7a09b4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:08 -0700 Subject: [PATCH 144/170] feat(ssh-relay): first-use approval queue naming the pane, machine, destination Each pending question carries its key home's live roster (T11) loaded with the card, the request's pre-selection carried, and the over-cap selection refused RED before the POST (a truncation must never read as the answer that was given). Approve sends the selection and an optional name; Deny answers without a confirm because it writes no grant and a relaunch simply re-asks. The card points a HostKeyAlias destination at the explicit-pin door (F2) instead of dead-ending. Co-Authored-By: Claude --- .../ssh/__tests__/pending-approvals.test.tsx | 223 ++++++++++++++++++ .../src/components/ssh/pending-approvals.tsx | 182 ++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx create mode 100644 apps/server/web/src/components/ssh/pending-approvals.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx new file mode 100644 index 000000000..366d4421f --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { PendingApprovals } from "@/components/ssh/pending-approvals"; + +/** + * The first-use approval queue (spec 2026-10-08 §6.2): each card names the + * asking pane, the connecting machine and the destination, and lets the + * operator pick the grant's key set FROM the key home's live roster. The cap + * is the load-bearing case: more than SSH_MAX_GRANT_FINGERPRINTS is a RED + * hard error (a refused selection, not a silent truncation) and Approve stays + * gated until the selection is legal. + */ + +const fp = (i: number) => `SHA256:${i.toString().padStart(43, "0")}`; +const PRE = fp(1); + +const REQUEST = { + id: "req1", + keyHomeNodeId: "nodeA", + resolvedSelector: "build-1.example.com", + requestedFingerprints: [PRE], + paneId: "pane1", + bNodeId: "nodeB", + expiresAt: "2026-10-09T10:00:00Z", + status: "pending", + createdAt: "2026-10-08T10:00:00Z", +}; + +const ROSTER = Array.from({ length: 10 }, (_, i) => ({ fingerprint: fp(i + 1), comment: `key ${i + 1}` })); + +interface Sent { + method: string; + path: string; + body: unknown; +} + +function stubFetch(restore: (undo: () => void) => void): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + sent.push({ method, path: url.pathname, body }); + if (url.pathname === "/api/ssh/grant-requests") { + return new Response(JSON.stringify({ requests: [REQUEST] }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/identities") { + return new Response(JSON.stringify({ identities: ROSTER }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/approve") { + return new Response(JSON.stringify({ grant: { id: "g1" } }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/deny") { + return new Response(JSON.stringify({ requestId: "req1" }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response( + JSON.stringify({ + nodes: [ + { id: "nodeA", name: "vault" }, + { id: "nodeB", name: "desk" }, + ], + }), + { + status: 200, + }, + ); + } + if (url.pathname === "/api/subshells") { + return new Response(JSON.stringify([{ id: "pane1", name: "builder", status: "running" }]), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +/** Renders the queue inside a throwaway router: the requester renders as a Link. */ +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const subshellRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, subshellRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + void router.load(); + return render( + + + , + ); +} + +async function renderWithRoster() { + renderScreen(); + return screen.findAllByRole("checkbox"); +} + +describe("PendingApprovals", () => { + it("names the requester, the connecting machine and the destination", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText("builder")).toBeDefined(); + expect(screen.getByText(/desk/)).toBeDefined(); + expect(screen.getByText(/build-1\.example\.com/)).toBeDefined(); + }); + + it("shows the key home's roster with the request's pre-selection carried", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + const boxes = await renderWithRoster(); + expect(boxes.length).toBe(10); + const pre = screen.getByRole("checkbox", { name: PRE }); + expect(pre.getAttribute("data-checked")).not.toBeNull(); + }); + + it("refuses an over-cap selection as a RED hard error and gates Approve", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + // One is already chosen (the pre-selection); check until the ninth lands. + let boxes = await renderWithRoster(); + for (const box of boxes) { + if (box.getAttribute("data-checked") === null) fireEvent.click(box); + boxes = await screen.findAllByRole("checkbox"); + const chosen = boxes.filter((b) => b.getAttribute("data-checked") !== null).length; + if (chosen > 8) break; + } + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("8"); + expect(alert.className).toContain("text-destructive"); + expect((screen.getByRole("button", { name: "Approve" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("approves the selected fingerprints with the typed name", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const boxes = await renderWithRoster(); + // Choose a second key, then take it back off: the pre-selection stays alone. + fireEvent.click(boxes[1]); + fireEvent.click(boxes[1]); + fireEvent.change(screen.getByLabelText("Grant name"), { target: { value: "prod keys" } }); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => expect(sent.some((s) => s.path.endsWith("/approve"))).toBe(true)); + const approve = sent.find((s) => s.path.endsWith("/approve")); + expect(approve?.body).toEqual({ fingerprints: [PRE], name: "prod keys" }); + }); + + it("denies with one click: a denial writes no grant and simply re-asks later", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Deny" })); + await waitFor(() => expect(sent.some((s) => s.path.endsWith("/deny"))).toBe(true)); + }); + + it("names the key home's silence as the reason and leaves the card standing", async () => { + const original = globalThis.fetch; + restores.push(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/ssh/grant-requests/req1/identities") { + return new Response(JSON.stringify({ message: "The key home could not be reached" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/api/ssh/grant-requests") { + return new Response(JSON.stringify({ requests: [REQUEST] }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response(JSON.stringify({ nodes: [] }), { status: 200 }); + } + if (url.pathname === "/api/subshells") { + return new Response(JSON.stringify([]), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${init?.method ?? "GET"} ${url.pathname}`); + }) as typeof globalThis.fetch; + renderScreen(); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("could not be reached"); + expect(screen.queryAllByRole("checkbox").length).toBe(0); + }); + + it("offers the explicit-pin way out for a HostKeyAlias destination", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(/HostKeyAlias/)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/pending-approvals.tsx b/apps/server/web/src/components/ssh/pending-approvals.tsx new file mode 100644 index 000000000..de637b3c9 --- /dev/null +++ b/apps/server/web/src/components/ssh/pending-approvals.tsx @@ -0,0 +1,182 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + errMessage, + Fact, + Input, + Label, +} from "@internal/node-admin"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useNodes } from "@/hooks/use-nodes"; +import { + useApproveSshGrantRequest, + useDenySshGrantRequest, + useSshGrantRequests, + useSshGrantRoster, +} from "@/hooks/use-ssh"; +import { useSubshellsList } from "@/hooks/use-subshells"; +import { grantSelectionError, type SshGrantRequest } from "@/lib/ssh"; + +/** + * The first-use approval queue (spec 2026-10-08 §6.2): every pending question + * the caller's key homes have asked, each card naming the asking pane, the + * connecting machine and the destination, with the key home's live roster to + * choose the grant's key set FROM. The selection cap is a HARD error, shown + * red the moment the ninth key is ticked: over-cap approvals are refused by + * the server, and the screen refuses before it, so a truncation can never be + * mistaken for the answer that was given. + * + * The roster loads with the card because it is what the answer is made of: + * an offline or refusing key home answers a named error and the request stays + * pending, so the card shows that sentence rather than an empty picker that + * would invite a yes against nothing. A denial needs no confirm: it writes + * only the audit row, and a later relaunch simply asks again. + */ +export function PendingApprovals() { + const { data: view } = useSshGrantRequests(); + const requests = (view?.requests ?? []).filter((r) => r.status === "pending"); + return ( + + + Pending approvals + + A launch that needs a key you have not granted yet asks here. An unanswered question waits 24 hours, then + expires. + + + + {requests.length === 0 ? ( +

    Nothing is waiting for an answer.

    + ) : ( +
    + {requests.map((request) => ( + + ))} +
    + )} +
    +
    + ); +} + +/** One pending question and the act that answers it. */ +function ApprovalCard({ request }: { request: SshGrantRequest }) { + const { data: nodeData } = useNodes(); + const { data: panes } = useSubshellsList(); + const roster = useSshGrantRoster(request.id, true); + const approve = useApproveSshGrantRequest(); + const deny = useDenySshGrantRequest(); + const [selected, setSelected] = useState(request.requestedFingerprints ?? []); + const [name, setName] = useState(""); + + const machineName = (id: string) => + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).find((n) => n.id === id)?.name ?? id; + const paneName = (Array.isArray(panes) ? panes : []).find((p) => p.id === request.paneId)?.name ?? request.paneId; + + const selectionError = grantSelectionError(selected); + const toggle = (fingerprint: string) => + setSelected((prev) => + prev.includes(fingerprint) ? prev.filter((f) => f !== fingerprint) : [...prev, fingerprint], + ); + + function answerYes() { + // The guard behind the gate (substrate contract): Enter-path submits and + // render races land here even though the button is swept. + if (selectionError || selected.length === 0) return; + approve.mutate({ requestId: request.id, fingerprints: selected, name: name.trim() || undefined }); + } + + return ( +
    +
    + + + {paneName} + + + {machineName(request.bNodeId)} + {machineName(request.keyHomeNodeId)} + + {request.resolvedSelector} + +
    +

    + Expires {request.expiresAt.slice(0, 16).replace("T", " ")}. If this destination is trusted under an SSH + HostKeyAlias, add its host-key pin under Destination trust first; the key home cannot capture that one itself. +

    +
    + + {roster.isPending && ( +

    Asking the key home's agent…

    + )} + {roster.isError && ( +

    + {errMessage(roster.error, "The key home could not be reached. The request stays pending.")} +

    + )} + {roster.data && roster.data.identities.length === 0 && ( +

    The key home's agent holds no keys right now.

    + )} + {roster.data && roster.data.identities.length > 0 && ( +
      + {roster.data.identities.map((identity) => ( +
    • + toggle(identity.fingerprint)} + /> +
      +
      {identity.fingerprint}
      + {identity.comment && ( +
      {identity.comment}
      + )} +
      +
    • + ))} +
    + )} + {selectionError && ( +

    + {selectionError} +

    + )} +
    +
    + + setName(e.target.value)} + /> +
    + {approve.isError && ( +

    + {errMessage(approve.error, "The approval could not be sent.")} +

    + )} + {deny.isError && ( +

    + {errMessage(deny.error, "The denial could not be sent.")} +

    + )} +
    + + +
    +
    + ); +} From 989193621fcaff32f0d34d44786c6ddc59a74d2c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:10 -0700 Subject: [PATCH 145/170] feat(ssh-relay): destination trust screen with delete and the explicit-pin door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller's host-key pins as destination plus SHA256: fingerprint (the key line itself is display-excluded by construction), the delete that re-opens the TOFU decision, and the §9 explicit-pin dialog for a destination trusted under an SSH HostKeyAlias where the capture cannot find the row. The add form runs the substrate; a conflicting pin is the server's RED line naming the recovery. Co-Authored-By: Claude --- .../ssh/__tests__/host-pins-screen.test.tsx | 167 +++++++++++ .../src/components/ssh/host-pins-screen.tsx | 268 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx create mode 100644 apps/server/web/src/components/ssh/host-pins-screen.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx b/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx new file mode 100644 index 000000000..58a18d6e1 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { type ConfirmOptions, setConfirmHandler } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HostPinsScreen } from "@/components/ssh/host-pins-screen"; + +/** + * Destination trust (spec 2026-10-08 §9): the caller's host-key pins as + * destination + `SHA256:` fingerprint (the key line itself never reaches the + * screen), the delete that forces a fresh-grant TOFU re-capture, and the + * explicit-pin door the F2 HostKeyAlias gap runs out of. The add dialog's + * title is static; the destination the person types rides its body. + */ + +const FP_HOST = `SHA256:${"C".repeat(43)}`; +const DEST = "theo@build.example.com:22"; +const HOST_KEY = "build.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinkeypinkeypinkeypinkeypin"; + +interface Sent { + method: string; + path: string; + body: unknown; +} + +function stubFetch( + restore: (undo: () => void) => void, + opts: { addStatus?: number; addMessage?: string } = {}, +): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + sent.push({ method, path: url.pathname, body }); + if (url.pathname === "/api/ssh/host-pins" && method === "GET") { + return new Response( + JSON.stringify({ + pins: [ + { + id: "p1", + destination: DEST, + fingerprint: FP_HOST, + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-07T09:00:00Z", + }, + ], + }), + { status: 200 }, + ); + } + if (url.pathname === "/api/ssh/host-pins" && method === "POST") { + if (opts.addStatus) { + return new Response(JSON.stringify({ message: opts.addMessage ?? "refused" }), { + status: opts.addStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ pin: {} }), { status: 200 }); + } + if (method === "DELETE") { + return new Response(JSON.stringify({ deleted: true }), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +function mockConfirm(answer: boolean) { + const seen: ConfirmOptions[] = []; + const previous = setConfirmHandler((options) => { + seen.push(options); + return Promise.resolve(answer); + }); + return { seen, restore: () => setConfirmHandler(previous) }; +} + +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render( + + + , + ); +} + +describe("HostPinsScreen", () => { + it("displays each pinned destination with its SHA256 fingerprint", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(DEST)).toBeDefined(); + expect(screen.getByText(FP_HOST)).toBeDefined(); + }); + + it("deletes through a static-titled confirm, keyed by the destination", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: `Remove pin for ${DEST}` })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + expect(confirm.seen[0]?.title).toBe("Remove this pin?"); + expect(confirm.seen[0]?.description).toContain(DEST); + await waitFor(() => + expect(sent.some((s) => s.method === "DELETE" && s.path === `/api/ssh/host-pins/${encodeURIComponent(DEST)}`)), + ); + }); + + it("opens a static-titled add dialog whose body carries the typed data", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Add destination pin" })); + // Static dialog title (ruling 2026-09-30): nothing typed moves the heading. + expect(await screen.findByRole("heading", { name: "Add destination pin" })).toBeDefined(); + fireEvent.change(screen.getByLabelText(/Destination/), { target: { value: DEST } }); + fireEvent.change(screen.getByLabelText(/Host key/), { target: { value: HOST_KEY } }); + fireEvent.click(screen.getByRole("button", { name: "Add pin" })); + await waitFor(() => expect(sent.some((s) => s.method === "POST" && s.path === "/api/ssh/host-pins")).toBe(true)); + expect(sent.find((s) => s.method === "POST" && s.path === "/api/ssh/host-pins")?.body).toEqual({ + destination: DEST, + hostKey: HOST_KEY, + }); + }); + + it("renders the server's refusal of an add as a red line", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { + addStatus: 409, + addMessage: "That destination is pinned to a different key", + }, + ); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Add destination pin" })); + fireEvent.change(await screen.findByLabelText(/Destination/), { target: { value: DEST } }); + fireEvent.change(screen.getByLabelText(/Host key/), { target: { value: HOST_KEY } }); + fireEvent.click(screen.getByRole("button", { name: "Add pin" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("pinned to a different key"); + expect(alert.className).toContain("text-destructive"); + }); + + it("says what removing a pin does", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(/re-decide/)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/host-pins-screen.tsx b/apps/server/web/src/components/ssh/host-pins-screen.tsx new file mode 100644 index 000000000..5f04f08f4 --- /dev/null +++ b/apps/server/web/src/components/ssh/host-pins-screen.tsx @@ -0,0 +1,268 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + CopyableValue, + confirmAction, + errMessage, + Input, + Label, +} from "@internal/node-admin"; +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { z } from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { Textarea } from "@/components/ui/textarea"; +import { useCreateSshHostPin, useDeleteSshHostPin, useSshHostPins } from "@/hooks/use-ssh"; +import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import type { SshHostPin } from "@/lib/ssh"; + +/** + * Destination trust (spec 2026-10-08 §9): the caller's host-key pins, each a + * canonical `user@host:port` and the pinned key's `SHA256:` fingerprint. The + * key line itself never reaches this screen by construction; the view carries + * only the public identifier. Pins are per destination and independent of the + * grants, so this card stands beside the grants card, not inside it. + * + * Delete is the TOFU recovery's first half: removing a pin lets the next + * capture at a fresh key re-decide trust, so the confirm says that, with the + * destination in the body under a static title. The add door is the §9 "or an + * explicit pin" path - the way out for a destination whose trust lives under + * an SSH HostKeyAlias, where the key home's capture cannot find the row + * (F2/T12): the operator supplies the known_hosts line themselves. + */ +export function HostPinsScreen() { + const { data: view } = useSshHostPins(); + const pins = view?.pins ?? []; + const [addOpen, setAddOpen] = useState(false); + return ( + + +
    + Destination trust + + The host key each destination was first seen with. Removing a pin makes the next connection re-decide trust + on first sight. + +
    + +
    + + {pins.length === 0 ? ( +

    + No pinned destinations yet. A pin is captured the first time a granted key connects. +

    + ) : ( +
      + {pins.map((pin) => ( + + ))} +
    + )} + {addOpen && } +
    +
    + ); +} + +/** One pinned destination and the delete that re-opens its TOFU decision. */ +function PinRow({ pin }: { pin: SshHostPin }) { + const remove = useDeleteSshHostPin(); + + async function drop() { + const ok = await confirmAction({ + // Static title; the destination rides the body (ruling 2026-09-30). + title: "Remove this pin?", + description: `"${pin.destination}" is no longer trusted. The next connection to it re-decides on first sight.`, + confirmLabel: "Remove", + danger: true, + }); + if (!ok) return; + remove.mutate(pin.destination); + } + + return ( +
  • +
    +
    {pin.destination}
    +
    + +
    +
    + Pinned {pin.createdAt.slice(0, 10)} · last match {pin.updatedAt.slice(0, 10)} +
    + {remove.isError && remove.variables === pin.destination && ( +

    + {errMessage(remove.error, "The pin could not be removed.")} +

    + )} +
    + +
  • + ); +} + +/** The add dialog's one validity rule; emptiness is a gap (gold), the server's refusal is the red one. */ +const hostPinSchema = z + .object({ + destination: z.string(), + hostKey: z.string(), + }) + .superRefine((values, ctx) => { + if (values.destination.trim() === "") { + ctx.addIssue({ + code: "custom", + path: ["destination"], + message: "A destination is required", + gap: true, + }); + } + if (values.hostKey.trim() === "") { + ctx.addIssue({ code: "custom", path: ["hostKey"], message: "A host key line is required", gap: true }); + } + }); + +/** + * `POST /api/ssh/host-pins` (the explicit-pin door). The dialog title is + * static: what the person types lands in the fields and the body, never in + * the heading. A server refusal (a bad canonical spelling, a destination + * already pinned to a DIFFERENT key) is the red line: it says "what you typed + * is wrong", and for a conflicting pin the sentence IS the recovery map - + * delete the standing pin first, nothing overwrites. + */ +function AddPinDialog({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { + const create = useCreateSshHostPin(); + const [serverError, setServerError] = useState(null); + + const form = makeForm({ + defaultValues: { destination: "", hostKey: "" }, + validator: hostPinSchema, + onSubmit: async (values) => { + const destination = values.destination.trim(); + const hostKey = values.hostKey.trim(); + if (destination === "" || hostKey === "") return; + setServerError(null); + try { + await create.mutateAsync({ destination, hostKey }); + onOpenChange(false); + } catch (err) { + setServerError(errMessage(err, "The pin could not be added.")); + } + }, + }); + const disabled = useSubmitDisabled(form, create.isPending); + + return ( + + + + Add destination pin + + A host key you supply yourself. Use this when the destination is trusted under an SSH HostKeyAlias and the + key home cannot capture it. + + + + {(field) => ( +
    + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.isTouched && + (() => { + const shown = fieldErrorToned(field.state.meta.errors); + return ( + shown && ( +

    + {shown.text} +

    + ) + ); + })()} +
    + )} +
    + + {(field) => ( +
    + +