From 13846b66afb2c609b0b7f4476e6e843c5f9f8423 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 14:29:03 -0700
Subject: [PATCH 01/35] docs(ssh): record the port-forwards design, amending
the relay spec's non-goal
---
.../2026-10-15-ssh-port-forwards-design.md | 178 ++++++++++++++++++
1 file changed, 178 insertions(+)
create mode 100644 docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
diff --git a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
new file mode 100644
index 000000000..34aba8a09
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
@@ -0,0 +1,178 @@
+# SSH Port Forwards
+
+Status: design, for review. Dated 2026-10-15.
+Branch: `feat/ssh-port-forwards`, cut from `origin/main`. This deliberately amends a
+written non-goal: the relay spec (`2026-10-08-ssh-agent-relay-design.md` §1) listed
+port forwarding out of scope, and the launcher refused it at three layers. The prior
+specs stay as the record of why; this one records what changed and why it is sound.
+
+## 1. Problem and goal
+
+An SSH pane gives the user a shell on the destination D, dialed from the connecting
+machine B. Plenty of services sit behind D: a database on D's private network, an
+admin API, a staging box only D can see. Today reaching them means hand-running
+tunnel commands inside the pane, and nothing in Subshell knows they exist.
+
+This feature makes port forwarding a first-class part of an SSH connection. At
+launch the user may declare forward rows, and the connection carries them for its
+lifetime. All three OpenSSH kinds ship: local (`-L`), dynamic/SOCKS (`-D`), and
+remote (`-R`). SSH presets can store rows, so "this host always forwards the DB"
+is a preset, not a ritual. Forwards bind to loopback by default; letting them bind
+wider is a per-machine owner decision.
+
+## 2. What this changes, stated precisely
+
+The refusal this feature inverts is real and was reasoned, so the inversion has to
+name what exactly stays and what goes.
+
+What goes: a config file the connecting account holds can no longer be the reason
+nothing forwards. What stays: **a config file can never be the reason something
+does.** The resolver keeps refusing `LocalForward`/`RemoteForward`/`DynamicForward`
+found by `ssh -G`, by name, unchanged and pinned by test; the frozen snapshot keeps
+`forwards` and `tunnels` as literal-null forbidden members; `Tunnel` (the tun
+device) stays `no`, and `ForwardAgent`/`ForwardX11` toward D stay `no`. The M1
+argument, "missing a forward would be the silent-widening defect," was always about
+*ambient* directives the user never consciously approved. What ships here is the
+opposite kind of thing: explicit user-declared rows, visible in the launch form,
+saved in presets, audited at launch.
+
+The widening is bounded by what the connection already grants. An approved `-L` or
+`-D` gives B less than the shell on D that this very connection hands the user (from
+inside the pane you can `socat` the same path by hand). An approved `-R` asks D to
+bind a port, and D's sshd decides under `GatewayPorts`/`PermitRemoteOpen` exactly as
+it would for any client; we relay the request and own the asking, nothing more. The
+accounting goes into `docs/security.md` §SSH surfaces as a paragraph, not a patch-on-
+a-refusal: config-derived forwards are still refused by name, and the new rows enter
+through a different door.
+
+## 3. Terminology
+
+B, D, J keep the ssh-anywhere spec's meanings (connecting machine, destination, jump
+hop). A **forward row** is one declared tunnel: a `kind`, a **listen** end (`bindHost`
++ `listenPort`, bound by B for local/dynamic, by D for remote), and for local/remote
+a **target** (`targetHost` + `targetPort`, reached through D for local, through B for
+remote). Dynamic rows have no target: the SSH channel decides destinations at runtime,
+which is what makes a SOCKS proxy useful and is exactly the reach a shell on D already
+has.
+
+## 4. Row grammar
+
+New protocol module `packages/subshell-protocol/src/ssh-forwards.ts`, pure and
+Metro-safe like its neighbors:
+
+- `SshForwardWire`: `kind` (`local` | `remote` | `dynamic`), `listenPort` (integer
+ 1..65535, always explicit; no ephemeral/auto ports), optional `bindHost`
+ (canonical host/IPv4/IPv6, bracketed IPv6 kept, option-like leading `-` refused
+ the way the snapshot grammar refuses it), and `targetHost`/`targetPort` required
+ for local/remote and forbidden for dynamic.
+- `parseSshForwardRows()`: the closed-shape rebuild discipline of
+ `parseSshConnectionSnapshot`. Any bad row refuses the whole set (there is no
+ "drop the bad one" in an approval list). Cap at 16 rows. Duplicate listen ends
+ refused: same `bindHost` (canonically compared) and `listenPort` twice is a bug,
+ not a configuration.
+
+Rows ride the pane's ssh launch payload **beside** the snapshot, never inside it.
+The snapshot is the resolver's output and its grammar says config-derived forwards
+do not survive it; approved rows are user input from a different door, and keeping
+the channels distinct is what lets the old refusal stay honest.
+
+## 5. Rendering
+
+`renderSshConfigContents` grows an options object, `{ relay?, forwards? }`. With
+approved rows it emits a second config block, `Host `,
+**after** the `Host *` policy block, carrying `LocalForward` / `RemoteForward` /
+`DynamicForward` lines composed only from re-parsed rows.
+
+Two facts about OpenSSH make the placement the whole design. First,
+`ClearAllForwardings` clears forwardings "specified in configuration files **or on
+the command line**" at the point it is applied, so approved rows cannot ride argv
+where the policy block would wipe them; they must be added after the clear, inside
+the same file, which is also why the rows stay out of `sshOptionTokens` (those are
+the destination-scoped facts the policy must not fight). Second, ProxyJump children
+re-read the `-F` file matching their own host, so they hit `Host *` (policy,
+cleared) and never match the exact final-host pattern: a forward cannot leak onto a
+hop's connection.
+
+Byte stability is kept (same approval renders the same file), so the run's recorded
+config byte-check and spec-22's sweep keep working. `ExitOnForwardFailure` stays at
+ssh's default: a busy port prints ssh's own warning into the pane and the session
+carries on. The forward is auxiliary to the terminal, not its precondition, and the
+form says so.
+
+## 6. Trust and gates
+
+- **Loopback is the default and the floor.** An omitted `bindHost` binds ssh's own
+ loopback, same as typing `127.0.0.1`. Loopback binds on B are reachable by B's
+ other OS users; that is §0's accepted posture verbatim, the same one the node's
+ loopback dashboard already carries, and `security.md` says so in one sentence.
+- **Wide binds are a machine decision.** A row naming a non-loopback bind host is
+ refused at launch unless the connecting node's new `sshWideForwards` setting is on:
+ owner-only (admin on `local`), cookie-only, default off, audited
+ `node.ssh_wide_forwards.update`, presented on the node's SSH card beside the
+ allow-SSH switch. Enforcement is plane-side only, deliberately: the node already
+ gates the whole SSH subsystem, the render trusts the approved rows the launch
+ carries, and a plane lying about the flag is the control-plane-compromise case §0
+ accepts. A wide bind turns B into a router for whatever can reach that port; the
+ setting is the owner saying their network is trusted enough for that, per machine.
+- **-R binds are the destination's policy**, not ours; the copy names it.
+- **Auth modes are untouched.** A forward is an option on the one already-approved
+ connection. Password mode and the one-shot local-key path gain nothing new: rows
+ never multiply connections, and the local-key signer still signs exactly once.
+- **Older agents are refused, never downgraded.** A launch carrying rows toward an
+ agent predating the grammar is refused by name with an "update this machine"
+ remedy. Silently stripping a forward is precisely the silent-widening failure the
+ resolver's refusal was written against; a dropped forward is a lie about what the
+ user approved.
+
+## 7. Surfaces
+
+- **Launch form**: a "Port forwards" group inside the existing `ssh-advanced-options`
+ disclosure. Per row: kind, listen port, optional bind host (only offered when the
+ selected machine reports wide binds on), target host and port (hidden for dynamic),
+ plus one detail-role line explaining what the kind does and where the port lives.
+ The forward dies with the pane, and the copy says so.
+- **Presets**: rows persist beside destination/auth/key choices (JSON column on
+ `ssh_presets`), validated at save through the same parser, applied to the form like
+ every other preset choice. A preset with rows is still unaudited CRUD, per the
+ presets precedent; the launch itself audits.
+- **Node overview**: the SSH card grows the wide-forwards toggle, owner-only.
+- The pane itself gains no new live surface in this round; see §10.
+
+## 8. Audit
+
+`ssh.launch` metadata grows a bounded summary of the approved rows (kind, canonical
+listen end, canonical target for local/remote). These are the same class of fact the
+row already records (it names the destination), and they answer "which machine is
+forwarding what" for a security review without a new event family. The wide-forwards
+flip writes its own row in the nodes family.
+
+## 9. Tests
+
+- Protocol: accept/refuse census on `parseSshForwardRows` (both IPv6 spellings, port
+ bounds, dynamic target rules, kind closedness, cap, dupes).
+- Resolver: the config-forward refusal pins stay green (this change must not touch
+ them; a test guards that a config `LocalForward` still fails resolution).
+- Render: byte snapshots (policy-first ordering, the forward block, relay and
+ forwards coexisting, the exact-Host pattern not matching the hop's host).
+- Plane: launch route gate matrix (loopback vs wide bind x node setting off/on),
+ min-protocol refusal, duplicate refusal, audit summary shape; preset save
+ validation and round-trip.
+- Node: launch re-validation of rows (a hostile list cannot render), config
+ byte-check still passes.
+- Web: draft round-trip, bind-host gating on the node flag, preset editor rows.
+- E2E: extend spec 22's real-sshd pattern: launch with a `-L` row and curl the bound
+ loopback port for the fixture's bytes; a `-D` leg through `curl --socks5-hostname`;
+ a `-R` loopback-bind leg; the wide-bind refusal before the toggle and acceptance
+ after. Assert the recorded config carries the forward block and the `Host *` clear.
+
+## 10. Non-goals and follow-ups
+
+- Not managed-tunnel objects: no start/stop apart from the pane, no live tunnel
+ list, no health. If forwards outgrow the pane, that's a new spec.
+- No ephemeral/auto ports (a row without a port has no honest audit story).
+- No way to use a forwarded port from the browser or the desktop app's machine;
+ forwards terminate on B, where the ssh process lives.
+- No pane-header "active forwards" display yet; the launch audit and the pane's ssh
+ config are the record.
+- `ForwardAgent`, `ForwardX11`, and `Tunnel` toward D stay refused: this feature is
+ TCP port forwarding, and nothing here opens those doors.
From a1e711885c96ea0ce9de2c1bbbbe2ea0ffba34cb Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 14:38:31 -0700
Subject: [PATCH 02/35] feat(ssh): forward-row grammar in the protocol package
---
.../2026-10-15-ssh-port-forwards-design.md | 66 ++++---
.../src/__tests__/ssh-forwards.test.ts | 154 +++++++++++++++++
packages/subshell-protocol/src/index.ts | 11 ++
packages/subshell-protocol/src/ssh-config.ts | 19 +-
.../subshell-protocol/src/ssh-forwards.ts | 162 ++++++++++++++++++
5 files changed, 383 insertions(+), 29 deletions(-)
create mode 100644 packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
create mode 100644 packages/subshell-protocol/src/ssh-forwards.ts
diff --git a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
index 34aba8a09..e36e46385 100644
--- a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
+++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
@@ -78,20 +78,36 @@ the channels distinct is what lets the old refusal stay honest.
## 5. Rendering
-`renderSshConfigContents` grows an options object, `{ relay?, forwards? }`. With
-approved rows it emits a second config block, `Host `,
-**after** the `Host *` policy block, carrying `LocalForward` / `RemoteForward` /
-`DynamicForward` lines composed only from re-parsed rows.
-
-Two facts about OpenSSH make the placement the whole design. First,
-`ClearAllForwardings` clears forwardings "specified in configuration files **or on
-the command line**" at the point it is applied, so approved rows cannot ride argv
-where the policy block would wipe them; they must be added after the clear, inside
-the same file, which is also why the rows stay out of `sshOptionTokens` (those are
-the destination-scoped facts the policy must not fight). Second, ProxyJump children
-re-read the `-F` file matching their own host, so they hit `Host *` (policy,
-cleared) and never match the exact final-host pattern: a forward cannot leak onto a
-hop's connection.
+`renderSshConfigContents` grows a third argument, the approved row list (the
+relay option stays where it is). Rendering is the plane's job end to end - the
+launch frame still carries only `{ configPath, fileContent }` - so no wire
+grammar changes and no agent update is required: the rows shape the bytes the
+node already writes verbatim. When rows are present the policy block **drops
+its `ClearAllForwardings yes` line** and a second block appears: `Host `, carrying `LocalForward` / `RemoteForward` / `DynamicForward`
+lines composed only from re-parsed rows. Omitted bind hosts render explicitly as
+`127.0.0.1:port` for local/dynamic; a remote row with no bind host rides D's own
+policy spelling. Without rows, the file is byte-identical to what renders today,
+clear included.
+
+That omission is measured, not assumed (OpenSSH 10.2p1, real sshd, observable
+listener): `ClearAllForwardings` applies at the END of the whole config load, not
+where it is written, so forwardings survive it neither from a later block nor from
+the command line. Placement "after the clear" was the first design and the probe
+buried it. What makes omitting the clear honest is where forwardings can otherwise
+come from on this connection: `-F` replaces the user file outright, so the only
+channels are our rendered lines and the root-owned system file - the same
+"inside the OS trust boundary, root's to decide" class the renderer's header
+already names. The connecting account's own config never reaches a Subshell
+launch, which is what the clear was really guarding.
+
+The exact-Host block still matters for the other reason: ProxyJump children re-read
+the `-F` file matching their own host, so they inherit the `Host *` policy (without
+the now-absent clear, which they never needed for confinement: `ForwardAgent`/
+`ForwardX11 no` and friends are first-obtained keywords) and never match the final
+host's pattern. A forward cannot leak onto a hop's connection. One grammar corner:
+a launch whose ProxyJump names the destination host itself is refused with forwards
+(the pattern would match both processes); the route refuses it by name.
Byte stability is kept (same approval renders the same file), so the run's recorded
config byte-check and spec-22's sweep keep working. `ExitOnForwardFailure` stays at
@@ -118,11 +134,14 @@ form says so.
- **Auth modes are untouched.** A forward is an option on the one already-approved
connection. Password mode and the one-shot local-key path gain nothing new: rows
never multiply connections, and the local-key signer still signs exactly once.
-- **Older agents are refused, never downgraded.** A launch carrying rows toward an
- agent predating the grammar is refused by name with an "update this machine"
- remedy. Silently stripping a forward is precisely the silent-widening failure the
- resolver's refusal was written against; a dropped forward is a lie about what the
- user approved.
+- **No agent update is required, and none is silently assumed.** The rows shape
+ the rendered config, and the launch frame already carries the rendered bytes;
+ every agent that writes a Subshell ssh config writes a forwarding one. What
+ must never happen is a plane rendering a launch WITHOUT a row the user
+ approved: compose re-validates the row list at the same boundary it
+ re-validates the snapshot, and a grammar refusal throws rather than dropping
+ a row (silently stripping a forward is the silent-widening failure the
+ resolver's refusal was written against, seen from the other side).
## 7. Surfaces
@@ -155,10 +174,11 @@ flip writes its own row in the nodes family.
- Render: byte snapshots (policy-first ordering, the forward block, relay and
forwards coexisting, the exact-Host pattern not matching the hop's host).
- Plane: launch route gate matrix (loopback vs wide bind x node setting off/on),
- min-protocol refusal, duplicate refusal, audit summary shape; preset save
- validation and round-trip.
-- Node: launch re-validation of rows (a hostile list cannot render), config
- byte-check still passes.
+ duplicate refusal, jump-names-destination refusal, audit summary shape;
+ preset save validation and round-trip.
+- Node: unchanged launch path (the frame still carries configPath + content
+ bytes; the path byte-check and the config dir sweep must still pass for a
+ launch that forwards).
- Web: draft round-trip, bind-host gating on the node flag, preset editor rows.
- E2E: extend spec 22's real-sshd pattern: launch with a `-L` row and curl the bound
loopback port for the fixture's bytes; a `-D` leg through `curl --socks5-hostname`;
diff --git a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
new file mode 100644
index 000000000..95d393613
--- /dev/null
+++ b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from "bun:test";
+import {
+ isLoopbackForwardBind,
+ isSshForwardKind,
+ parseSshForwardRows,
+ SSH_FORWARD_KINDS,
+ SSH_MAX_FORWARD_ROWS,
+ type SshForwardWire,
+} from "../ssh-forwards.js";
+
+/**
+ * The forward-row grammar (spec 2026-10-15 §4). These refusals are the
+ * shape half of the security story - the node re-runs this parser on every
+ * launch that names forwards, so what renders can never be wider than what
+ * this grammar can express. Policy (the wide-bind gate) is deliberately NOT
+ * tested here: this module answers "is this a row", never "is this row
+ * allowed on this machine".
+ */
+
+function makeRow(overrides: Partial = {}): SshForwardWire {
+ return {
+ kind: "local",
+ listenPort: 5432,
+ bindHost: null,
+ targetHost: "db.internal",
+ targetPort: 5432,
+ ...overrides,
+ };
+}
+
+describe("parseSshForwardRows", () => {
+ it("round-trips one row of each kind", () => {
+ const rows: SshForwardWire[] = [
+ makeRow(),
+ makeRow({ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "node.internal", targetPort: 3000 }),
+ makeRow({ kind: "dynamic", listenPort: 1080, targetHost: null, targetPort: null }),
+ ];
+ expect(parseSshForwardRows(structuredClone(rows))).toEqual(rows);
+ });
+
+ it("treats null and absent as no forwards, and the empty list round-trips", () => {
+ expect(parseSshForwardRows(null)).toEqual([]);
+ expect(parseSshForwardRows(undefined)).toEqual([]);
+ expect(parseSshForwardRows([])).toEqual([]);
+ });
+
+ it("accepts a bound row: loopback v4, bracketed v6, and a named host", () => {
+ for (const bindHost of ["127.0.0.1", "[::1]", "localhost", "10.0.0.5", "fwd.internal"]) {
+ expect(parseSshForwardRows([makeRow({ bindHost })])).toEqual([makeRow({ bindHost })]);
+ }
+ });
+
+ it("refuses a bare (unbracketed) IPv6 bind: the host grammar wants brackets, same as every other host field", () => {
+ expect(parseSshForwardRows([makeRow({ bindHost: "::1" })])).toBeNull();
+ });
+
+ it("refuses an option-like leading dash on any host member", () => {
+ expect(parseSshForwardRows([makeRow({ bindHost: "-oProxyCommand=evil" })])).toBeNull();
+ expect(parseSshForwardRows([makeRow({ targetHost: "-lroot" })])).toBeNull();
+ });
+
+ it("refuses a target baked with a port (the grammar keeps port its own field)", () => {
+ expect(parseSshForwardRows([makeRow({ targetHost: "db.internal:5432" })])).toBeNull();
+ });
+
+ it("refuses an unknown kind and a wrong-case kind (enums are enums)", () => {
+ expect(parseSshForwardRows([makeRow({ kind: "tunnel" as SshForwardWire["kind"] })])).toBeNull();
+ expect(parseSshForwardRows([{ ...makeRow(), kind: "Local" }])).toBeNull();
+ });
+
+ it("refuses ports outside 1..65535 and non-integers", () => {
+ for (const listenPort of [0, 65536, -1, 80.5, Number.NaN, "80"]) {
+ expect(parseSshForwardRows([makeRow({ listenPort: listenPort as unknown as number })])).toBeNull();
+ }
+ for (const targetPort of [0, 65536, 1.5]) {
+ expect(parseSshForwardRows([makeRow({ targetPort: targetPort as unknown as number })])).toBeNull();
+ }
+ });
+
+ it("refuses a dynamic row that carries a target (a contradiction, not a hint)", () => {
+ expect(parseSshForwardRows([makeRow({ kind: "dynamic", targetHost: "x.example" })])).toBeNull();
+ expect(parseSshForwardRows([makeRow({ kind: "dynamic", targetPort: 80 })])).toBeNull();
+ });
+
+ it("refuses local/remote rows missing a target member", () => {
+ expect(parseSshForwardRows([{ kind: "local", listenPort: 5432, bindHost: null }])).toBeNull();
+ expect(parseSshForwardRows([{ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "h" }])).toBeNull();
+ });
+
+ it("refuses an absent or non-null bindHost member: presence is required, null is the default spelling", () => {
+ expect(parseSshForwardRows([{ kind: "local", listenPort: 5432, targetHost: "h", targetPort: 1 }])).toBeNull();
+ expect(parseSshForwardRows([makeRow({ bindHost: "" })])).toBeNull();
+ });
+
+ it("refuses a row with an unknown key rather than dropping it", () => {
+ expect(parseSshForwardRows([{ ...makeRow(), extra: true }])).toBeNull();
+ });
+
+ it("refuses a duplicate listen end, case-insensitively on the name", () => {
+ expect(parseSshForwardRows([makeRow(), makeRow({ targetHost: "other.example" })])).toBeNull();
+ expect(parseSshForwardRows([makeRow({ bindHost: "LOCALHOST" }), makeRow({ bindHost: "localhost" })])).toBeNull();
+ });
+
+ it("accepts two rows on one port under different literal names (the bind race is ssh's warning, not this grammar's)", () => {
+ const rows = [makeRow({ bindHost: "localhost" }), makeRow({ bindHost: "127.0.0.1" })];
+ expect(parseSshForwardRows(rows)).toEqual(rows);
+ });
+
+ it("refuses a non-array list", () => {
+ expect(parseSshForwardRows({ 0: makeRow() })).toBeNull();
+ expect(parseSshForwardRows("local")).toBeNull();
+ expect(parseSshForwardRows([null, undefined, 42, []])).toBeNull();
+ });
+
+ it("refuses over the cap rather than truncating", () => {
+ const over = Array.from({ length: SSH_MAX_FORWARD_ROWS + 1 }, (_, i) => makeRow({ listenPort: i + 1 }));
+ expect(parseSshForwardRows(over)).toBeNull();
+ const atCap = over.slice(0, SSH_MAX_FORWARD_ROWS);
+ expect(parseSshForwardRows(atCap)).toEqual(atCap);
+ });
+
+ it("returns rebuilt rows, never the input objects (stray-array mutation cannot ride along)", () => {
+ const input = [makeRow()];
+ const parsed = parseSshForwardRows(input) ?? [];
+ expect(parsed).toEqual(input);
+ expect(parsed[0]).not.toBe(input[0]);
+ });
+});
+
+describe("isLoopbackForwardBind", () => {
+ const loopback = [null, "localhost", "LOCALHOST", "Localhost", "[::1]", "127.0.0.1", "127.9.9.9", "127.0.0.0"];
+ for (const bindHost of loopback) {
+ it(`confines ${bindHost ?? "(omitted)"}`, () => {
+ expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(true);
+ });
+ }
+ const wide = ["0.0.0.0", "::", "[::]", "10.0.0.5", "example.com", "[::ffff:127.0.0.1]", "mybox.internal"];
+ for (const bindHost of wide) {
+ it(`names ${bindHost} wide (fails closed: the test is the written name, not a DNS answer)`, () => {
+ expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(false);
+ });
+ }
+});
+
+describe("SSH_FORWARD_KINDS / isSshForwardKind", () => {
+ it("the runtime list matches the type census", () => {
+ expect([...SSH_FORWARD_KINDS].sort()).toEqual(["dynamic", "local", "remote"]);
+ });
+ it("accepts exactly the named kinds, case-sensitively", () => {
+ for (const kind of SSH_FORWARD_KINDS) expect(isSshForwardKind(kind)).toBe(true);
+ expect(isSshForwardKind("LOCAL")).toBe(false);
+ expect(isSshForwardKind(undefined)).toBe(false);
+ });
+});
diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts
index a8f058ab8..c090ebc54 100644
--- a/packages/subshell-protocol/src/index.ts
+++ b/packages/subshell-protocol/src/index.ts
@@ -249,6 +249,8 @@ export {
type ViewerCapacity,
} from "./shared-geometry.js";
export {
+ isSshHostName,
+ isSshPort,
parseSshConnectionSnapshot,
SSH_FORBIDDEN_SNAPSHOT_FIELDS,
type SshConnectionSnapshotWire,
@@ -260,6 +262,15 @@ export {
SSH_ERROR_DESCRIPTIONS,
type SshErrorCode,
} from "./ssh-errors.js";
+export {
+ isLoopbackForwardBind,
+ isSshForwardKind,
+ parseSshForwardRows,
+ SSH_FORWARD_KINDS,
+ SSH_MAX_FORWARD_ROWS,
+ type SshForwardKind,
+ type SshForwardWire,
+} from "./ssh-forwards.js";
export {
isSshFingerprint,
isSshFingerprints,
diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts
index 4ce8a511b..9710af426 100644
--- a/packages/subshell-protocol/src/ssh-config.ts
+++ b/packages/subshell-protocol/src/ssh-config.ts
@@ -163,8 +163,12 @@ function isWireName(value: unknown): value is string {
);
}
-/** A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. */
-function isHostName(value: unknown): value is string {
+/**
+ * A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}.
+ * Exported because the forward-row grammar validates host-valued fields under
+ * exactly the same rule; one definition, not two that can drift.
+ */
+export function isSshHostName(value: unknown): value is string {
return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && HOST_RE.test(value);
}
@@ -178,8 +182,11 @@ 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 {
+/**
+ * A resolved port: a positive integer inside the legal range. Exported for the
+ * forward-row grammar, which bounds listen and target ports by the same rule.
+ */
+export function isSshPort(value: unknown): value is number {
return isInt(value) && (value as number) >= 1 && (value as number) <= 65535;
}
@@ -189,7 +196,7 @@ function isPathListWithin(value: unknown, cap: number): value is string[] {
}
function isHop(value: unknown): value is SshHopWire {
- if (!isRecord(value) || !isHostName(value.host) || !isPort(value.port)) return false;
+ if (!isRecord(value) || !isSshHostName(value.host) || !isSshPort(value.port)) return false;
if (!("user" in value)) return false;
return value.user === null || isUserName(value.user);
}
@@ -213,7 +220,7 @@ export function parseSshConnectionSnapshot(value: unknown): SshConnectionSnapsho
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 (!isWireName(value.alias) || !isSshHostName(value.host) || !isSshPort(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;
diff --git a/packages/subshell-protocol/src/ssh-forwards.ts b/packages/subshell-protocol/src/ssh-forwards.ts
new file mode 100644
index 000000000..f95256040
--- /dev/null
+++ b/packages/subshell-protocol/src/ssh-forwards.ts
@@ -0,0 +1,162 @@
+/**
+ * The user-declared port-forward row (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md).
+ *
+ * Read the sibling `ssh-config.ts` first, because the two grammars draw the
+ * line the security posture depends on: the SNAPSHOT's forbidden `forwards`
+ * member stays literal null, so a forward found in the connecting account's
+ * own ssh config still fails resolution by name. What lives HERE is the other
+ * door - a row the human typed into the launch form (or a preset saved from
+ * it), approved on its face, rendered into the per-connection config's exact
+ * destination block. Same rebuild discipline as `parseSshConnectionSnapshot`:
+ * any bad row refuses the whole list, because half a list of approved
+ * forwards is a lie about what the user approved.
+ *
+ * Shape is the grammar's job; policy is the plane's. `bindHost` may name ANY
+ * canonical host here, including `0.0.0.0`: whether a wide bind is ALLOWED is
+ * the connecting node's `sshWideForwards` decision, checked at the launch
+ * route against the machine's setting, never here. This module only knows
+ * what a row IS, so the node can re-validate the shape it was handed without
+ * holding the plane's policy.
+ *
+ * Imports no `node:` builtin; Metro-safe barrel like its neighbors.
+ */
+
+import { isRecord, isStr } from "./guards.js";
+import { isSshHostName, isSshPort } from "./ssh-config.js";
+
+/** The three OpenSSH forward kinds: `-L`, `-R`, `-D`. */
+export const SSH_FORWARD_KINDS = ["local", "remote", "dynamic"] as const;
+
+/** One member of {@link SSH_FORWARD_KINDS}. */
+export type SshForwardKind = (typeof SSH_FORWARD_KINDS)[number];
+
+/** True when `value` is one of the named kinds (case-sensitive: the wire spells enums, never prose). */
+export function isSshForwardKind(value: unknown): value is SshForwardKind {
+ return isStr(value) && (SSH_FORWARD_KINDS as readonly string[]).includes(value);
+}
+
+/**
+ * One approved forward. The listen end is where the port binds: on the
+ * connecting machine B for `local`/`dynamic`, on the destination D for
+ * `remote`. The target is what traffic reaches: a host D can see for
+ * `local`, a host B can see for `remote`, nothing at all for `dynamic`
+ * (the SOCKS channel picks its own destinations at runtime, which is the
+ * whole feature and exactly the reach a shell on D already grants).
+ *
+ * A `type`, not an interface: rows ride inside JSON command members, and a
+ * type has an implicit index signature the command builders rely on.
+ */
+export type SshForwardWire = {
+ /** Which way traffic flows; see {@link SshForwardKind}. */
+ kind: SshForwardKind;
+ /** Listen port, 1..65535. Always explicit: an ephemeral/auto port has no honest audit story (spec §10). */
+ listenPort: number;
+ /**
+ * Listen bind host; null means "the stack's loopback default" (rendered
+ * explicitly for local/dynamic; for remote, D's own `GatewayPorts` policy
+ * decides, which is D's to make - spec §6). Grammar-valid host otherwise;
+ * the WIDE question is the plane's gate, not this parser's.
+ */
+ bindHost: string | null;
+ /** Target host; required for local/remote, null only for dynamic. */
+ targetHost: string | null;
+ /** Target port; follows the same kind rule. */
+ targetPort: number | null;
+};
+
+/**
+ * Rows one connection may carry. Generous for a real dev host (a database, a
+ * cache, an admin API, and room to spare) and small enough that the audit
+ * summary and the rendered block stay reviewable. Over the cap is a refusal,
+ * never a truncation.
+ */
+export const SSH_MAX_FORWARD_ROWS = 16;
+
+/** The keys one row may spell; anything else means the caller hand-built a shape this grammar does not have. */
+const FORWARD_ROW_KEYS: readonly (keyof SshForwardWire)[] = [
+ "kind",
+ "listenPort",
+ "bindHost",
+ "targetHost",
+ "targetPort",
+];
+
+/**
+ * Validate and rebuild one row. Returns the narrowed row or null; the caller
+ * never sees the input object (rebuilt output, stray keys die with the input,
+ * same discipline as the snapshot parser).
+ */
+function parseSshForwardRow(value: unknown): SshForwardWire | null {
+ if (!isRecord(value)) return null;
+ for (const key of Object.keys(value)) {
+ if (!(FORWARD_ROW_KEYS as readonly string[]).includes(key)) return null;
+ }
+ if (!isSshForwardKind(value.kind) || !isSshPort(value.listenPort)) return null;
+ if (!("bindHost" in value)) return null;
+ if (value.bindHost !== null && !isSshHostName(value.bindHost)) return null;
+ const needsTarget = value.kind === "local" || value.kind === "remote";
+ if (needsTarget) {
+ if (!("targetHost" in value) || !isSshHostName(value.targetHost)) return null;
+ if (!("targetPort" in value) || !isSshPort(value.targetPort)) return null;
+ } else {
+ // A dynamic row carrying a target is a contradiction, not a hint: refuse.
+ if ((value.targetHost ?? null) !== null || (value.targetPort ?? null) !== null) return null;
+ }
+ return {
+ kind: value.kind,
+ listenPort: value.listenPort,
+ bindHost: value.bindHost === undefined ? null : (value.bindHost as string | null),
+ targetHost: needsTarget ? (value.targetHost as string) : null,
+ targetPort: needsTarget ? (value.targetPort as number) : null,
+ };
+}
+
+/**
+ * Validate a whole forward list: each row through {@link parseSshForwardRow},
+ * then the LIST rules. A duplicate listen end (same host case-insensitively,
+ * same port) refuses the list: two rows competing for one bind is a bug the
+ * user should hear about, not a race ssh silently half-wins. The cap is a
+ * refusal too, never a trim.
+ *
+ * @param value - candidate rows (parsed JSON; unknown provenance), or null/undefined for "no forwards"
+ * @returns the narrowed rows (possibly empty), or null when malformed or over cap
+ */
+export function parseSshForwardRows(value: unknown): SshForwardWire[] | null {
+ if (value === null || value === undefined) return [];
+ if (!Array.isArray(value) || value.length > SSH_MAX_FORWARD_ROWS) return null;
+ const rows: SshForwardWire[] = [];
+ const seen = new Set();
+ for (const item of value) {
+ const row = parseSshForwardRow(item);
+ if (row === null) return null;
+ // Casefold only for the dupe key: hostnames match case-insensitively, and
+ // a "LOCALHOST" beside "localhost" is one bind, not two.
+ const key = `${(row.bindHost ?? "").toLowerCase()}|${row.listenPort}`;
+ if (seen.has(key)) return null;
+ seen.add(key);
+ rows.push(row);
+ }
+ return rows;
+}
+
+/**
+ * True when this row's listen end is confined to loopback: an omitted
+ * `bindHost` (which renders as loopback) or a named loopback address
+ * (`127.0.0.0/8`, `::1`, `localhost`). Exported for the plane's wide-bind
+ * gate: anything answering false requires the connecting machine's
+ * `sshWideForwards` setting. A hostname that merely HAPPENS to resolve to
+ * loopback ("mybox.internal") is not loopback here - the test is the name
+ * the user wrote, because that name is what the rendered config carries and
+ * what DNS can later point elsewhere.
+ */
+export function isLoopbackForwardBind(row: SshForwardWire): boolean {
+ if (row.bindHost === null) return true;
+ const host = row.bindHost.toLowerCase();
+ // The grammar admits IPv6 only bracketed (HOST_RE), so `[::1]` is the
+ // spelling a row can actually carry.
+ if (host === "localhost" || host === "[::1]") return true;
+ // IPv4 127.0.0.0/8: dotted-quad only (the grammar's host shape admits the
+ // other spellings, but nobody renders those here and refusing them as
+ // "wide" fails closed, which is the safe direction).
+ return /^127(\.\d{1,3}){3}$/.test(host);
+}
From e91cba8960d5f5571e8bb251df13b94454fe7069 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 14:48:44 -0700
Subject: [PATCH 03/35] feat(ssh): render approved forward rows into the
exact-destination config block
---
.../api/src/services/ssh-launch.service.ts | 11 ++
.../src/ssh/__tests__/ssh-render.test.ts | 131 ++++++++++++++++++
packages/pane-runtime/src/ssh/ssh-render.ts | 83 ++++++++++-
3 files changed, 223 insertions(+), 2 deletions(-)
diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts
index 731435195..01a2f591e 100644
--- a/apps/server/api/src/services/ssh-launch.service.ts
+++ b/apps/server/api/src/services/ssh-launch.service.ts
@@ -21,6 +21,7 @@ import {
parseSshConnectionSnapshot,
SSH_NAME_MAX_CHARS,
type SshConnectionSnapshotWire,
+ type SshForwardWire,
} from "@internal/subshell-protocol";
import { loadNodeGate } from "@/api/nodes/node-gate.js";
import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js";
@@ -142,6 +143,15 @@ export function composeSshLaunch(args: {
*/
hostPinPath?: string;
authentication?: "password";
+ /**
+ * User-declared port-forward rows (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md).
+ * The launch route has already gate-checked them (wide binds against the
+ * machine's setting); the renderer re-validates the SHAPE here as the last
+ * station before the bytes, same impossible-state posture as the snapshot
+ * line below - a refusal that dropped rows silently would tell the user a
+ * forward exists when nothing binds it.
+ */
+ forwards?: SshForwardWire[];
}): {
configPath: string;
fileContent: string;
@@ -154,6 +164,7 @@ export function composeSshLaunch(args: {
let fileContent = renderSshConfigContents(
approved,
args.hostPinPath === undefined ? undefined : { hostPinPath: args.hostPinPath },
+ args.forwards,
);
if (args.authentication === "password") {
if (args.hostPinPath) throw new Error("Password authentication cannot use a key relay.");
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 906f5d0a5..1da86f815 100644
--- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts
+++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
+import type { SshForwardWire } from "@internal/subshell-protocol";
import {
buildAgentSocketPath,
buildSshConfigPath,
@@ -367,6 +368,136 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => {
});
});
+describe("renderSshConfigContents port forwards (spec 2026-10-15)", () => {
+ const localRow: SshForwardWire = {
+ kind: "local",
+ listenPort: 5432,
+ bindHost: null,
+ targetHost: "db.internal",
+ targetPort: 5432,
+ };
+ const wideRow: SshForwardWire = {
+ kind: "local",
+ listenPort: 8080,
+ bindHost: "10.0.0.5",
+ targetHost: "api.internal",
+ targetPort: 80,
+ };
+ const remoteRow: SshForwardWire = {
+ kind: "remote",
+ listenPort: 9090,
+ bindHost: null,
+ targetHost: "node.internal",
+ targetPort: 3000,
+ };
+ const dynamicRow: SshForwardWire = {
+ kind: "dynamic",
+ listenPort: 1080,
+ bindHost: null,
+ targetHost: null,
+ targetPort: null,
+ };
+
+ it("absent or empty rows render byte-identical to the rowless posture, clear included", () => {
+ const snap = baseSnapshot();
+ expect(renderSshConfigContents(snap, undefined, [])).toBe(renderSshConfigContents(snap));
+ expect(renderSshConfigContents(snap, undefined, [localRow])).not.toBe(renderSshConfigContents(snap));
+ });
+
+ it("renders each kind as a line under the exact destination Host, loopback binds explicit", () => {
+ const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow, wideRow, remoteRow, dynamicRow]);
+ expect(out).toContain("Host app-02.example.com");
+ expect(out).toContain(" LocalForward 127.0.0.1:5432 db.internal:5432");
+ expect(out).toContain(" LocalForward 10.0.0.5:8080 api.internal:80");
+ // A remote row with no bind host stays bare: D's own GatewayPorts policy
+ // is the authority there, and inventing a name we did not approve would
+ // be the opposite lie of the one the renderer refuses elsewhere.
+ expect(out).toContain(" RemoteForward 9090 node.internal:3000");
+ expect(out).toContain(" DynamicForward 127.0.0.1:1080");
+ });
+
+ it("the row block comes AFTER the Host * policy section, under its own header", () => {
+ const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow]);
+ const policy = out.indexOf("Host *");
+ const block = out.indexOf("Host app-02.example.com");
+ expect(policy).toBeGreaterThanOrEqual(0);
+ expect(block).toBeGreaterThan(policy);
+ // No LocalForward byte exists BEFORE the exact-Host header: the rows are
+ // under their own section, not smuggled into the policy one (the whole
+ // jump-child isolation argument rests on this shape).
+ expect(out.indexOf("LocalForward")).toBeGreaterThan(block);
+ });
+
+ it("drops ONLY the ClearAllForwardings line; every other confinement stands", () => {
+ const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow]);
+ expect(out).not.toContain("ClearAllForwardings");
+ for (const [key, value] of [
+ ["ForwardAgent", "no"],
+ ["ForwardX11", "no"],
+ ["Tunnel", "no"],
+ ["PermitRemoteOpen", "none"],
+ ["PermitLocalCommand", "no"],
+ ["EscapeChar", "none"],
+ ["ControlMaster", "no"],
+ ] as [string, string][]) {
+ expect(out).toContain(` ${key} ${value}`);
+ }
+ });
+
+ it("coexists with relay mode: pinned trust bytes AND the row block", () => {
+ const out = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh/s1/known_hosts" }, [dynamicRow]);
+ expect(out).toContain(" StrictHostKeyChecking yes");
+ expect(out).toContain(" UserKnownHostsFile /data/ssh/s1/known_hosts");
+ expect(out).toContain(" DynamicForward 127.0.0.1:1080");
+ expect(out).not.toContain("ClearAllForwardings");
+ });
+
+ it("refuses the whole render on a malformed row (no partial forward list, ever)", () => {
+ expect(() => renderSshConfigContents(baseSnapshot(), undefined, [localRow, { kind: "tunnel" } as never])).toThrow(
+ /grammar re-validation/,
+ );
+ });
+
+ it("refuses rows when a ProxyJump hop names the destination host (case-insensitively)", () => {
+ const snap = baseSnapshot({
+ host: "dup.example.com",
+ proxyJumps: [{ host: "DUP.example.com", user: null, port: 22 }],
+ });
+ expect(() => renderSshConfigContents(snap, undefined, [localRow])).toThrow(/ProxyJump names the destination/);
+ // Rowless, the same snapshot still renders: the guard belongs to forwards only.
+ expect(renderSshConfigContents(snap)).toContain("Host *");
+ });
+
+ it.skipIf(!Bun.which("ssh"))("real OpenSSH parses a rendered forward config without complaint", async () => {
+ const root = tempRoot("ssh-render-forwards-");
+ try {
+ const path = join(root, "config");
+ writeFileSync(
+ path,
+ renderSshConfigContents(
+ baseSnapshot({ identityFiles: [], certificateFiles: [], knownHostsFiles: [] }),
+ undefined,
+ [localRow, wideRow, remoteRow, dynamicRow],
+ ),
+ );
+ // -G evaluates the config for a host without connecting. The destination
+ // host's pass must exit clean; ssh would warn on a bad keyword or value.
+ const child = Bun.spawn([Bun.which("ssh") as string, "-G", "-F", path, "app-02.example.com"], {
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const stderr = await new Response(child.stderr).text();
+ expect(await child.exited).toBe(0);
+ // OpenSSH 10.2 answers -G on a non-tty stdin with a pseudo-terminal
+ // NOTICE regardless of the config; the honest check is that no CONFIG
+ // complaint rides along (a bad keyword or value names itself).
+ expect(stderr).not.toMatch(/bad configuration|unknown|invalid|overlong/i);
+ } finally {
+ cleanup(root);
+ }
+ });
+});
+
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 5a4939b25..f95c9f7ab 100644
--- a/packages/pane-runtime/src/ssh/ssh-render.ts
+++ b/packages/pane-runtime/src/ssh/ssh-render.ts
@@ -1,7 +1,9 @@
import {
parseSshConnectionSnapshot,
+ parseSshForwardRows,
SSH_PATH_MAX_CHARS,
type SshConnectionSnapshotWire,
+ type SshForwardWire,
type SshHopWire,
} from "@internal/subshell-protocol";
@@ -75,6 +77,9 @@ const MANDATORY_POLICY: readonly [string, string][] = [
["ForwardX11", "no"],
["ForwardX11Trusted", "no"],
// no local/remote/dynamic forwards or tunnels, whatever any config said
+ // (the ONE conditional policy line: omitted when the launch carries
+ // approved forward rows, which this clear would wipe anyway - see the
+ // render function's doc for the measured OpenSSH fact)
["ClearAllForwardings", "yes"],
["Tunnel", "no"],
["PermitRemoteOpen", "none"],
@@ -146,6 +151,28 @@ export interface SshRelayRenderOption {
/** 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" };
+/** The ssh_config keyword each approved forward kind renders to (the row's kind is grammar-closed, so this lookup is total). */
+const FORWARD_KEYWORD: Record = {
+ local: "LocalForward",
+ remote: "RemoteForward",
+ dynamic: "DynamicForward",
+};
+
+/**
+ * One approved forward as one config line, under the exact-destination `Host`
+ * block. The listen spelling carries no whitespace risk (the row grammar
+ * validates every host member), so no quoting seam is needed here; a null
+ * `bindHost` renders ssh's own loopback default EXPLICITLY for local/dynamic
+ * (127.0.0.1 is the name the audit and the reviewer both read), and stays
+ * spelled bare for remote, where D's `GatewayPorts` policy is the authority
+ * and adding a name we did not approve would be a lie of the opposite kind.
+ */
+function forwardLine(row: SshForwardWire): string {
+ const listen = row.bindHost === null ? (row.kind === "remote" ? "" : "127.0.0.1:") : `${row.bindHost}:`;
+ const head = `${FORWARD_KEYWORD[row.kind]} ${listen}${row.listenPort}`;
+ return ` ${row.kind === "dynamic" ? head : `${head} ${row.targetHost}:${row.targetPort}`}`;
+}
+
/**
* 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
@@ -167,9 +194,46 @@ const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new
* 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.
+ *
+ * User-declared port forwards (the third argument, spec 2026-10-15) change two
+ * bytes-worth of policy and nothing else: the `ClearAllForwardings yes` line
+ * is dropped, and an exact-destination `Host` block carrying the approved
+ * rows is appended. The drop is measured, not assumed (OpenSSH 10.2p1, real
+ * sshd, observable listener): `ClearAllForwardings` applies at the END of the
+ * whole config load, not where it is written, so forwardings survive it
+ * neither from a later block nor from the command line; keeping the line
+ * would silently undo the rows, and silently dropping a forward is precisely
+ * the lie the resolver's refusal-by-name exists to prevent. Omitting it is
+ * honest because the forward channels left are our own lines and the
+ * root-owned system file - `-F` replaces the user config outright, and the
+ * system file is the "root's to decide" class this file's header already
+ * names. The rows enter under the EXACT destination host because ProxyJump
+ * children re-read this file matching their own host: they get the `Host *`
+ * policy and never the row block, so a forward cannot leak onto a hop.
+ * Absent or empty rows, every byte is what renders today, clear included.
*/
-export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string {
+export function renderSshConfigContents(
+ snapshot: SshConnectionSnapshotWire,
+ relay?: SshRelayRenderOption,
+ forwards?: SshForwardWire[],
+): string {
assertRenderable(snapshot);
+ // Rows are re-validated HERE, at the last station before bytes a machine
+ // writes: a refused row THROWS rather than dropping, because half an
+ // approved forward list is a lie about what the user approved (the same
+ // no-partial-approval discipline as the snapshot parse).
+ let rows: SshForwardWire[] = [];
+ if (forwards !== undefined) {
+ const parsed = parseSshForwardRows(forwards);
+ if (parsed === null) throw new Error("forward rows failed grammar re-validation");
+ rows = parsed;
+ }
+ if (rows.length > 0 && snapshot.proxyJumps.some((h) => h.host.toLowerCase() === snapshot.host.toLowerCase())) {
+ // The exact-`Host` pattern would match the hop child too, handing a
+ // forward to the wrong connection. The route refuses this launch for the
+ // user first; this is the impossible-state guard beneath it.
+ throw new Error("port forwards cannot ride a launch whose ProxyJump names the destination host");
+ }
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
@@ -192,7 +256,12 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel
]
: []),
"Host *",
- ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`),
+ ...MANDATORY_POLICY.filter(
+ // The clear wipes approved rows too, measured (see the function doc):
+ // a launch with approved forwards drops the line, every other key of
+ // the policy stays, and so does the line for every rowless launch.
+ ([key]) => !(rows.length > 0 && key === "ClearAllForwardings"),
+ ).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, and neither may the system-wide DB
// (`yes` consults GlobalKnownHostsFile too, so it is /dev/null'd; one
@@ -209,6 +278,16 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel
...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`),
...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`),
]),
+ // User-declared forwards (spec 2026-10-15): a separate exact-Host section,
+ // never inside the `Host *` policy - the pattern is the whole point, it
+ // marks the final connection and only the final one.
+ ...(rows.length > 0
+ ? [
+ "# User-declared port forwards, approved at launch (spec 2026-10-15).",
+ `Host ${snapshot.host}`,
+ ...rows.map(forwardLine),
+ ]
+ : []),
"",
];
return lines.join("\n");
From 46e67db194873627a299b4c93ef84cf4d52b8dbe Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 15:05:55 -0700
Subject: [PATCH 04/35] feat(ssh): launch accepts forward rows; per-node
wide-bind setting
---
.../set-node-ssh-wide-forwards.route.test.ts | 144 +++++++++++++++
apps/server/api/src/api/nodes/node-view.ts | 5 +
.../api/nodes/set-node-ssh-enabled.route.ts | 48 ++++-
.../src/api/ssh/__tests__/ssh-routes.test.ts | 166 ++++++++++++++++++
apps/server/api/src/api/ssh/launch.route.ts | 56 +++++-
apps/server/api/src/db/migrate.ts | 2 +
.../migrations/0055-node-ssh-wide-forwards.ts | 18 ++
.../0055-node-ssh-wide-forwards.test.ts | 72 ++++++++
.../src/db/repositories/nodes.repository.ts | 25 +++
.../server/api/src/db/types/nodes.db-types.ts | 8 +
.../nodes/__tests__/remote-launcher.test.ts | 1 +
.../api/src/services/nodes/ssh-enabled.ts | 26 +++
.../api/src/services/ssh-launch.service.ts | 75 +++++++-
packages/backend-errors/src/error-codes.ts | 12 ++
14 files changed, 651 insertions(+), 7 deletions(-)
create mode 100644 apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts
create mode 100644 apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts
create mode 100644 apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts
diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts
new file mode 100644
index 000000000..3cdd54793
--- /dev/null
+++ b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts
@@ -0,0 +1,144 @@
+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-wide-forwards` (spec 2026-10-15 §6) — the per-node
+ * wide-bind allowance for SSH port forwards, opt-in and off by default,
+ * cloned from the SSH switch down to the gate: owner (`canManage`, admin on
+ * `local`), deliberately not the `edit` grantee's door.
+ *
+ * The route performs no write and records no audit itself —
+ * `setNodeSshWideForwards` is the one change-site — so this file asserts the
+ * ROUTE wired the acting human into that row.
+ */
+describe("node ssh-wide-forwards route", () => {
+ const pw = "ssh-wide-1";
+ const ownerEmail = `wf-owner-${crypto.randomUUID()}@subshell.local`;
+ const editorEmail = `wf-editor-${crypto.randomUUID()}@subshell.local`;
+ const otherEmail = `wf-other-${crypto.randomUUID()}@subshell.local`;
+ const adminEmail = `wf-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-wf-${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,
+ });
+ }
+
+ async function wfAudit(): Promise> {
+ const rows = await db
+ .selectFrom("auditEvents")
+ .select(["id", "actorUserId", "metadataJson"])
+ .where("targetId", "=", NODE)
+ .where("action", "=", "node.ssh_wide_forwards.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-wide-forwards`, {
+ 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();
+ await db
+ .updateTable("nodes")
+ .set({ sshWideForwards: 0 } as never)
+ .where("id", "=", LOCAL_NODE_ID)
+ .execute();
+ for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email);
+ });
+
+ it("defaults off: a fresh node's view carries false", async () => {
+ const res = await put(NODE, ownerCookie, false);
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(false);
+ });
+
+ 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);
+ expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true);
+ });
+
+ it("names the acting human and carries ONLY {on} in the audit row", async () => {
+ await put(NODE, ownerCookie, false);
+ const before = await wfAudit();
+
+ expect((await put(NODE, ownerCookie, true)).status).toBe(200);
+ const added = [...(await wfAudit())].filter(([id]) => !before.has(id));
+ expect(added).toHaveLength(1);
+ expect(added[0][1].actorUserId).toBe(ownerId);
+ expect(added[0][1].metadataJson).toBe('{"on":true}');
+ });
+
+ it("an edit grantee is refused — it is not their machine to widen", async () => {
+ 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 () => {
+ expect((await put(NODE, adminCookie, true)).status).toBe(403);
+ expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403);
+ const res = await put(LOCAL_NODE_ID, adminCookie, true);
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true);
+ });
+});
diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts
index a57663fbf..8d2d6dede 100644
--- a/apps/server/api/src/api/nodes/node-view.ts
+++ b/apps/server/api/src/api/nodes/node-view.ts
@@ -160,6 +160,10 @@ export const NodeViewSchema = t.Object({
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",
}),
+ sshWideForwards: t.Boolean({
+ description:
+ "Whether SSH port forwards on this machine may bind non-loopback addresses. Off by default: forwards bind loopback only, and widening turns the machine into a router for whatever network reaches the bound port. The owner's decision, refused at launch while false",
+ }),
held: t.Nullable(
t.Object({
reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], {
@@ -450,6 +454,7 @@ function nodeViewBase(
maintenanceAt: row.maintenanceAt,
maintenanceSource: row.maintenanceSource,
sshEnabled: row.sshEnabled === 1,
+ sshWideForwards: row.sshWideForwards === 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
index 3e23d5435..2422eb414 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
@@ -7,7 +7,14 @@ 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";
+import { setNodeSshEnabled, setNodeSshWideForwards } from "@/services/nodes/ssh-enabled.js";
+
+const SshWideForwardsBodySchema = t.Object({
+ on: t.Boolean({
+ description:
+ "Allow SSH port forwards on this machine to bind non-loopback addresses. Off by default: forwards bind loopback only. Owner only; admin on the control-plane host.",
+ }),
+});
const SshEnabledBodySchema = t.Object({
on: t.Boolean({
@@ -79,4 +86,43 @@ export const setNodeSshEnabledRoute = new Elysia()
"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.",
},
},
+ )
+ // The wide-bind allowance rides THIS instance rather than a module of its
+ // own for a measured reason: a second mounted route module of this shape
+ // tips the Elysia `App` inference into TS2589 (the depth ceiling already
+ // recorded for new route files; `auth-rate-limit.route.ts` is the fold
+ // precedent). The acts are one family anyway, owner-only SSH flags on the
+ // machine's row, each written at exactly one change-site.
+ .put(
+ "/:id/ssh-wide-forwards",
+ async ({ params, body, user, actor, status }) => {
+ requireCookieActor(actor, "Node SSH wide-forwards settings are 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 setNodeSshWideForwards({ nodeId: gate.row.id, on: body.on, actorUserId: user.id });
+ // The same re-read rule as the switch above: the view renders the row.
+ 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: SshWideForwardsBodySchema,
+ response: {
+ 200: NodeViewSchema,
+ 400: "ApiErrorResponse",
+ 401: "ApiErrorResponse",
+ 403: "ApiErrorResponse",
+ 404: "ApiErrorResponse",
+ },
+ detail: {
+ operationId: "setNodeSshWideForwards",
+ tags: ["nodes"],
+ description:
+ "Allow or refuse non-loopback binds for SSH port forwards on a node (owner only; admin on the control-plane host). Off by default: forwards bind loopback only, and enabling lets launches on this machine bind addresses other machines can reach. Turning it off refuses future wide binds; already-running forwards ride the connections approved when they started.",
+ },
+ },
);
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 7bfad2fd7..738636c67 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
@@ -543,6 +543,172 @@ describe("POST /api/ssh/launch", () => {
});
});
+describe("POST /api/ssh/launch with port forwards (spec 2026-10-15)", () => {
+ const localRow = { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 };
+ const wideRow = { kind: "local", listenPort: 8080, bindHost: "10.0.0.5", targetHost: "api.internal", targetPort: 80 };
+
+ /** A node whose owner allowed wide binds; the flag is restored by the caller. */
+ async function wideOn(nodeId: string): Promise {
+ await nodes.setSshWideForwards(nodeId, { on: true });
+ }
+ async function wideOff(nodeId: string): Promise {
+ await nodes.setSshWideForwards(nodeId, { on: false });
+ }
+
+ function forwardingScript() {
+ return attachScriptedNode(NODE_ON, {
+ ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }),
+ launch: ok,
+ stat_dir: statDirEcho,
+ });
+ }
+
+ it("a malformed row list is a 400 before the machine is asked anything", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ // dynamic-with-target: a contradiction the grammar refuses whole.
+ const res = await launch(
+ { node: NODE_ON, destination: "work", forwards: [{ ...localRow, kind: "dynamic" }] },
+ ownerCookie,
+ );
+ expect(res.status).toBe(400);
+ expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.CONFIG_INVALID);
+ expect(scripted.cmdTypes()).toEqual([]);
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a duplicate listen end is refused (two rows, one bind: a bug, not a configuration)", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const res = await launch(
+ { node: NODE_ON, destination: "work", forwards: [localRow, { ...localRow, targetHost: "other.internal" }] },
+ ownerCookie,
+ );
+ expect(res.status).toBe(400);
+ expect(scripted.cmdTypes()).toEqual([]);
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a wide bind is refused before any resolve while the machine's setting is off", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [wideRow] }, ownerCookie);
+ expect(res.status).toBe(403);
+ expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_FORWARD_BIND_DENIED);
+ expect(scripted.cmdTypes()).toEqual([]);
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a loopback forward launches, renders into the exact-destination block, and reads in the audit", async () => {
+ const scripted = forwardingScript();
+ const beforeIds = new Set((await auditRows()).map((r) => r.id));
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow] }, 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 {
+ ssh?: { fileContent: string };
+ argv?: string[];
+ preset?: { flags?: string[] };
+ };
+ expect(cmd.ssh?.fileContent).toBe(renderSshConfigContents(ACCEPTED as never, undefined, [localRow] as never));
+ expect(cmd.ssh?.fileContent).toContain(" LocalForward 127.0.0.1:5432 db.internal:5432");
+ expect(cmd.ssh?.fileContent).not.toContain("ClearAllForwardings");
+ // The forward rows must NOT ride the option argv (the policy file would
+ // clear them there; the config block is the one honest channel).
+ const flags = cmd.preset?.flags ?? [];
+ expect(flags.join(" ")).not.toMatch(/LocalForward|-L /);
+
+ const added = (await auditRows()).filter((r) => !beforeIds.has(r.id));
+ expect(added).toHaveLength(1);
+ expect(added[0].metadata).toEqual({
+ nodeId: NODE_ON,
+ destination: CANON,
+ subshellId: subshell.id,
+ forwards: [{ kind: "local", listen: "127.0.0.1:5432", target: "db.internal:5432" }],
+ });
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a wide bind launches once the owner allowed it, and the row carries the written address", async () => {
+ const scripted = forwardingScript();
+ await wideOn(NODE_ON);
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow, wideRow] }, 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 { ssh?: { fileContent: string } };
+ expect(cmd.ssh?.fileContent).toContain(" LocalForward 10.0.0.5:8080 api.internal:80");
+ } finally {
+ await wideOff(NODE_ON);
+ scripted.detach();
+ }
+ });
+
+ it("forwards on a connection whose jump names the destination itself are refused after resolve", async () => {
+ const snap = snapshot({ proxyJumps: [{ host: "EXAMPLE.test", user: null, port: 22 }] });
+ const scripted = attachScriptedNode(NODE_ON, {
+ ssh_resolve_config: () => ({ accepted: true, snapshot: snap }),
+ launch: ok,
+ stat_dir: statDirEcho,
+ });
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow] }, ownerCookie);
+ expect(res.status).toBe(400);
+ expect(((await res.json()) as { message: string }).message).toContain("jump hop is the destination itself");
+ expect(scripted.countOf("launch")).toBe(0);
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a dynamic row launches with no target, and the audit says so", async () => {
+ const scripted = forwardingScript();
+ const beforeIds = new Set((await auditRows()).map((r) => r.id));
+ const dynamic = { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null };
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [dynamic] }, 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 { ssh?: { fileContent: string } };
+ expect(cmd.ssh?.fileContent).toContain(" DynamicForward 127.0.0.1:1080");
+ const added = (await auditRows()).filter((r) => !beforeIds.has(r.id));
+ expect(added[0].metadata).toMatchObject({
+ forwards: [{ kind: "dynamic", listen: "127.0.0.1:1080" }],
+ });
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a remote row with no bind host renders bare (D's policy spells it)", async () => {
+ const scripted = forwardingScript();
+ const remote = { kind: "remote", listenPort: 9090, bindHost: null, targetHost: "dev.local", targetPort: 3000 };
+ try {
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [remote] }, 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 { ssh?: { fileContent: string } };
+ expect(cmd.ssh?.fileContent).toContain(" RemoteForward 9090 dev.local:3000");
+ } finally {
+ scripted.detach();
+ }
+ });
+});
+
describe("/api/ssh/recent-destinations", () => {
it("GET lists only the caller's recent destinations", async () => {
// Owner A saves; owner B (a second human with their own node) must see
diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts
index becb58df8..9a90cd3f3 100644
--- a/apps/server/api/src/api/ssh/launch.route.ts
+++ b/apps/server/api/src/api/ssh/launch.route.ts
@@ -1,4 +1,9 @@
-import { normalizeLabel, SSH_ROSTER_MAX_IDENTITIES } from "@internal/subshell-protocol";
+import {
+ normalizeLabel,
+ SSH_MAX_FORWARD_ROWS,
+ SSH_NAME_MAX_CHARS,
+ SSH_ROSTER_MAX_IDENTITIES,
+} from "@internal/subshell-protocol";
import { Elysia, t } from "elysia";
import { authGuard, requireCookieActor } from "@/api/auth-guard.js";
import { SubshellSchema } from "@/api/models.js";
@@ -12,6 +17,45 @@ import { contextPlugin } from "@/plugins/context.plugin.js";
import { apiModels } from "@/schema/index.js";
import { sshLaunch } from "@/services/ssh-launch.service.js";
+/**
+ * One forward row's ENVELOPE. The shape lives here so the OpenAPI docs read
+ * honestly, and deliberately NOT the detailed grammar: `parseSshForwardRows`
+ * in the protocol package is the single authority (host forms, kind rules,
+ * duplicate listens), because the renderer re-runs that parser at the last
+ * station before bytes and a second grammar here would be two that drift.
+ */
+const SshForwardRowSchema = t.Object({
+ kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
+ description:
+ "local: bind a port on the connecting machine that reaches a host the destination can see; remote: ask the destination to bind a port that reaches a host the connecting machine can see; dynamic: a SOCKS proxy on the connecting machine",
+ }),
+ listenPort: t.Numeric({
+ minimum: 1,
+ maximum: 65535,
+ description: "Port to bind (always explicit; no auto ports). Integer-ness belongs to the protocol parser",
+ }),
+ bindHost: t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: SSH_NAME_MAX_CHARS,
+ description:
+ "Address to bind (hostname, IPv4, or bracketed IPv6). Null means loopback for local/dynamic, and the destination's own policy for remote",
+ }),
+ ),
+ targetHost: t.Optional(
+ t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: SSH_NAME_MAX_CHARS,
+ description: "Host to reach through the connection; required for local/remote, null for dynamic",
+ }),
+ ),
+ ),
+ targetPort: t.Optional(
+ t.Nullable(t.Numeric({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" })),
+ ),
+});
+
/**
* `POST /api/ssh/launch` `{node, destination, name?}` — open an interactive
* SSH pane to one destination (spec 2026-10-07 §5): gate (current launch access and readiness, before
@@ -47,6 +91,9 @@ export const sshLaunchRoute = new Elysia()
fingerprints: body.fingerprints,
authentication: body.authentication,
localKeyId: body.localKeyId,
+ // The protocol parser owns the detail (shape, kind rules, dupes);
+ // this handler only carries the envelope through.
+ forwards: body.forwards,
sessionId: session?.id,
subshells: ctx.services.subshells,
});
@@ -81,6 +128,13 @@ export const sshLaunchRoute = new Elysia()
"RELAY MODE: the key home machine whose agent signs for this connection; absent uses the connecting machine's own keys",
}),
),
+ forwards: t.Optional(
+ t.Array(SshForwardRowSchema, {
+ maxItems: SSH_MAX_FORWARD_ROWS,
+ description:
+ "User-declared port forwards this connection carries (spec 2026-10-15). They live and die with the pane; a non-loopback bind host needs the machine's wide-forwards setting on",
+ }),
+ ),
}),
response: {
201: t.Object({ subshell: SubshellSchema }),
diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts
index 772fb930f..212cd4302 100644
--- a/apps/server/api/src/db/migrate.ts
+++ b/apps/server/api/src/db/migrate.ts
@@ -53,6 +53,7 @@ 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";
import * as sshPresetsMigration from "@/db/migrations/0054-ssh-presets.js";
+import * as sshWideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js";
import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js";
/**
@@ -163,6 +164,7 @@ export async function runMigrations(): Promise {
"0052-subshell-key-home": subshellKeyHomeMigration,
"0053-drop-ssh-grants": dropSshGrantsMigration,
"0054-ssh-presets": sshPresetsMigration,
+ "0055-node-ssh-wide-forwards": sshWideForwardsMigration,
};
},
},
diff --git a/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts b/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts
new file mode 100644
index 000000000..145c5bf4c
--- /dev/null
+++ b/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts
@@ -0,0 +1,18 @@
+import type { Kysely } from "kysely";
+
+// Adds the per-node wide-bind allowance for SSH port forwards
+// (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md §6). Default
+// 0 keeps the whole fleet loopback-only after an upgrade: a forward that binds
+// a non-loopback address turns the machine into a router for whatever network
+// reaches that port, and only the machine's owner decides that is acceptable
+// there. Same default-0 doctrine as the SSH capability gate (0047).
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .alterTable("nodes")
+ .addColumn("ssh_wide_forwards", "integer", (col) => col.notNull().defaultTo(0))
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable("nodes").dropColumn("ssh_wide_forwards").execute();
+}
diff --git a/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts b/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts
new file mode 100644
index 000000000..d4e41bf7c
--- /dev/null
+++ b/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts
@@ -0,0 +1,72 @@
+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 * as wideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js";
+import { openSqliteDatabase } from "@/db/open-database.js";
+
+interface MigrationDatabase {
+ [table: string]: Record;
+ nodes: Record;
+}
+
+/**
+ * The wide-bind allowance column (spec 2026-10-15 §6). Default 0 is the
+ * upgrade story: every machine, the control-plane host included, stays
+ * loopback-only until its owner widens it.
+ */
+describe("migration 0055-node-ssh-wide-forwards", () => {
+ let dbFile: string;
+ let db: Kysely;
+
+ beforeAll(async () => {
+ dbFile = `/tmp/subshell-0055-${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);
+ await sshEnabledMigration.up(db);
+ // Written BEFORE the column exists - the upgrade case this test is about.
+ await db
+ .insertInto("nodes")
+ .values({
+ id: "pre-existing",
+ owner_user_id: "u1",
+ name: "pre-existing",
+ kind: "agent",
+ created_at: "2026-10-15T00:00:00.000Z",
+ updated_at: "2026-10-15T00:00:00.000Z",
+ })
+ .execute();
+ await wideForwardsMigration.up(db);
+ });
+
+ afterAll(async () => {
+ await db.destroy();
+ });
+
+ it("adds the column", 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_wide_forwards");
+ });
+
+ it("rows written before the migration read 0 (loopback stays the floor after an upgrade)", async () => {
+ const row = await db
+ .selectFrom("nodes")
+ .select("ssh_wide_forwards")
+ .where("id", "=", "pre-existing")
+ .executeTakeFirst();
+ expect(row?.ssh_wide_forwards).toBe(0);
+ });
+
+ it("down removes the column", async () => {
+ await wideForwardsMigration.down(db);
+ const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db);
+ expect(cols.rows.map((c) => c.name)).not.toContain("ssh_wide_forwards");
+ });
+});
diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts
index 3fd95ca53..566df61a6 100644
--- a/apps/server/api/src/db/repositories/nodes.repository.ts
+++ b/apps/server/api/src/db/repositories/nodes.repository.ts
@@ -60,6 +60,7 @@ export class NodesRepository extends BaseRepository {
// and the gate is opt-in per machine (spec 4.3); mirror it the same way.
sshEnabled: input.sshEnabled ?? 0,
sshEnabledAt: input.sshEnabledAt ?? null,
+ sshWideForwards: 0,
createdAt: input.createdAt ?? now,
updatedAt: now,
})
@@ -275,6 +276,30 @@ export class NodesRepository extends BaseRepository {
.executeTakeFirst();
}
+ /**
+ * Write the SSH wide-forwards allowance (spec 2026-10-15 §6). No stamp
+ * column: unlike {@link setSshEnabled} there is no node-side mirror to
+ * reconcile - the flag is read plane-side at launch, when the rendered
+ * config is composed, and nothing travels to the machine.
+ */
+ async setSshWideForwards(
+ id: string,
+ state: {
+ /** True = forwards on this machine may bind non-loopback addresses. */
+ on: boolean;
+ },
+ ): Promise {
+ return await this.db
+ .updateTable("nodes")
+ .set({
+ sshWideForwards: state.on ? 1 : 0,
+ 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/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts
index 74400a0a8..c45aa5f03 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;
+ /**
+ * 1 = this machine's owner allows SSH port forwards to bind NON-loopback
+ * addresses (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
+ * §6). Default 0: forwards bind loopback only. A wide bind turns the machine
+ * into a router for whatever network reaches that port, so the decision is
+ * per machine and belongs to the owner, never to a connection's author.
+ */
+ sshWideForwards: number;
/**
* 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
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 a8cb3413a..e650f61e8 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
@@ -174,6 +174,7 @@ function nodeRow(over: Partial = {}): NodeTable {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: 0,
+ sshWideForwards: 0,
sshEnabledAt: null,
// The 0049 mirror slot: NULL until the runtime-report task fills it.
sshFingerprint: null,
diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts
index d461513a3..69afbca9e 100644
--- a/apps/server/api/src/services/nodes/ssh-enabled.ts
+++ b/apps/server/api/src/services/nodes/ssh-enabled.ts
@@ -48,6 +48,32 @@ export async function setNodeSshEnabled(write: SshEnabledWrite): Promise {
pushSetSshEnabledBestEffort(write.nodeId, { on: write.on, changedAt: write.changedAt });
}
+/**
+ * Flip one node's wide-bind allowance for SSH port forwards (spec 2026-10-15
+ * §6): row and audit in one function, same one-change-site posture as
+ * {@link setNodeSshEnabled}. The differences are all in what does NOT happen:
+ * no stamp, no push, no mirror, and no relay cut - the flag is read
+ * plane-side when a launch composes its config, so turning it off refuses
+ * every FUTURE wide bind while already-running forwards ride the connection
+ * that was approved when they started (the same honesty the SSH switch
+ * carries about already-authenticated sessions).
+ */
+export async function setNodeSshWideForwards(write: {
+ nodeId: string;
+ on: boolean;
+ actorUserId: string;
+}): Promise {
+ const { repos } = getRequestlessContext();
+ await repos.nodes.setSshWideForwards(write.nodeId, { on: write.on });
+ await audit({
+ actorUserId: write.actorUserId,
+ action: "node.ssh_wide_forwards.update",
+ targetType: "node",
+ targetId: write.nodeId,
+ metadataJson: JSON.stringify({ on: write.on }),
+ });
+}
+
/** The node-row fields {@link decideSshEnabled} reads — nothing else is relevant. */
type SshRow = Pick;
diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts
index 01a2f591e..cb26b4835 100644
--- a/apps/server/api/src/services/ssh-launch.service.ts
+++ b/apps/server/api/src/services/ssh-launch.service.ts
@@ -14,11 +14,13 @@ import {
sshOptionTokens,
} from "@internal/pane-runtime";
import {
+ isLoopbackForwardBind,
type NodeSshAgentIdentity,
type NodeSshAliasListResult,
type NodeSshResolveOutcomeWire,
parseNodeSshAliasList,
parseSshConnectionSnapshot,
+ parseSshForwardRows,
SSH_NAME_MAX_CHARS,
type SshConnectionSnapshotWire,
type SshForwardWire,
@@ -410,6 +412,13 @@ export async function sshLaunch(args: {
authentication?: "password";
/** One-use preparation owned by this exact cookie session. */
localKeyId?: string;
+ /**
+ * User-declared port forwards (spec 2026-10-15). `unknown` ON PURPOSE: the
+ * route's t-schema is an envelope, and `parseSshForwardRows` here is the
+ * authority - the same parser the renderer re-runs at the last station, so
+ * what composes can never be wider than what the grammar can express.
+ */
+ forwards?: unknown;
sessionId?: string;
/** The REQUEST-scoped subshells service (the create path is its own). */
subshells: Pick;
@@ -431,6 +440,27 @@ export async function sshLaunch(args: {
if (!isWireSafeSshDestination(args.destination)) {
return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY);
}
+ // Port forwards (spec 2026-10-15): parse first (the grammar owns shape),
+ // then the machine's wide-bind gate (the owner's setting owns policy). Both
+ // BEFORE any resolve, so a refused list never dials a machine.
+ let forwards: SshForwardWire[] = [];
+ if (args.forwards !== undefined) {
+ const parsed = parseSshForwardRows(args.forwards);
+ if (parsed === null)
+ return codedRefusal(
+ 400,
+ BackendErrorCodes.CONFIG_INVALID,
+ "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port.",
+ );
+ forwards = parsed;
+ }
+ if (forwards.length > 0 && forwards.some((f) => !isLoopbackForwardBind(f)) && row.sshWideForwards !== 1) {
+ return codedRefusal(
+ 403,
+ BackendErrorCodes.SSH_FORWARD_BIND_DENIED,
+ "This machine only allows port forwards that bind its own loopback address. Change the bind address, or its owner can allow wide forwards in the machine's SSH settings.",
+ );
+ }
if (args.fingerprints !== undefined && args.keyHomeNodeId === undefined) {
return codedRefusal(
400,
@@ -454,6 +484,16 @@ export async function sshLaunch(args: {
const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it
const destinationProblem = sshExpectedDestinationProblem(snapshot, args.expectedDestination);
if (destinationProblem) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, destinationProblem);
+ if (forwards.length > 0 && snapshot.proxyJumps.some((h) => h.host.toLowerCase() === snapshot.host.toLowerCase())) {
+ // The forward block renders under the exact destination host; a hop named
+ // the same would match the child too and hand it a forward (the renderer
+ // throws on this as the guard beneath; the human hears it here first).
+ return codedRefusal(
+ 400,
+ BackendErrorCodes.CONFIG_INVALID,
+ "Port forwards can't ride a connection whose jump hop is the destination itself. Remove the jump or the forwards.",
+ );
+ }
if (args.keyHomeNodeId !== undefined) {
const refusal = relaySnapshotRefusal(snapshot);
if (refusal) return refusal;
@@ -510,6 +550,10 @@ export async function sshLaunch(args: {
// signed relay-open just wrote beside it; a direct launch renders the M1
// accept-new bytes untouched.
...(relay || localKey ? { hostPinPath: buildSshKnownHostsPath(dataDir, subshellId) } : {}),
+ // Approved rows (spec 2026-10-15): the compose re-validates and renders
+ // them into the exact-destination block; rowless launches keep today's
+ // bytes, ClearAllForwardings included.
+ ...(forwards.length > 0 ? { forwards } : {}),
});
// 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,
@@ -581,11 +625,32 @@ export async function sshLaunch(args: {
targetType: "node",
targetId: row.id,
// Only session ids and destination metadata enter the success audit.
- metadataJson: JSON.stringify(
- relay
- ? { nodeId: row.id, destination, subshellId: created.id, relayRef: relay.ref }
- : { nodeId: row.id, destination, subshellId: created.id },
- ),
+ // The forward summary (spec 2026-10-15 §8) is the same class of fact:
+ // canonical names and ports the user approved, no config text and no key
+ // material, bounded by the row cap.
+ metadataJson: JSON.stringify({
+ nodeId: row.id,
+ destination,
+ subshellId: created.id,
+ ...(relay ? { relayRef: relay.ref } : {}),
+ ...(forwards.length > 0
+ ? {
+ forwards: forwards.map((f) => ({
+ kind: f.kind,
+ // What the user wrote; "127.0.0.1:port" when they wrote loopback
+ // implicitly, and a bare port for a remote row, whose bind is
+ // the destination's policy to spell.
+ listen:
+ f.bindHost === null
+ ? f.kind === "remote"
+ ? String(f.listenPort)
+ : `127.0.0.1:${f.listenPort}`
+ : `${f.bindHost}:${f.listenPort}`,
+ ...(f.kind === "dynamic" ? {} : { target: `${String(f.targetHost)}:${String(f.targetPort)}` }),
+ })),
+ }
+ : {}),
+ }),
});
return ok({ subshellId: created.id });
}
diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts
index 795defe0c..956582153 100644
--- a/packages/backend-errors/src/error-codes.ts
+++ b/packages/backend-errors/src/error-codes.ts
@@ -289,6 +289,14 @@ export enum BackendErrorCodes {
* nowhere in it, and the interactive pane is untouched.
*/
SSH_UPGRADE_FAILED = "SSH_UPGRADE_FAILED",
+ /**
+ * A launch asked for a port forward that binds a non-loopback address on a
+ * machine whose owner has not allowed wide binds (spec 2026-10-15 §6): the
+ * forward would turn the machine into a router for whatever network reaches
+ * that port. Refused before anything composes; the remedy the message names
+ * is the machine's SSH setting, or a loopback bind.
+ */
+ SSH_FORWARD_BIND_DENIED = "SSH_FORWARD_BIND_DENIED",
}
/**
@@ -591,4 +599,8 @@ export const BackendErrorCodeDefs = {
message: "Subshell setup didn't finish",
statusCode: 409,
},
+ [BackendErrorCodes.SSH_FORWARD_BIND_DENIED]: {
+ message: "This machine only allows port forwards that bind its own loopback address",
+ statusCode: 403,
+ },
};
From 65a13ad5fc83e2b86cc33ceff36b404be9d0befb Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 15:16:21 -0700
Subject: [PATCH 05/35] feat(ssh): SSH presets store declared port forwards,
validated at save
---
apps/server/api/src/api/ssh-presets.ts | 22 +++-
.../src/api/ssh/__tests__/ssh-routes.test.ts | 105 ++++++++++++++++++
apps/server/api/src/api/ssh/launch.route.ts | 47 +-------
apps/server/api/src/api/ssh/ssh-views.ts | 69 ++++++++++++
apps/server/api/src/db/migrate.ts | 2 +
.../db/migrations/0056-ssh-preset-forwards.ts | 16 +++
.../0056-ssh-preset-forwards.test.ts | 47 ++++++++
.../api/src/db/types/ssh-presets.db-types.ts | 8 ++
.../api/src/services/ssh-presets.service.ts | 19 ++++
9 files changed, 288 insertions(+), 47 deletions(-)
create mode 100644 apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts
create mode 100644 apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts
diff --git a/apps/server/api/src/api/ssh-presets.ts b/apps/server/api/src/api/ssh-presets.ts
index 946f4e78c..97df223cc 100644
--- a/apps/server/api/src/api/ssh-presets.ts
+++ b/apps/server/api/src/api/ssh-presets.ts
@@ -1,6 +1,11 @@
-import { SSH_FINGERPRINT_PATTERN, SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol";
+import type { SshForwardWire } from "@internal/subshell-protocol";
+import {
+ SSH_FINGERPRINT_PATTERN,
+ SSH_MAX_FORWARD_ROWS,
+ SSH_MAX_SELECTED_FINGERPRINTS,
+} from "@internal/subshell-protocol";
import { t } from "elysia";
-import { SshExpectedDestinationField } from "@/api/ssh/ssh-views.js";
+import { SshExpectedDestinationField, SshForwardRowInputSchema } from "@/api/ssh/ssh-views.js";
import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js";
const SshPresetFields = {
@@ -49,6 +54,15 @@ const SshPresetFields = {
}),
),
),
+ forwards: t.Optional(
+ t.Nullable(
+ t.Array(SshForwardRowInputSchema, {
+ maxItems: SSH_MAX_FORWARD_ROWS,
+ description:
+ "Port forwards this preset carries to every launch; validated by the protocol grammar at save, and a non-loopback bind is still refused at launch until the connecting machine allows it. Omitted on update keeps the stored list, null clears it",
+ }),
+ ),
+ ),
};
export const CreateSshPresetSchema = t.Object(
{
@@ -77,6 +91,9 @@ export const SshPresetSchema = t.Object({
fingerprints: t.Array(t.String({ description: "Selected public key fingerprint" }), {
description: "Remote key selection, empty for other authentication modes",
}),
+ forwards: t.Array(SshForwardRowInputSchema, {
+ description: "Declared port forwards, empty when the preset stores none",
+ }),
createdAt: t.String({ description: "Creation timestamp" }),
updatedAt: t.String({ description: "Last update timestamp" }),
});
@@ -86,5 +103,6 @@ export function sshPresetView(row: SshPresetTable) {
...row,
kind: "ssh" as const,
fingerprints: row.fingerprints === null ? [] : (JSON.parse(row.fingerprints) as string[]),
+ forwards: row.forwards === null ? [] : (JSON.parse(row.forwards) as SshForwardWire[]),
};
}
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 738636c67..3c60be0e7 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
@@ -709,6 +709,111 @@ describe("POST /api/ssh/launch with port forwards (spec 2026-10-15)", () => {
});
});
+describe("SSH preset forwards (spec 2026-10-15 §7)", () => {
+ const fwd = { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 };
+ const dyn = { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null };
+ const presetBody = (name: string, extra: Record = {}) => ({
+ kind: "ssh",
+ name,
+ nodeId: NODE_ON,
+ destination: "work",
+ authentication: "connecting",
+ fingerprints: [],
+ ...extra,
+ });
+
+ it("a saved list round-trips through the view and the row", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const created = await sshFetch("/api/presets", {
+ method: "POST",
+ cookie: ownerCookie,
+ body: presetBody("Forward preset", { forwards: [fwd, dyn] }),
+ });
+ expect(created.status).toBe(200);
+ const view = (await created.json()) as { id: string; forwards: unknown[] };
+ expect(view.forwards).toEqual([fwd, dyn]);
+ const row = await db.selectFrom("sshPresets").select("forwards").where("id", "=", view.id).executeTakeFirst();
+ expect(JSON.parse(String(row?.forwards))).toEqual([fwd, dyn]);
+ await sshFetch(`/api/presets/${view.id}`, { method: "DELETE", cookie: ownerCookie });
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("omission keeps the stored list, null clears it, an empty list clears it", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const created = await sshFetch("/api/presets", {
+ method: "POST",
+ cookie: ownerCookie,
+ body: presetBody("Forward keeps", { forwards: [fwd] }),
+ });
+ const { id } = (await created.json()) as { id: string };
+ const put = (extra: Record) =>
+ sshFetch(`/api/presets/${id}`, {
+ method: "PUT",
+ cookie: ownerCookie,
+ body: {
+ name: "Forward keeps",
+ nodeId: NODE_ON,
+ destination: CANON,
+ authentication: "connecting",
+ fingerprints: [],
+ ...extra,
+ },
+ });
+ const kept = await put({});
+ expect(kept.status).toBe(200);
+ expect(((await kept.json()) as { forwards: unknown[] }).forwards).toEqual([fwd]);
+ const cleared = await put({ forwards: null });
+ expect(((await cleared.json()) as { forwards: unknown[] }).forwards).toEqual([]);
+ const row = await db.selectFrom("sshPresets").select("forwards").where("id", "=", id).executeTakeFirst();
+ expect(row?.forwards).toBeNull();
+ await sshFetch(`/api/presets/${id}`, { method: "DELETE", cookie: ownerCookie });
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a malformed list is refused at save, before anything writes", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const res = await sshFetch("/api/presets", {
+ method: "POST",
+ cookie: ownerCookie,
+ body: presetBody("Bad forwards", { forwards: [{ kind: "tunnel", listenPort: 1, bindHost: null }] }),
+ });
+ expect(res.status).toBe(400);
+ const rows = await db.selectFrom("sshPresets").select("id").where("name", "=", "Bad forwards").execute();
+ expect(rows).toHaveLength(0);
+ } finally {
+ scripted.detach();
+ }
+ });
+
+ it("a wide-bind row SAVES fine and refuses at launch until the machine allows it", async () => {
+ const scripted = silentScript(NODE_ON);
+ try {
+ const wide = { kind: "remote", listenPort: 9090, bindHost: "0.0.0.0", targetHost: "dev.local", targetPort: 3000 };
+ const created = await sshFetch("/api/presets", {
+ method: "POST",
+ cookie: ownerCookie,
+ body: presetBody("Wide preset", { forwards: [wide] }),
+ });
+ expect(created.status).toBe(200);
+ const { id } = (await created.json()) as { id: string };
+ // The save stores the wish; the launch enforces the machine's answer.
+ const res = await launch({ node: NODE_ON, destination: "work", forwards: [wide] }, ownerCookie);
+ expect(res.status).toBe(403);
+ expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_FORWARD_BIND_DENIED);
+ await sshFetch(`/api/presets/${id}`, { method: "DELETE", cookie: ownerCookie });
+ } finally {
+ scripted.detach();
+ }
+ });
+});
+
describe("/api/ssh/recent-destinations", () => {
it("GET lists only the caller's recent destinations", async () => {
// Owner A saves; owner B (a second human with their own node) must see
diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts
index 9a90cd3f3..05bf8b348 100644
--- a/apps/server/api/src/api/ssh/launch.route.ts
+++ b/apps/server/api/src/api/ssh/launch.route.ts
@@ -1,15 +1,11 @@
-import {
- normalizeLabel,
- SSH_MAX_FORWARD_ROWS,
- SSH_NAME_MAX_CHARS,
- SSH_ROSTER_MAX_IDENTITIES,
-} from "@internal/subshell-protocol";
+import { normalizeLabel, SSH_MAX_FORWARD_ROWS, SSH_ROSTER_MAX_IDENTITIES } 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,
SshExpectedDestinationField,
+ SshForwardRowSchema,
SshResolveRefusalViewSchema,
throwCodedRefusal,
} from "@/api/ssh/ssh-views.js";
@@ -17,45 +13,6 @@ import { contextPlugin } from "@/plugins/context.plugin.js";
import { apiModels } from "@/schema/index.js";
import { sshLaunch } from "@/services/ssh-launch.service.js";
-/**
- * One forward row's ENVELOPE. The shape lives here so the OpenAPI docs read
- * honestly, and deliberately NOT the detailed grammar: `parseSshForwardRows`
- * in the protocol package is the single authority (host forms, kind rules,
- * duplicate listens), because the renderer re-runs that parser at the last
- * station before bytes and a second grammar here would be two that drift.
- */
-const SshForwardRowSchema = t.Object({
- kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
- description:
- "local: bind a port on the connecting machine that reaches a host the destination can see; remote: ask the destination to bind a port that reaches a host the connecting machine can see; dynamic: a SOCKS proxy on the connecting machine",
- }),
- listenPort: t.Numeric({
- minimum: 1,
- maximum: 65535,
- description: "Port to bind (always explicit; no auto ports). Integer-ness belongs to the protocol parser",
- }),
- bindHost: t.Nullable(
- t.String({
- minLength: 1,
- maxLength: SSH_NAME_MAX_CHARS,
- description:
- "Address to bind (hostname, IPv4, or bracketed IPv6). Null means loopback for local/dynamic, and the destination's own policy for remote",
- }),
- ),
- targetHost: t.Optional(
- t.Nullable(
- t.String({
- minLength: 1,
- maxLength: SSH_NAME_MAX_CHARS,
- description: "Host to reach through the connection; required for local/remote, null for dynamic",
- }),
- ),
- ),
- targetPort: t.Optional(
- t.Nullable(t.Numeric({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" })),
- ),
-});
-
/**
* `POST /api/ssh/launch` `{node, destination, name?}` — open an interactive
* SSH pane to one destination (spec 2026-10-07 §5): gate (current launch access and readiness, before
diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts
index d02f7db2c..bb0b504ce 100644
--- a/apps/server/api/src/api/ssh/ssh-views.ts
+++ b/apps/server/api/src/api/ssh/ssh-views.ts
@@ -10,6 +10,75 @@ import type { SshRefusal } from "@/services/ssh-launch.service.js";
* drift.
*/
+/**
+ * One user-declared port-forward row's ENVELOPE (spec 2026-10-15), shared by
+ * the launch body and the preset save because they carry the same thing.
+ * Deliberately NOT the detailed grammar: `parseSshForwardRows` in the protocol
+ * package is the single authority (host forms, kind rules, duplicate listens),
+ * and the renderer re-runs that parser at the last station before bytes - a
+ * second grammar in a schema here would be two that drift.
+ */
+export const SshForwardRowSchema = t.Object({
+ kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
+ description:
+ "local: bind a port on the connecting machine that reaches a host the destination can see; remote: ask the destination to bind a port that reaches a host the connecting machine can see; dynamic: a SOCKS proxy on the connecting machine",
+ }),
+ listenPort: t.Number({
+ minimum: 1,
+ maximum: 65535,
+ description: "Port to bind (always explicit; no auto ports). Integer-ness belongs to the protocol parser",
+ }),
+ bindHost: t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: 253,
+ description:
+ "Address to bind (hostname, IPv4, or bracketed IPv6). Null means loopback for local/dynamic, and the destination's own policy for remote",
+ }),
+ ),
+ targetHost: t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: 253,
+ description: "Host to reach through the connection; required for local/remote, null for dynamic",
+ }),
+ ),
+ targetPort: t.Nullable(
+ t.Number({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" }),
+ ),
+});
+
+/**
+ * The same forward row as the preset routes carry it, deliberately FLATTER
+ * than {@link SshForwardRowSchema}: nested literal unions inside the
+ * agent-or-ssh body/response union push the `presets.route.ts` handlers past
+ * Elysia's inference depth (measured the day `forwards` arrived; the launch
+ * route, which never unions with agent bodies, keeps the expressive kind
+ * census). The protocol parser enforces the kind set either way, so the
+ * pattern here is documentation, not the enforcement.
+ */
+export const SshForwardRowInputSchema = t.Object({
+ kind: t.String({ pattern: "^(local|remote|dynamic)$", description: "local (-L), remote (-R), or dynamic (-D)" }),
+ listenPort: t.Number({ minimum: 1, maximum: 65535, description: "Port to bind (always explicit; no auto ports)" }),
+ bindHost: t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: 253,
+ description: "Address to bind; null means loopback for local/dynamic, the destination's policy for remote",
+ }),
+ ),
+ targetHost: t.Nullable(
+ t.String({
+ minLength: 1,
+ maxLength: 253,
+ description: "Host to reach through the connection; required for local/remote, null for dynamic",
+ }),
+ ),
+ targetPort: t.Nullable(
+ t.Number({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" }),
+ ),
+});
+
/** 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)" }),
diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts
index 212cd4302..a5d4ad7e0 100644
--- a/apps/server/api/src/db/migrate.ts
+++ b/apps/server/api/src/db/migrate.ts
@@ -54,6 +54,7 @@ import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-reques
import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js";
import * as sshPresetsMigration from "@/db/migrations/0054-ssh-presets.js";
import * as sshWideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js";
+import * as sshPresetForwardsMigration from "@/db/migrations/0056-ssh-preset-forwards.js";
import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js";
/**
@@ -165,6 +166,7 @@ export async function runMigrations(): Promise {
"0053-drop-ssh-grants": dropSshGrantsMigration,
"0054-ssh-presets": sshPresetsMigration,
"0055-node-ssh-wide-forwards": sshWideForwardsMigration,
+ "0056-ssh-preset-forwards": sshPresetForwardsMigration,
};
},
},
diff --git a/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts b/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts
new file mode 100644
index 000000000..c80d36ab5
--- /dev/null
+++ b/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts
@@ -0,0 +1,16 @@
+import type { Kysely } from "kysely";
+
+// SSH presets carry their owner's declared port forwards (spec 2026-10-15 §7):
+// a JSON array of rows the protocol grammar validated AT SAVE, or NULL for
+// "no forwards" (the pre-column spelling and the clear-the-list spelling
+// agree). The rows are connection choices, not credentials, so they follow
+// the same storage rules as destination and authentication - and a wide-bind
+// row remains a WIDE refusal at launch until the connecting machine allows
+// it, exactly as it would for a typed list.
+export async function up(db: Kysely): Promise {
+ await db.schema.alterTable("ssh_presets").addColumn("forwards", "text").execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable("ssh_presets").dropColumn("forwards").execute();
+}
diff --git a/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts b/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts
new file mode 100644
index 000000000..2b22eb6ed
--- /dev/null
+++ b/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts
@@ -0,0 +1,47 @@
+import { expect, test } from "bun:test";
+import { Kysely, sql } from "kysely";
+import { BunSqliteDialect } from "kysely-bun-sqlite-dialect";
+import * as presetsMigration from "@/db/migrations/0054-ssh-presets.js";
+import * as presetForwardsMigration from "@/db/migrations/0056-ssh-preset-forwards.js";
+import { openSqliteDatabase } from "@/db/open-database.js";
+
+/**
+ * The preset forwards column (spec 2026-10-15 §7): nullable JSON, so every
+ * preset written before the migration simply has none, and NULL is the same
+ * reading as the explicit clear.
+ */
+test("SSH preset forwards column arrives nullable and leaves cleanly", async () => {
+ const db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(":memory:") }) });
+ try {
+ await sql`CREATE TABLE user (id TEXT PRIMARY KEY)`.execute(db);
+ await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db);
+ await sql`CREATE TABLE presets (id TEXT PRIMARY KEY, name TEXT)`.execute(db);
+ await sql`CREATE TABLE ssh_saved_hosts (id TEXT PRIMARY KEY, owner_user_id TEXT, destination TEXT, alias TEXT, node_id TEXT, saved_at TEXT, last_connect_at TEXT)`.execute(
+ db,
+ );
+ await sql`INSERT INTO user VALUES ('u')`.execute(db);
+ await sql`INSERT INTO nodes VALUES ('n')`.execute(db);
+ await presetsMigration.up(db);
+ // A preset written BEFORE 0056, the upgrade case.
+ await sql`INSERT INTO ssh_presets VALUES ('p', 'u', 'Work', NULL, 'n', 'host:22', NULL, NULL, 'connecting', NULL, NULL, 'now', 'now')`.execute(
+ db,
+ );
+
+ await presetForwardsMigration.up(db);
+ const cols = await sql<{
+ name: string;
+ notnull: number;
+ }>`SELECT name, "notnull" FROM pragma_table_info('ssh_presets')`.execute(db);
+ const fwd = cols.rows.find((c) => c.name === "forwards");
+ expect(fwd).toBeDefined();
+ expect(fwd?.notnull).toBe(0);
+ const row = await sql<{ forwards: string | null }>`SELECT forwards FROM ssh_presets WHERE id='p'`.execute(db);
+ expect(row.rows[0]?.forwards).toBeNull();
+
+ await presetForwardsMigration.down(db);
+ const after = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_presets')`.execute(db);
+ expect(after.rows.map((c) => c.name)).not.toContain("forwards");
+ } finally {
+ await db.destroy();
+ }
+});
diff --git a/apps/server/api/src/db/types/ssh-presets.db-types.ts b/apps/server/api/src/db/types/ssh-presets.db-types.ts
index bbaf43cae..41bd45bd7 100644
--- a/apps/server/api/src/db/types/ssh-presets.db-types.ts
+++ b/apps/server/api/src/db/types/ssh-presets.db-types.ts
@@ -33,6 +33,14 @@ export interface SshPresetTable {
keyHome: string | null;
/** JSON-encoded array of selected `SHA256:` public-key fingerprints (remote only); null otherwise */
fingerprints: string | null;
+ /**
+ * JSON-encoded array of user-declared port-forward rows (spec 2026-10-15
+ * §7), validated by the protocol grammar at save; null means no forwards.
+ * Connection choices, not credentials - the same class as destination and
+ * authentication, and a wide-bind row stored here is still refused at
+ * launch until the connecting machine allows it.
+ */
+ forwards: string | null;
/** ISO 8601 creation timestamp (DB default) */
createdAt: string;
/** ISO 8601 last-update timestamp */
diff --git a/apps/server/api/src/services/ssh-presets.service.ts b/apps/server/api/src/services/ssh-presets.service.ts
index 2de0f2db9..a6ad89185 100644
--- a/apps/server/api/src/services/ssh-presets.service.ts
+++ b/apps/server/api/src/services/ssh-presets.service.ts
@@ -1,3 +1,4 @@
+import { parseSshForwardRows } from "@internal/subshell-protocol";
import type { Static } from "elysia";
import { loadNodeGate } from "@/api/nodes/node-gate.js";
import { throwCodedRefusal } from "@/api/ssh/ssh-views.js";
@@ -49,6 +50,21 @@ export async function saveSshPreset(
throw new SshPresetError("bad_request", "Choose a different node to provide the SSH key");
await validateNodeAccess(userId, body.keyHome);
}
+ // Declared port forwards (spec 2026-10-15 §7): saved through the protocol's
+ // own parser, so no row the launch renderer would refuse can ride a stored
+ // preset. Whether a WIDE bind is allowed is a connect-time decision, not a
+ // save-time one: a preset stores a wish, and the launch route checks the
+ // connecting machine's flag every time it is honored.
+ let forwardsJson: string | null = null;
+ if (body.forwards !== undefined && body.forwards !== null) {
+ const parsed = parseSshForwardRows(body.forwards);
+ if (parsed === null)
+ throw new SshPresetError(
+ "bad_request",
+ "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port.",
+ );
+ if (parsed.length > 0) forwardsJson = JSON.stringify(parsed);
+ }
let destination = body.destination;
// PUT omits a field it is not deciding about, so an absent alias keeps the
// stored one (null clears it), the same rule the agent PUT body follows.
@@ -105,6 +121,9 @@ export async function saveSshPreset(
keyHome: body.authentication === "remote" ? (body.keyHome ?? null) : null,
fingerprints:
body.authentication === "remote" && body.fingerprints != null ? JSON.stringify(body.fingerprints) : null,
+ // PUT convention, same as `description`: omitted keeps what is stored,
+ // null (or an emptied list) clears it.
+ forwards: body.forwards === undefined ? (existing?.forwards ?? null) : forwardsJson,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
From 63f49a46a056c89ac8abdc98061b03ca33c3ffe1 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 15:42:21 -0700
Subject: [PATCH 06/35] feat(ssh): port-forward rows in the launch form,
presets, and the node SSH card
---
.../__tests__/new-subshell-form.test.tsx | 1 +
.../components/__tests__/node-row.test.tsx | 1 +
.../__tests__/subshell-card.test.tsx | 1 +
.../connect/__tests__/connect-panel.test.tsx | 48 +++++
.../src/components/connect/connect-panel.tsx | 20 ++-
.../components/connect/ssh-forward-fields.tsx | 170 ++++++++++++++++++
.../web/src/components/connect/ssh-refusal.ts | 10 ++
.../components/connect/ssh-session-draft.ts | 6 +
.../web/src/components/connect/ssh-wizard.tsx | 10 ++
.../__tests__/node-harness-card.test.tsx | 1 +
.../nodes/__tests__/node-list-row.test.tsx | 1 +
.../nodes/__tests__/node-section-nav.test.tsx | 1 +
.../nodes/__tests__/node-ssh-card.test.tsx | 26 ++-
.../nodes/__tests__/node-table.test.tsx | 1 +
.../nodes/__tests__/node-update-card.test.tsx | 1 +
.../src/components/nodes/node-ssh-card.tsx | 29 ++-
.../__tests__/ssh-preset-editor.test.tsx | 27 +++
.../ssh/__tests__/trust-section.test.tsx | 1 +
.../__tests__/new-subshell-form.test.tsx | 1 +
.../__tests__/no-launch-targets.test.tsx | 1 +
.../__tests__/ssh-launch-integration.test.tsx | 2 +
.../__tests__/use-node-harnesses.test.tsx | 1 +
.../src/hooks/__tests__/use-nodes.test.tsx | 1 +
apps/server/web/src/hooks/use-ssh.ts | 19 ++
.../src/lib/__tests__/ssh-forwards.test.ts | 93 ++++++++++
.../web/src/lib/__tests__/ssh-preset.test.ts | 2 +
.../src/lib/__tests__/subshell-compat.test.ts | 1 +
apps/server/web/src/lib/ssh-forwards.ts | 110 ++++++++++++
apps/server/web/src/lib/ssh-preset.ts | 3 +
apps/server/web/src/lib/ssh.ts | 22 +++
.../routes/__tests__/nodes-detail.test.tsx | 1 +
.../__tests__/nodes-page-filters.test.tsx | 2 +
.../routes/__tests__/nodes-page-gate.test.tsx | 1 +
.../src/routes/__tests__/nodes-tabs.test.tsx | 1 +
apps/server/web/src/types/preset.ts | 14 +-
.../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 +
.../src/__tests__/ssh-trust-card.test.tsx | 1 +
packages/node-admin/src/types/node.ts | 8 +
41 files changed, 638 insertions(+), 5 deletions(-)
create mode 100644 apps/server/web/src/components/connect/ssh-forward-fields.tsx
create mode 100644 apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
create mode 100644 apps/server/web/src/lib/ssh-forwards.ts
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 65c4b1c9a..8c6a6004b 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
@@ -56,6 +56,7 @@ function node(overrides: Partial): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 f30afc40d..f173c5928 100644
--- a/apps/server/web/src/components/__tests__/node-row.test.tsx
+++ b/apps/server/web/src/components/__tests__/node-row.test.tsx
@@ -30,6 +30,7 @@ const BASE: Node = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
};
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 15e7da1fc..2db9036cd 100644
--- a/apps/server/web/src/components/__tests__/subshell-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/subshell-card.test.tsx
@@ -47,6 +47,7 @@ function agent(overrides: Partial = {}): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
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 b595db3b1..a5d73403c 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
@@ -50,6 +50,7 @@ function node(overrides: Partial): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
@@ -763,3 +764,50 @@ it("a delayed URL preset cannot overwrite an edited draft during or after a pend
restore();
}
});
+
+it("carries declared port forwards in the launch body, and holds Start until the row is valid (spec 2026-10-15)", async () => {
+ const { calls, restore } = await renderPanel({
+ nodes: [HOST_A],
+ launch: () => json({ subshell: { id: "pane-fwd" } }, 201),
+ });
+ try {
+ fireEvent.change(destinationInput(), { target: { value: "build.example" } });
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ // Loopback-only machine: no bind field offered, and the reason is on screen.
+ expect(screen.queryByLabelText(/Bind address/)).toBeNull();
+ expect(screen.getByText(/loopback/i)).toBeTruthy();
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
+ await settle();
+ // Half a row: the listen port alone is not a launchable forward.
+ expect((screen.getByRole("button", { name: /^Start SSH subshell$/ }) as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
+ await settle();
+ await clickConnect();
+ await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true));
+ expect(calls.find((call) => call.url === "/api/ssh/launch")?.body).toMatchObject({
+ node: "a1",
+ destination: "build.example",
+ forwards: [{ kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }],
+ });
+ } finally {
+ restore();
+ }
+});
+
+it("offers the bind address on a machine whose owner allows wide binds", async () => {
+ const HOST_WIDE = node({ id: "w1", name: "router box", sshEnabled: true, sshWideForwards: true });
+ const { restore } = await renderPanel({ nodes: [HOST_WIDE] });
+ try {
+ fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
+ expect(screen.queryByText(/owner can allow other bind addresses/i)).toBeNull();
+ } 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 8c1d725bd..dfe4f8644 100644
--- a/apps/server/web/src/components/connect/connect-panel.tsx
+++ b/apps/server/web/src/components/connect/connect-panel.tsx
@@ -7,6 +7,7 @@ import { useBrowserKeySelection } from "@/components/connect/browser-key-fields"
import { DestinationField } from "@/components/connect/destination-field";
import { buildDestinationOptions } from "@/components/connect/destination-options";
import { NoSshTargets } from "@/components/connect/no-ssh-targets";
+import { SshForwardFields } from "@/components/connect/ssh-forward-fields";
import { SshKeyStep } from "@/components/connect/ssh-key-step";
import { SaveSshPresetFields, SshPresetPicker } from "@/components/connect/ssh-preset-controls";
import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal";
@@ -41,6 +42,7 @@ import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form";
import { nodeOptionLabel } from "@/lib/node-label";
import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone";
import { sshCanEnableOnStart } from "@/lib/ssh";
+import { sshForwardRowsToWire } from "@/lib/ssh-forwards";
import { sshDestinationProblem } from "@/lib/ssh-input-validation";
import { launchWithLocalKey } from "@/lib/ssh-local-key";
import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset";
@@ -232,6 +234,7 @@ export function ConnectPanel({
const selectionError = sshKeySelectionProblem(fingerprints, roster.data?.identities);
const rosterReady = !keyHome || (!roster.isPending && !roster.isError && selectionError === null);
const destination = sshDestination(draft);
+ const forwardRows = sshForwardRowsToWire(draft.forwards);
// 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.
@@ -344,6 +347,7 @@ export function ConnectPanel({
destination,
...(expectedDestination ? { expectedDestination } : {}),
...(keyHome ? { keyHome, fingerprints } : {}),
+ ...(forwardRows.length > 0 ? { forwards: forwardRows } : {}),
};
const created =
draft.authentication === "local-key"
@@ -381,6 +385,7 @@ export function ConnectPanel({
destination,
...(expectedDestination ? { expectedDestination } : {}),
...(keyHome ? { keyHome, fingerprints } : {}),
+ ...(forwardRows.length > 0 ? { forwards: forwardRows } : {}),
},
launch.mutateAsync,
);
@@ -566,14 +571,17 @@ export function ConnectPanel({
0}
aria-controls="ssh-advanced-options"
onClick={() => setAdvanced((value) => !value)}
>
Advanced SSH options
-
+
Use keys from
)}
+
+ setDraft({ ...draft, forwards })}
+ />
+
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
new file mode 100644
index 000000000..1ac65d6a7
--- /dev/null
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -0,0 +1,170 @@
+import { Button, Input, Label } from "@internal/node-admin";
+import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
+import { Plus, X } from "lucide-react";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { RequiredMark } from "@/components/ui/required-mark";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { EMPTY_SSH_FORWARD_ROW, type SshForwardDraftRow, sshForwardRowProblems } from "@/lib/ssh-forwards";
+
+/**
+ * The port-forwards group (spec 2026-10-15 §7): every row a person declared
+ * for this connection, with its kind, ports, and (only on a machine whose
+ * owner allowed wide binds) its bind address. Rows are form state until
+ * Start; the launch carries them and the rendered config makes them real,
+ * and they die with the pane.
+ */
+
+const KIND_LABELS = [
+ { value: "local", label: "Local (-L)" },
+ { value: "remote", label: "Remote (-R)" },
+ { value: "dynamic", label: "Dynamic SOCKS (-D)" },
+] as const;
+
+function kindLabel(kind: SshForwardDraftRow["kind"]): string {
+ return KIND_LABELS.find((option) => option.value === kind)?.label ?? kind;
+}
+
+export function SshForwardFields({
+ rows,
+ allowWideBind,
+ busy,
+ onChange,
+}: {
+ rows: SshForwardDraftRow[];
+ /** The selected machine's wide-bind allowance; false hides the bind field. */
+ allowWideBind: boolean;
+ busy: boolean;
+ onChange: (rows: SshForwardDraftRow[]) => void;
+}) {
+ const patchRow = (index: number, value: Partial
) =>
+ onChange(rows.map((row, i) => (i === index ? { ...row, ...value } : row)));
+ return (
+
+ Port forwards
+
+ Each forward opens a listening port for as long as this connection lives. A local or dynamic forward binds it on
+ the connecting machine, so programs there reach a host the destination can see; a remote one asks the
+ destination to bind a port that reaches a host the connecting machine can see.
+
+ {rows.map((row, index) => {
+ const problem = sshForwardRowProblems(row);
+ const needsTarget = row.kind !== "dynamic";
+ const idPrefix = `ssh-fwd-${String(index)}`;
+ return (
+
+ );
+ })}
+ {!allowWideBind && (
+
+ Forwards bind this machine's own loopback address. Its owner can allow other bind addresses on the node's SSH
+ settings.
+
+ )}
+ = SSH_MAX_FORWARD_ROWS}
+ disabledReason={
+ rows.length >= SSH_MAX_FORWARD_ROWS
+ ? `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`
+ : undefined
+ }
+ onClick={() => onChange([...rows, { ...EMPTY_SSH_FORWARD_ROW }])}
+ >
+
+ Add port forward
+
+
+ );
+}
diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts
index dd5fbd377..c85a96de2 100644
--- a/apps/server/web/src/components/connect/ssh-refusal.ts
+++ b/apps/server/web/src/components/connect/ssh-refusal.ts
@@ -85,6 +85,16 @@ export function sshLaunchRefusal(
// ALIAS_UNSAFE: the token was refused before the machine was asked.
return { text: "Enter a valid SSH destination, such as deploy@example.com:22.", field: "destination" };
case 403: {
+ if (err.code === "SSH_FORWARD_BIND_DENIED") {
+ // A wide bind needs the machine's owner to allow it (spec 2026-10-15
+ // §6): the server's own sentence names both remedies, so pass it
+ // through like the CONFIG_INVALID arm does.
+ const message =
+ err.body && typeof err.body === "object" && "message" in err.body && typeof err.body.message === "string"
+ ? err.body.message
+ : err.message;
+ return { text: message, field: "machine" };
+ }
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
diff --git a/apps/server/web/src/components/connect/ssh-session-draft.ts b/apps/server/web/src/components/connect/ssh-session-draft.ts
index 6a06b181a..ccfd553f2 100644
--- a/apps/server/web/src/components/connect/ssh-session-draft.ts
+++ b/apps/server/web/src/components/connect/ssh-session-draft.ts
@@ -1,5 +1,6 @@
import type { DestinationCandidate } from "@/components/connect/destination-options";
import { type SshAgentIdentity, sshSelectionError } from "@/lib/ssh";
+import { type SshForwardDraftRow, sshForwardListProblems } from "@/lib/ssh-forwards";
import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation";
export type SshInitialChoices = {
@@ -30,6 +31,8 @@ export interface SshSessionDraft {
presetApplied?: boolean;
saveAsPreset?: boolean;
presetName?: string;
+ /** Declared port forwards as FORM rows (strings; untyped rows are ignored). Spec 2026-10-15. */
+ forwards: SshForwardDraftRow[];
}
export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft {
@@ -43,6 +46,7 @@ export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft {
typed: initial?.destination ?? "",
},
initialPresetId: initial?.preset,
+ forwards: [],
};
}
@@ -72,6 +76,8 @@ export function sshDraftProblems(draft: SshSessionDraft): Record
if (!draft.nodeId) return { node: "Choose a node to connect from first." };
const destinationProblem = sshDestinationProblem(sshDestination(draft));
if (destinationProblem) return { destination: destinationProblem };
+ const forwardProblem = sshForwardListProblems(draft.forwards);
+ if (forwardProblem) return { forwards: forwardProblem };
const error =
sshKeySourceProblem(draft) ||
(draft.keyHome ? sshKeySelectionProblem(draft.selections[draft.keyHome] ?? []) : null);
diff --git a/apps/server/web/src/components/connect/ssh-wizard.tsx b/apps/server/web/src/components/connect/ssh-wizard.tsx
index c49d89073..0049b0ee4 100644
--- a/apps/server/web/src/components/connect/ssh-wizard.tsx
+++ b/apps/server/web/src/components/connect/ssh-wizard.tsx
@@ -3,6 +3,7 @@ import { CircleAlert } from "lucide-react";
import { useEffect, useRef } from "react";
import { BrowserKeyFields, type BrowserKeySelection } from "@/components/connect/browser-key-fields";
import { SshDestinationStep } from "@/components/connect/ssh-destination-step";
+import { SshForwardFields } from "@/components/connect/ssh-forward-fields";
import { SshKeyStep } from "@/components/connect/ssh-key-step";
import { SshMachineSetup, SshMachineStep } from "@/components/connect/ssh-machine-step";
import { SaveSshPresetFields } from "@/components/connect/ssh-preset-controls";
@@ -19,6 +20,7 @@ import { useSshNodeRoster } from "@/hooks/use-ssh";
import { isDesktop } from "@/lib/desktop";
import { makeForm, useSubmitDisabled } from "@/lib/form";
import { type SshMachineReadiness, sshCanEnableOnStart } from "@/lib/ssh";
+import { sshForwardListProblems } from "@/lib/ssh-forwards";
import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation";
export interface SshWizardProps {
@@ -92,6 +94,8 @@ export function SshWizard({
if (!value.nodeId) return { draft: "Choose a node." };
const destinationProblem = sshDestinationProblem(sshDestination(value));
if (destinationProblem) return { draft: destinationProblem };
+ const forwardProblem = sshForwardListProblems(value.forwards);
+ if (forwardProblem) return { draft: forwardProblem };
if (remote) {
const error = !value.keyHome ? "Choose a node to supply keys." : sshKeySourceProblem(value);
if (error) return { draft: error };
@@ -331,6 +335,12 @@ export function SshWizard({
+ patch({ forwards })}
+ />
{!presetEditor && }
{refusal && (
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 83a0ccf64..312720f05 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
@@ -105,6 +105,7 @@ function view(over: Partial): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 465af89b7..26927df0c 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
@@ -36,6 +36,7 @@ function node(overrides: Partial = {}): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
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 b6101c03d..1ddac617a 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
@@ -35,6 +35,7 @@ function navNode(overrides: Partial = {}): NodeDetail {
canManage: true,
canRetire: true,
sshEnabled: false,
+ sshWideForwards: false,
canLaunch: true,
allowedDirs: [],
capabilities: [],
diff --git a/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx
index 5f69e2727..3b7fe0cc0 100644
--- a/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx
+++ b/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx
@@ -28,6 +28,7 @@ function node(over: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...over,
};
@@ -54,7 +55,7 @@ for (const enabled of [false, true]) {
return new Response("{}");
}) as unknown as typeof fetch;
mount(node({ sshEnabled: enabled }));
- expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe(String(enabled));
+ expect(screen.getByRole("switch", { name: "Allow SSH" }).getAttribute("aria-checked")).toBe(String(enabled));
fireEvent.click(screen.getByRole("switch", { name: "Allow SSH" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0].url).toContain("/api/nodes/node1/ssh-enabled");
@@ -62,6 +63,29 @@ for (const enabled of [false, true]) {
expect(JSON.parse(String(calls[0].init?.body))).toEqual({ on: !enabled });
});
}
+it("offers the wide-forwards switch beside the SSH switch, and writes its own endpoint", async () => {
+ const calls: { url: string; init?: RequestInit }[] = [];
+ globalThis.fetch = (async (url, init) => {
+ calls.push({ url: String(url), init });
+ return new Response("{}");
+ }) as unknown as typeof fetch;
+ mount(node({ sshEnabled: true }));
+ const wide = screen.getByRole("switch", { name: "Allow wide port forwards" });
+ expect(wide.getAttribute("aria-checked")).toBe("false");
+ fireEvent.click(wide);
+ await waitFor(() => expect(calls.some((c) => c.url.includes("/api/nodes/node1/ssh-wide-forwards"))).toBe(true));
+ const call = calls.find((c) => c.url.includes("/ssh-wide-forwards"));
+ expect(JSON.parse(String(call?.init?.body))).toEqual({ on: true });
+});
+
+it("hides the wide-forwards switch while SSH itself is off, and says who owns it to others", () => {
+ mount(node({ sshEnabled: false }));
+ expect(screen.queryByRole("switch", { name: "Allow wide port forwards" })).toBeNull();
+ mount(node({ sshEnabled: true, access: "edit", canManage: false }));
+ const wide = screen.getByRole("switch", { name: "Allow wide port forwards" });
+ expect(wide.hasAttribute("data-disabled")).toBe(true);
+});
+
it("shows configuration to edit viewers without offering changes", () => {
mount(node({ access: "edit", canManage: false }));
expect(screen.getByRole("switch").hasAttribute("data-disabled")).toBe(true);
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 7b61365d6..41feda532 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
@@ -30,6 +30,7 @@ function node(overrides: Partial = {}): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 bc64345fa..b820699bf 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
@@ -36,6 +36,7 @@ function node(over: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...over,
};
diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx
index 1c122507a..0018777de 100644
--- a/apps/server/web/src/components/nodes/node-ssh-card.tsx
+++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx
@@ -9,11 +9,12 @@ import {
Switch,
} from "@internal/node-admin";
import type { ReactNode } from "react";
-import { useSetSshEnabled } from "@/hooks/use-ssh";
+import { useSetSshEnabled, useSetSshWideForwards } from "@/hooks/use-ssh";
/** The node's configuration permission is supplied by the same server gate as the update route. */
export function NodeSshCard({ node, children }: { node: NodeDetail; children?: ReactNode }) {
const update = useSetSshEnabled();
+ const wide = useSetSshWideForwards();
return (
@@ -47,6 +48,32 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R
{errMessage(update.error, "Couldn't update the SSH setting.")}
)}
+ {node.sshEnabled && (
+
+
+ wide.mutate({ id: node.id, on })}
+ />
+ Allow wide port forwards
+
+
+ {node.sshWideForwards
+ ? "Port forwards can bind an address other machines can reach, so this machine routes their traffic into whatever the connection reaches. Turning this off refuses future wide binds; running forwards keep going until their pane closes."
+ : "Port forwards bind this machine's own loopback address only, so just programs on this machine can use them. Turn this on to let a forward bind an address other machines can reach."}
+
+ {!node.canManage && (
+
Only this node’s owner can change this setting.
+ )}
+ {wide.isError && (
+
+ {errMessage(wide.error, "Couldn't update the port-forward setting.")}
+
+ )}
+
+ )}
{children}
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
index be3a6ce26..3ed160d04 100644
--- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
+++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
@@ -32,6 +32,7 @@ const preset: SshPreset = {
authentication: "local-key",
keyHome: null,
fingerprints: [],
+ forwards: [],
createdAt: "t",
updatedAt: "t",
};
@@ -112,10 +113,36 @@ it("saves the local-key radio choice without requiring a file or approval", asyn
authentication: "local-key",
keyHome: null,
fingerprints: [],
+ forwards: [],
},
},
]);
});
+
+it("saves a declared port forward alongside the connection choices", async () => {
+ const { writes, closed } = await setup();
+ await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
+ fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
+ await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
+ await waitFor(() => expect(closed()).toBe(true));
+ expect(writes[0]?.body.forwards).toEqual([
+ { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 },
+ ]);
+});
+
+it("an incomplete forward row holds Save", async () => {
+ const { writes } = await setup();
+ await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
+ await waitFor(() => expect(disabled()).toBe(true));
+ expect(writes).toHaveLength(0);
+});
it("allows unrelated edits while the saved connecting node is offline", async () => {
const { writes } = await setup(false);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Renamed" } });
diff --git a/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx
index 3169523d2..48aafe65e 100644
--- a/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx
+++ b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx
@@ -48,6 +48,7 @@ function node(over: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: true,
+ sshWideForwards: false,
held: null,
sshTrust: {
own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC },
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 e2bef6da1..b64ce062d 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
@@ -194,6 +194,7 @@ const BOX = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 60f367fac..b3e354cbe 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
@@ -57,6 +57,7 @@ function node(overrides: Partial): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
diff --git a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx
index 0716166bc..ec693a2e4 100644
--- a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx
+++ b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx
@@ -39,6 +39,7 @@ function api() {
canManage: true,
access: "owner",
sshEnabled: true,
+ sshWideForwards: false,
maintenance: false,
harnesses: [],
allowedDirs: [],
@@ -70,6 +71,7 @@ function api() {
name: "Server",
kind: "local",
sshEnabled: true,
+ sshWideForwards: false,
canLaunch: true,
canManage: true,
status: "online",
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 181047db7..82d5c30df 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
@@ -39,6 +39,7 @@ const NODE: NodeDetail = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 eac2e457e..9afc42fad 100644
--- a/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx
+++ b/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx
@@ -41,6 +41,7 @@ const NODE: Node = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
};
diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts
index c3dd59ca4..ae24a5ad2 100644
--- a/apps/server/web/src/hooks/use-ssh.ts
+++ b/apps/server/web/src/hooks/use-ssh.ts
@@ -182,3 +182,22 @@ export function useSetSshEnabled() {
},
});
}
+
+/** Flip one machine's wide-bind allowance for SSH port forwards (spec 2026-10-15 §6); owner-only, like the SSH switch. */
+export function useSetSshWideForwards() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, on }: { id: string; on: boolean }) =>
+ apiFetch(`/api/nodes/${encodeURIComponent(id)}/ssh-wide-forwards`, {
+ method: "PUT",
+ body: JSON.stringify({ on }),
+ }),
+ onSuccess: async (_data, { id }) => {
+ await Promise.all([
+ client.invalidateQueries({ queryKey: ["ssh-readiness"] }),
+ client.invalidateQueries({ queryKey: [...NODE_QUERY_KEY, id] }),
+ client.invalidateQueries({ queryKey: NODES_QUERY_KEY }),
+ ]);
+ },
+ });
+}
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
new file mode 100644
index 000000000..995414d09
--- /dev/null
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "bun:test";
+import {
+ EMPTY_SSH_FORWARD_ROW,
+ type SshForwardDraftRow,
+ sshForwardListProblems,
+ sshForwardRowProblems,
+ sshForwardRowsFromWire,
+ sshForwardRowsToWire,
+} from "@/lib/ssh-forwards";
+
+/** The form-side mirror of the launch-time refusals (spec 2026-10-15). */
+function row(over: Partial = {}): SshForwardDraftRow {
+ return { kind: "local", listenPort: "5432", bindHost: "", targetHost: "db.internal", targetPort: "5432", ...over };
+}
+
+describe("sshForwardRowProblems", () => {
+ it("accepts a complete local row and a dynamic row with no target", () => {
+ expect(sshForwardRowProblems(row())).toBeNull();
+ expect(sshForwardRowProblems(row({ kind: "dynamic", targetHost: "", targetPort: "" }))).toBeNull();
+ });
+
+ it("refuses ports outside the range and non-numbers", () => {
+ for (const listenPort of ["", "0", "65536", "abc", "1.5", "-1"]) {
+ expect(sshForwardRowProblems(row({ listenPort }))).toMatch(/listen port/i);
+ }
+ expect(sshForwardRowProblems(row({ targetPort: "0" }))).toMatch(/target port/i);
+ });
+
+ it("refuses a local or remote row with no target host", () => {
+ expect(sshForwardRowProblems(row({ targetHost: "" }))).toMatch(/host to reach/i);
+ expect(sshForwardRowProblems(row({ kind: "remote", targetHost: "" }))).toMatch(/host to reach/i);
+ // dynamic carries no target at all, so blank is correct there.
+ expect(sshForwardRowProblems(row({ kind: "dynamic", targetHost: "", targetPort: "" }))).toBeNull();
+ });
+
+ it("refuses option-like or space-bearing bind hosts, and accepts blanks", () => {
+ expect(sshForwardRowProblems(row({ bindHost: "-oProxyCommand=x" }))).toMatch(/bind host/i);
+ expect(sshForwardRowProblems(row({ bindHost: "two words" }))).toMatch(/bind host/i);
+ expect(sshForwardRowProblems(row({ bindHost: "" }))).toBeNull();
+ expect(sshForwardRowProblems(row({ bindHost: "10.0.0.5" }))).toBeNull();
+ expect(sshForwardRowProblems(row({ bindHost: "[::1]" }))).toBeNull();
+ });
+});
+
+describe("sshForwardListProblems", () => {
+ it("an all-empty list (including untouched blank rows) is ready", () => {
+ expect(sshForwardListProblems([])).toBeNull();
+ expect(sshForwardListProblems([EMPTY_SSH_FORWARD_ROW, { ...EMPTY_SSH_FORWARD_ROW }])).toBeNull();
+ });
+
+ it("refuses two rows binding the same port, case-insensitively on the name", () => {
+ expect(sshForwardListProblems([row(), row({ targetHost: "other.internal" })])).toMatch(/same port/i);
+ expect(sshForwardListProblems([row({ bindHost: "LOCALHOST" }), row({ bindHost: "localhost" })])).toMatch(
+ /same port/i,
+ );
+ // Different literal names on one port is a bind race ssh reports itself.
+ expect(sshForwardListProblems([row({ bindHost: "localhost" }), row({ bindHost: "127.0.0.1" })])).toBeNull();
+ });
+
+ it("refuses over the cap", () => {
+ const many = Array.from({ length: 17 }, (_, i) => row({ listenPort: String(i + 1) }));
+ expect(sshForwardListProblems(many)).toMatch(/at most 16/i);
+ });
+
+ it("reports the first row's problem before list rules", () => {
+ expect(sshForwardListProblems([row({ listenPort: "" }), row()])).toMatch(/listen port/i);
+ });
+});
+
+describe("sshForwardRowsToWire", () => {
+ it("drops empty rows, parses numbers, and nulls the blanks", () => {
+ expect(
+ sshForwardRowsToWire([
+ row({ listenPort: " 5432 ", targetHost: " db.internal " }),
+ EMPTY_SSH_FORWARD_ROW,
+ row({ kind: "dynamic", listenPort: "1080", targetHost: "", targetPort: "" }),
+ ]),
+ ).toEqual([
+ { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 },
+ { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null },
+ ]);
+ });
+});
+
+describe("sshForwardRowsFromWire", () => {
+ it("round-trips wire rows into form strings and back", () => {
+ const wire = [
+ { kind: "remote" as const, listenPort: 9090, bindHost: "0.0.0.0", targetHost: "dev.local", targetPort: 3000 },
+ ];
+ expect(sshForwardRowsToWire(sshForwardRowsFromWire(wire))).toEqual(wire);
+ expect(sshForwardRowsFromWire(undefined)).toEqual([]);
+ });
+});
diff --git a/apps/server/web/src/lib/__tests__/ssh-preset.test.ts b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts
index 1fcc354e6..fb7101222 100644
--- a/apps/server/web/src/lib/__tests__/ssh-preset.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts
@@ -13,6 +13,7 @@ const preset: SshPreset = {
authentication: "remote",
keyHome: "keys",
fingerprints: ["SHA256:one"],
+ forwards: [],
createdAt: "t",
updatedAt: "t",
};
@@ -47,6 +48,7 @@ it("projects the local-key preference without key, credential, or stale remote s
authentication: "local-key",
keyHome: null,
fingerprints: [],
+ forwards: [],
});
});
it.each(["connecting", "remote", "local-key", "password"] as const)(
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 a6baaaaae..b9d565370 100644
--- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts
+++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts
@@ -33,6 +33,7 @@ function node(overrides: Partial): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
new file mode 100644
index 000000000..559c51622
--- /dev/null
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -0,0 +1,110 @@
+import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
+import type { SshForwardRow } from "@/lib/ssh";
+
+/**
+ * Port-forward rows as the FORM holds them: every value a string, because a
+ * half-typed port is normal keystrokes and NaN is not. The pure checks here
+ * mirror the server's refusals (kind rules, port bounds, host hygiene,
+ * duplicate binds) so a person sees the problem beside the field instead of
+ * after pressing Start; the protocol grammar on the server stays the
+ * authority, and nothing here needs to be a perfect copy of it.
+ */
+export type SshForwardDraftRow = {
+ kind: "local" | "remote" | "dynamic";
+ listenPort: string;
+ bindHost: string;
+ targetHost: string;
+ targetPort: string;
+};
+
+export const EMPTY_SSH_FORWARD_ROW: SshForwardDraftRow = {
+ kind: "local",
+ listenPort: "",
+ bindHost: "",
+ targetHost: "",
+ targetPort: "",
+};
+
+/** True when a row holds nothing but its default kind: an empty row is not a forward. */
+export function sshForwardRowEmpty(row: SshForwardDraftRow): boolean {
+ return (
+ row.listenPort.trim() === "" &&
+ row.bindHost.trim() === "" &&
+ row.targetHost.trim() === "" &&
+ row.targetPort.trim() === ""
+ );
+}
+
+const PORT_RE = /^\d{1,5}$/;
+
+function isPortText(value: string): boolean {
+ if (!PORT_RE.test(value.trim())) return false;
+ const n = Number(value.trim());
+ return n >= 1 && n <= 65535;
+}
+
+/** The host hygiene the wire grammar also refuses: no option-like start, no whitespace or control characters, bounded length. */
+function isHostText(value: string): boolean {
+ const host = value.trim();
+ return host.length > 0 && host.length <= 253 && !host.startsWith("-") && !/[\s\p{Cc}]/u.test(host);
+}
+
+/** The first problem with one row, in the voice of the field it belongs to. */
+export function sshForwardRowProblems(row: SshForwardDraftRow): string | null {
+ if (!isPortText(row.listenPort)) return "Enter a listen port from 1 to 65535.";
+ if (row.bindHost.trim() !== "" && !isHostText(row.bindHost))
+ return "Enter the bind host as a plain name or address, or leave it blank for loopback.";
+ if (row.kind !== "dynamic") {
+ if (!isHostText(row.targetHost)) return "Enter the host to reach through the connection.";
+ if (!isPortText(row.targetPort)) return "Enter a target port from 1 to 65535.";
+ }
+ return null;
+}
+
+/**
+ * The whole-list answer: per-row problems first, then the LIST rules
+ * (duplicate binds, the cap). Returns null when the list is ready to launch;
+ * empty rows (the untyped remainder of "Add forward") do not count against
+ * anything.
+ */
+export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): string | null {
+ const filled = rows.filter((row) => !sshForwardRowEmpty(row));
+ if (filled.length === 0) return null;
+ if (filled.length > SSH_MAX_FORWARD_ROWS)
+ return `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`;
+ for (const row of filled) {
+ const problem = sshForwardRowProblems(row);
+ if (problem) return problem;
+ }
+ const listens = new Set();
+ for (const row of filled) {
+ const key = `${row.bindHost.trim().toLowerCase()}|${row.listenPort.trim()}`;
+ if (listens.has(key)) return "Two forwards can't bind the same port. Remove one or change a port.";
+ listens.add(key);
+ }
+ return null;
+}
+
+/** The list as the wire wants it (empty rows dropped, numbers parsed, blanks null). Call only when {@link sshForwardListProblems} says the list is ready. */
+export function sshForwardRowsToWire(rows: readonly SshForwardDraftRow[]): SshForwardRow[] {
+ return rows
+ .filter((row) => !sshForwardRowEmpty(row))
+ .map((row) => ({
+ kind: row.kind,
+ listenPort: Number(row.listenPort.trim()),
+ bindHost: row.bindHost.trim() === "" ? null : row.bindHost.trim(),
+ targetHost: row.kind === "dynamic" || row.targetHost.trim() === "" ? null : row.targetHost.trim(),
+ targetPort: row.kind === "dynamic" || row.targetPort.trim() === "" ? null : Number(row.targetPort.trim()),
+ }));
+}
+
+/** A stored preset's rows back into form spelling. */
+export function sshForwardRowsFromWire(rows: readonly SshForwardRow[] | undefined): SshForwardDraftRow[] {
+ return (rows ?? []).map((row) => ({
+ kind: row.kind,
+ listenPort: String(row.listenPort),
+ bindHost: row.bindHost ?? "",
+ targetHost: row.targetHost ?? "",
+ targetPort: row.targetPort === null ? "" : String(row.targetPort),
+ }));
+}
diff --git a/apps/server/web/src/lib/ssh-preset.ts b/apps/server/web/src/lib/ssh-preset.ts
index 7f3e8abd6..7fd90a6e9 100644
--- a/apps/server/web/src/lib/ssh-preset.ts
+++ b/apps/server/web/src/lib/ssh-preset.ts
@@ -1,4 +1,5 @@
import { type SshSessionDraft, sshDestination, sshSessionDraft } from "@/components/connect/ssh-session-draft";
+import { sshForwardRowsFromWire, sshForwardRowsToWire } from "@/lib/ssh-forwards";
import type { SshPreset, SshPresetPayload } from "@/types/preset";
export const SSH_AUTHENTICATION_LABELS = {
@@ -19,6 +20,7 @@ export function sshDraftFromPreset(preset: SshPreset): SshSessionDraft {
keyHome: preset.authentication === "remote" ? (preset.keyHome ?? "") : "",
selections:
preset.authentication === "remote" && preset.keyHome ? { [preset.keyHome]: [...preset.fingerprints] } : {},
+ forwards: sshForwardRowsFromWire(preset.forwards),
};
}
@@ -37,5 +39,6 @@ export function sshPresetPayload(draft: SshSessionDraft, name: string, descripti
authentication,
keyHome: authentication === "remote" ? draft.keyHome : null,
fingerprints: authentication === "remote" ? (draft.selections[draft.keyHome] ?? []) : [],
+ forwards: sshForwardRowsToWire(draft.forwards),
};
}
diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts
index 4845c22f5..18a835af0 100644
--- a/apps/server/web/src/lib/ssh.ts
+++ b/apps/server/web/src/lib/ssh.ts
@@ -126,6 +126,26 @@ export interface SshAliasesView {
truncated: boolean;
}
+/**
+ * One declared port-forward row, mirroring the server's `SshForwardWire`
+ * (docs/security.md's SSH section; spec 2026-10-15). Hosts are canonical
+ * names; nulls mean the omitted spelling (loopback for local/dynamic, the
+ * destination's policy for remote). Checked against the route schema in
+ * `apps/server/api/src/api/ssh/launch.route.ts`.
+ */
+export interface SshForwardRow {
+ /** local (-L, binds on the connecting machine), remote (-R, binds on the destination), dynamic (-D SOCKS) */
+ kind: "local" | "remote" | "dynamic";
+ /** Port to bind, 1..65535, always explicit */
+ listenPort: number;
+ /** Listen bind host; null is the omitted default */
+ bindHost: string | null;
+ /** Target host through the connection; null only for dynamic */
+ targetHost: string | null;
+ /** Target port; null only for dynamic */
+ targetPort: number | null;
+}
+
/** `POST /api/ssh/launch` body: name is the pane's display name, optional. */
export interface SshLaunchRequest {
/** One-use native signing preparation, never key material. */
@@ -142,6 +162,8 @@ export interface SshLaunchRequest {
expectedDestination?: string;
/** Pane display name */
name?: string;
+ /** User-declared port forwards this connection carries (spec 2026-10-15); omitted launches none */
+ forwards?: SshForwardRow[];
}
/**
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 59adefa8b..185991655 100644
--- a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx
+++ b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx
@@ -40,6 +40,7 @@ function enrolledNode(overrides: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...overrides,
};
diff --git a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx
index 6997faf9e..327afab75 100644
--- a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx
+++ b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx
@@ -33,6 +33,7 @@ const NODES: Node[] = [
canRetire: true,
canLaunch: true,
sshEnabled: false,
+ sshWideForwards: false,
allowedDirs: [],
capabilities: [],
harnesses: [],
@@ -58,6 +59,7 @@ const NODES: Node[] = [
canRetire: true,
canLaunch: true,
sshEnabled: false,
+ sshWideForwards: false,
allowedDirs: [],
capabilities: [],
harnesses: [],
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 84df1c763..c28ad7742 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
@@ -142,6 +142,7 @@ describe("/nodes add-node gating", () => {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 ddb6938c4..d51078527 100644
--- a/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx
+++ b/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx
@@ -29,6 +29,7 @@ const NODE = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
};
diff --git a/apps/server/web/src/types/preset.ts b/apps/server/web/src/types/preset.ts
index c2a856ac6..52e1f6da4 100644
--- a/apps/server/web/src/types/preset.ts
+++ b/apps/server/web/src/types/preset.ts
@@ -4,6 +4,8 @@
* (apps/server/api/src/api/models.ts): it carries the fields the frontend uses
* and omits `userId`.
*/
+import type { SshForwardRow } from "@/lib/ssh";
+
export interface AgentPreset {
kind: "agent";
id: string;
@@ -58,6 +60,8 @@ export interface SshPreset {
keyHome: string | null;
/** Selected public-key fingerprints (remote mode only); private keys stay on their machine */
fingerprints: string[];
+ /** Declared port forwards this preset carries to every launch; empty when it stores none (spec 2026-10-15) */
+ forwards: SshForwardRow[];
/** ISO 8601 creation timestamp */
createdAt: string;
/** ISO 8601 update timestamp */
@@ -66,5 +70,13 @@ export interface SshPreset {
export type SshPresetPayload = Pick<
SshPreset,
- "name" | "description" | "destination" | "alias" | "resolutionAlias" | "authentication" | "keyHome" | "fingerprints"
+ | "name"
+ | "description"
+ | "destination"
+ | "alias"
+ | "resolutionAlias"
+ | "authentication"
+ | "keyHome"
+ | "fingerprints"
+ | "forwards"
> & { kind: "ssh"; nodeId: string; expectedDestination?: string };
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 eaea6ebfd..c1f4f021b 100644
--- a/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx
+++ b/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx
@@ -34,6 +34,7 @@ function node(over: Partial = {}): Node {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 9c91ec548..ac297b26b 100644
--- a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx
+++ b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx
@@ -41,6 +41,7 @@ function node(overrides: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 03310153a..5db4ec467 100644
--- a/packages/node-admin/src/__tests__/node-runtime-card.test.tsx
+++ b/packages/node-admin/src/__tests__/node-runtime-card.test.tsx
@@ -34,6 +34,7 @@ const base: NodeDetail = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: 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 1c983a6c5..7f70e0b35 100644
--- a/packages/node-admin/src/__tests__/node-service-card.test.tsx
+++ b/packages/node-admin/src/__tests__/node-service-card.test.tsx
@@ -49,6 +49,7 @@ const base: NodeDetail = {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
};
diff --git a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx
index 8bc8f846a..eaec6357b 100644
--- a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx
+++ b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx
@@ -44,6 +44,7 @@ function node(over: Partial = {}): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: false,
+ sshWideForwards: false,
held: null,
...over,
};
diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts
index 01aa16e32..d71a38dff 100644
--- a/packages/node-admin/src/types/node.ts
+++ b/packages/node-admin/src/types/node.ts
@@ -177,6 +177,14 @@ export interface Node {
* machine visible but disabled, never hidden.
*/
sshEnabled: boolean;
+ /**
+ * Whether SSH port forwards on this machine may bind non-loopback
+ * addresses (spec 2026-10-15 §6). Off by default: forwards bind loopback
+ * only, and the launch refuses a wide bind while this is false. The
+ * launch form reads it to decide whether to offer the bind-host field at
+ * all.
+ */
+ sshWideForwards: 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 0e22197218a1423511c8c195f82f84eab33a3afb Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 16:59:22 -0700
Subject: [PATCH 07/35] fix(ssh): move the backup era strip set with the
migration pin
The backup engine refuses a snapshot carrying migrations it does not know, and
the era fixture strips the ledger tail that the pin names: advancing the pin
without moving the strip set leaves an intermediate state where the restored
snapshot keeps the newer DDL under a re-applied older name. One commit is the
fixture's own rule.
Co-Authored-By: Claude
---
.../src/routes/__tests__/status-page.test.tsx | 1 +
.../__tests__/backup-cli-subprocess.test.ts | 26 ++++++++++++-------
.../api/src/services/backups/database.ts | 2 +-
3 files changed, 19 insertions(+), 10 deletions(-)
diff --git a/apps/node/web/src/routes/__tests__/status-page.test.tsx b/apps/node/web/src/routes/__tests__/status-page.test.tsx
index c820da5af..f99f07cc6 100644
--- a/apps/node/web/src/routes/__tests__/status-page.test.tsx
+++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx
@@ -50,6 +50,7 @@ function localView(): NodeDetail {
maintenanceAt: null,
maintenanceSource: null,
sshEnabled: true,
+ sshWideForwards: false,
held: null,
serverUrl: "http://127.0.0.1:3080",
// No `runtime`: the page then skips the Runtime/Service cards, and this
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 0192304eb..ac33cc75c 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+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.
+ // WHY 0047..0056 together: this era's ten migrations are the tail of the
+ // registered ledger, so the prefix runs contiguously through 0046 and boot
+ // re-applies all ten. Advancing LATEST_BACKUP_MIGRATION (0056) 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','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets') 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','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') 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_presets; DROP TABLE ssh_recent_destinations; 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_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','0053-drop-ssh-grants','0054-ssh-presets'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');",
+ "ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; 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_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','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards'); 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_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_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_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_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; 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_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','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets')",
+ "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','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards')",
)
.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','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets') 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','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') ORDER BY name",
)
.all(),
).toEqual([
@@ -442,6 +442,8 @@ test("real entry: available older upgrade snapshot restores and migrates before
{ name: "0052-subshell-key-home" },
{ name: "0053-drop-ssh-grants" },
{ name: "0054-ssh-presets" },
+ { name: "0055-node-ssh-wide-forwards" },
+ { name: "0056-ssh-preset-forwards" },
]);
expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" });
expect(
@@ -464,6 +466,12 @@ test("real entry: available older upgrade snapshot restores and migrates before
expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_host_pins'").get()).toEqual({
name: "ssh_host_pins",
});
+ expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_wide_forwards'").get()).toEqual({
+ name: "ssh_wide_forwards",
+ });
+ expect(migrated.query("SELECT name FROM pragma_table_info('ssh_presets') WHERE name='forwards'").get()).toEqual({
+ name: "forwards",
+ });
for (const removedTable of ["ssh_key_grants", "ssh_grant_requests"]) {
expect(migrated.query("SELECT name FROM sqlite_schema WHERE name=?").get(removedTable)).toBeNull();
}
diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts
index dc9229979..b96f2e69a 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 = "0054-ssh-presets";
+export const LATEST_BACKUP_MIGRATION = "0056-ssh-preset-forwards";
/** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */
export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise {
From a4dd3566c677ab8df108d77c125c035f14a4d375 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 16:59:35 -0700
Subject: [PATCH 08/35] docs(ssh): port forwards in the guides, the security
accounting, and changesets
Co-Authored-By: Claude
---
.changeset/document-ssh-port-forwards.md | 5 ++++
.changeset/ssh-port-forwards.md | 5 ++++
.claude/rules/security-context.md | 4 +--
apps/docs/content/docs/guides/ssh.mdx | 23 ++++++++++++++-
apps/docs/content/docs/nodes/manage.mdx | 2 ++
apps/docs/content/docs/reference/ports.mdx | 3 ++
docs/security.md | 33 ++++++++++++++++++++--
7 files changed, 70 insertions(+), 5 deletions(-)
create mode 100644 .changeset/document-ssh-port-forwards.md
create mode 100644 .changeset/ssh-port-forwards.md
diff --git a/.changeset/document-ssh-port-forwards.md b/.changeset/document-ssh-port-forwards.md
new file mode 100644
index 000000000..4fdee620f
--- /dev/null
+++ b/.changeset/document-ssh-port-forwards.md
@@ -0,0 +1,5 @@
+---
+"@internal/docs": minor
+---
+
+Document SSH port forwards: the kinds and the wide-forwards node switch in the SSH guide, the toggle on the node management page, and forwarded ports in the ports reference.
diff --git a/.changeset/ssh-port-forwards.md b/.changeset/ssh-port-forwards.md
new file mode 100644
index 000000000..9734574c7
--- /dev/null
+++ b/.changeset/ssh-port-forwards.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": minor
+---
+
+SSH connections can carry port forwards you declare: local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) rows in the connection form and in SSH presets. Forwards bind the connecting node's loopback address unless its owner turns on **Allow wide port forwards** on the node's SSH card, and a remote forward's bind answers to the destination's own SSH server policy. A forward opens with the subshell and closes when it ends. Forwards that came from the connecting account's SSH configuration files are still refused by name; only rows declared at launch open a port.
diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md
index 95db9a510..3483d3fcd 100644
--- a/.claude/rules/security-context.md
+++ b/.claude/rules/security-context.md
@@ -124,7 +124,7 @@ for the route inventory and trust limits. `/api/ssh/*` requires a browser sessio
SSH terminal input additionally requires the pane owner and refuses all bearer
actors. The node SSH switch is owner-only (admin for the built-in node), including
implicit enablement on launch. It controls outgoing SSH and agent-key supply,
-not incoming access. Relays require access to both nodes; the UI requires key selection, while the
+not incoming access. Port forwards are declared per connection, never config-derived (that refusal stands): loopback binds by default, and a non-loopback bind needs the node's owner-only wide-forwards switch (`node.ssh_wide_forwards.update`); remote-forward binds answer to the destination's sshd policy. Relays require access to both nodes; the UI requires key selection, while the
cookie API can select the current roster when selection is omitted. Private keys
stay at the source. Bounded configuration scanning does not prevent all local
execution during `ssh -G`; trust the connecting account’s SSH configuration. First-use destination confirmation cannot replace
@@ -186,7 +186,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_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|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 ids and relay ref, 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, relayRef on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; SSH preset 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_wide_forwards.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|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 ids and relay ref, 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, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset 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/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx
index f26be3349..c0ae50743 100644
--- a/apps/docs/content/docs/guides/ssh.mdx
+++ b/apps/docs/content/docs/guides/ssh.mdx
@@ -1,6 +1,6 @@
---
title: "Connect over SSH"
-description: "Open an SSH terminal through a node, choose keys or a password, verify unfamiliar destinations, and reuse connection choices as SSH presets."
+description: "Open an SSH terminal through a node, choose keys or a password, verify unfamiliar destinations, forward ports through the connection, and reuse connection choices as SSH presets."
---
Open an SSH terminal on another machine through one of your Subshell nodes.
@@ -116,6 +116,27 @@ Presets never store your private key, key file, passphrase, native approval, or
If you select **Save as preset** while launching and the connection fails, the saved preset remains available. Retrying the same choices doesn't create another copy. Previously remembered destinations aren't converted into named presets; create presets for the connections you want to reuse.
+## Port forwards
+
+Some services, like a database or an admin API, live behind the destination and have no other route. A connection can open a port that reaches them: in the connection form, choose **Advanced SSH options**, then **Add port forward**. Every forward belongs to that connection, and the port closes when the subshell ends.
+
+| Kind | Where the port opens | What it does |
+| --- | --- | --- |
+| **Local** (`-L`) | The connecting node | Programs on that node reach a host the destination can see. |
+| **Remote** (`-R`) | The destination | The destination reaches a host the connecting node can see. The destination's own SSH server configuration decides whether and how it binds the port. |
+| **Dynamic SOCKS** (`-D`) | The connecting node | Tools configured to use the SOCKS proxy pick their own destinations at runtime and reach them through the connection. |
+
+Fill in a **Local port** (or **Remote port** for a remote forward), and for local and remote kinds the **Target host** and **Target port** you want to reach. A local or dynamic forward can also name a **Bind address**; left blank, the port binds the node's loopback address, so only programs on that node use it.
+
+A non-loopback bind lets other machines route through the port into whatever the connection reaches. That's a decision about the machine, not the connection, so it stays off until the node's owner turns on **Allow wide port forwards** on the node's SSH card. A launch that asks for a wide bind is refused until then; turning the setting off again refuses future wide binds while forwards that are already running keep going until their subshell closes.
+
+If the port you asked for is already taken on the node, the destination's shell still opens, and the terminal prints SSH's warning that the forward didn't bind.
+
+A preset carries forwards beside its other connection choices, so connecting from the preset fills the same rows; you can still edit or remove them before you start. The forward rows themselves are not stored anywhere else, and every launch records them in the audit trail with the connection.
+
+> [!note]
+> A forward extends the reach your SSH login already grants; it doesn't add a new credential or a new trust. Forwards end with the subshell: to reopen a port after a disconnect, start a new SSH subshell.
+
## Make the destination a node
As the SSH subshell's owner, open its **⋯** menu and choose **Set up Subshell here…** when available. Review the confirmation before running the installer: it installs Subshell on the destination and enrolls it as a node you own. Your SSH subshell keeps running.
diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx
index cc9459a27..a1ffcce16 100644
--- a/apps/docs/content/docs/nodes/manage.mdx
+++ b/apps/docs/content/docs/nodes/manage.mdx
@@ -27,6 +27,8 @@ Only the node’s owner can change this setting. An administrator manages it for
You can also enable it when starting an SSH subshell: the connection form explains the change before you submit. See [Connect over SSH](/guides/ssh).
+With SSH allowed, the card gains **Allow wide port forwards**. While it's off, port forwards opened from this node bind the node's own loopback address, so only programs on that node can use them. Turning it on lets a launch bind a port other machines can reach, which routes their traffic through that connection. It's off by default and sits behind the same owner-only gate as **Allow SSH**; turning it off affects new launches, while forwards already running keep going until their subshell closes.
+
## Service and logs
Where you have management access, review the service and log sections. A node may be online from a terminal process even when no service is installed.
diff --git a/apps/docs/content/docs/reference/ports.mdx b/apps/docs/content/docs/reference/ports.mdx
index f8b4662ff..23c2ada83 100644
--- a/apps/docs/content/docs/reference/ports.mdx
+++ b/apps/docs/content/docs/reference/ports.mdx
@@ -14,6 +14,7 @@ Identify the network listeners and connection paths used by Subshell.
| Browser terminal stream | WebSocket through the control plane. |
| HTTPS proxy or network publication | Port selected by that frontend, forwarding to the server. |
| Node management dashboard | Loopback-only local listener; no login. Other local users can reach it. |
+| SSH port forward | A port and address chosen at launch, bound on the connecting node (local and dynamic kinds) or by the destination's SSH server (remote kind). Subshell fixes none of them. |
The server serves its API and production web interface on one port. Browsers don't need a route to every execution node.
@@ -23,6 +24,8 @@ Allow the server port only on the networks intended to reach it. A wildcard bind
Enrolled nodes connect outward. Avoid exposing their local management dashboard as a public endpoint.
+SSH port forwards follow the same shape: a forward that binds loopback adds no listener other machines can reach. A wide bind (allowed only when the node's owner turns on **Allow wide port forwards**) does, so firewall choices on that node's network apply to it. See [Connect over SSH](/guides/ssh#port-forwards).
+
## Development
The server API defaults to 3080 and the web development server to 5174. The documentation development server uses 3400; the marketing site uses 3401. The isolated end-to-end stack uses 3199.
diff --git a/docs/security.md b/docs/security.md
index e124688d7..46a612b55 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1220,7 +1220,7 @@ connecting node's credentials. Restart still requires a new SSH connection.
The connection form is **New subshell → SSH terminal**. **Settings → Presets → SSH**
manages saved connection choices; it has no host-key editor. SSH presets retain
-the resolved destination, original SSH resolution token, and authentication choice.
+the resolved destination, original SSH resolution token, authentication choice, and declared port forwards.
The token preserves alias-scoped routing and key settings; an optional
`expectedDestination` constraint checks the resolved username, host, and port
before save/clone, local-key preparation, trust discovery/acceptance, or launch
@@ -1238,6 +1238,7 @@ keys. It does not configure `sshd` or grant incoming SSH access.
| `/api/ssh/readiness`, `/aliases`, `/resolve`, `/identities` | Browser session required. Node discovery and operations follow the caller's node access and the applicable readiness gates; alias/configuration data and public agent identities are not anonymous information. |
| `POST /api/ssh/launch` | Browser session and launch access to the connecting node; relays also require launch access to the key source. Both participants must pass SSH and readiness checks. |
| `PUT /api/nodes/:id/ssh-enabled` | Browser session and node owner, or administrator for the built-in node. An edit share alone cannot enable SSH. The setting can be changed while a node is offline and is reconciled on reconnect. |
+| `PUT /api/nodes/:id/ssh-wide-forwards` | Browser session and node owner, or administrator for the built-in node. Governs whether new SSH launches from this node may bind port forwards beyond loopback; an edit share cannot change it. |
| `/api/ssh/recent-destinations` and `/preferences` | Browser session, scoped to the caller. Recent suggestions and the default connecting node are separate from destination trust. |
| `/api/presets` with SSH kind | Browser session, scoped to the caller. Saving resolves the destination through the existing node gate and checks access to any key node; it does not enable SSH or approve trust. Launch rechecks access, readiness, fingerprints, and trust. Deleting a preset neither removes its host pin nor closes a subshell. |
| `/api/ssh/verify-destination` and `/verify-destination/accept` | Browser session, current access checks, and the one-use confirmation binding described above. These are destination network probes and trust writes, not login attempts. |
@@ -1283,6 +1284,32 @@ account’s configuration as executable trusted input, not passive data. Remote-
one canonical destination. These restrictions do not sandbox that OS account
or make its OpenSSH binary and configuration untrusted-code-safe.
+**Port forwards are declared, never ambient.** Every forward that came from the
+connecting account's own SSH configuration stays refused by name: the resolver
+rejects `LocalForward`, `RemoteForward`, and `DynamicForward` found by `ssh -G`
+before the snapshot freezes, and the snapshot keeps them as forbidden members.
+Forwards that exist come from rows the user declared at launch (or from the
+preset storing them): an explicit kind, port, and target, visible in the form
+and summarized in the launch audit. A local or dynamic forward binds the
+connecting node's loopback address by default, which that node's other OS users
+can reach: §0's accepted posture verbatim, the same class the node's loopback
+dashboard already carries. Binding anything wider requires the node's
+**Allow wide port forwards** switch (owner-only, admin on the built-in node,
+default off, audited `node.ssh_wide_forwards.update`), and a launch asking for
+a wide bind on a node without it is refused; enforcement sits plane-side
+because the node already gates the whole SSH subsystem, and a plane lying
+about the flag is the control-plane-compromise case §0 accepts. A remote
+forward binds on the destination under its sshd's own `GatewayPorts` and
+`PermitRemoteOpen` policy, like any SSH client's request; Subshell relays it
+and owns the asking. When approved rows exist the rendered config omits its
+`ClearAllForwardings yes` line (measured on OpenSSH: the keyword applies at the
+end of the whole config load, so rows cannot survive it from anywhere), and
+that omission is honest because the pane's `-F` file replaces the connecting
+account's configuration outright, leaving only our rendered lines and the
+root-owned system file as forward sources. `Tunnel`, `ForwardAgent`, and
+`ForwardX11` toward the destination stay refused: this opens TCP port
+forwarding and nothing else.
+
**Setup progress is not a durable job.** Completed results remain for one hour
in the running server process. Closing the dialog does not cancel the installer;
a server restart loses progress tracking. Check the destination and node roster
@@ -2572,6 +2599,7 @@ 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_wide_forwards.update` (the boolean itself; which node, who flipped it),
`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
@@ -2607,7 +2635,8 @@ Audit events are written, grouped by family:
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 }` plus `relayRef` for a
- relay launch, naming which machine
+ relay launch and a bounded `forwards` summary (per approved row: kind, canonical
+ listen end, and target for local/remote) when the launch carries rows, 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
From bd9bde2d203a7d1f7a7948c5388b7a2622a23ff0 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 17:00:05 -0700
Subject: [PATCH 09/35] fix(ssh): canonical duplicate-bind check, per-row kind
help, and the e2e forward legs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-two review of spec 2026-10-15: a null bind on a local or dynamic row
renders exactly what a written 127.0.0.1 renders, so the grammar's duplicate
rule now sees them as one bind (mirrored in the form); the loopback test now
bounds each 127/8 octet; each forward row carries its own kind explanation
where the port lives (spec §7); the preset schema's clear sentence names the
editor's real empty-list path, with a test for the branch; and the spec §9
E2E legs ship - real -L, -D, and -R listeners, the wide-bind door before and
after the switch, and the rendered file byte-checked without the clear.
Co-Authored-By: Claude
---
apps/server/api/src/api/ssh-presets.ts | 2 +-
.../src/api/ssh/__tests__/ssh-routes.test.ts | 4 +
.../components/connect/ssh-forward-fields.tsx | 12 +-
.../src/lib/__tests__/ssh-forwards.test.ts | 14 +
apps/server/web/src/lib/ssh-forwards.ts | 6 +-
e2e/ports.ts | 10 +
e2e/tests/26-ssh-forwarding.spec.ts | 443 ++++++++++++++++++
.../src/__tests__/ssh-forwards.test.ts | 34 +-
.../subshell-protocol/src/ssh-forwards.ts | 20 +-
9 files changed, 535 insertions(+), 10 deletions(-)
create mode 100644 e2e/tests/26-ssh-forwarding.spec.ts
diff --git a/apps/server/api/src/api/ssh-presets.ts b/apps/server/api/src/api/ssh-presets.ts
index 97df223cc..f693f35ad 100644
--- a/apps/server/api/src/api/ssh-presets.ts
+++ b/apps/server/api/src/api/ssh-presets.ts
@@ -59,7 +59,7 @@ const SshPresetFields = {
t.Array(SshForwardRowInputSchema, {
maxItems: SSH_MAX_FORWARD_ROWS,
description:
- "Port forwards this preset carries to every launch; validated by the protocol grammar at save, and a non-loopback bind is still refused at launch until the connecting machine allows it. Omitted on update keeps the stored list, null clears it",
+ "Port forwards this preset carries to every launch; validated by the protocol grammar at save, and a non-loopback bind is still refused at launch until the connecting machine allows it. Omitted on update keeps the stored list; null or an empty list clears it",
}),
),
),
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 3c60be0e7..2cfe208f9 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
@@ -766,6 +766,10 @@ describe("SSH preset forwards (spec 2026-10-15 §7)", () => {
const kept = await put({});
expect(kept.status).toBe(200);
expect(((await kept.json()) as { forwards: unknown[] }).forwards).toEqual([fwd]);
+ // The editor's real clear path: deleting every row sends []. An
+ // "empty keeps" refactor here would silently break that button.
+ const emptied = await put({ forwards: [] });
+ expect(((await emptied.json()) as { forwards: unknown[] }).forwards).toEqual([]);
const cleared = await put({ forwards: null });
expect(((await cleared.json()) as { forwards: unknown[] }).forwards).toEqual([]);
const row = await db.selectFrom("sshPresets").select("forwards").where("id", "=", id).executeTakeFirst();
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 1ac65d6a7..36d6cd7ff 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -24,6 +24,15 @@ function kindLabel(kind: SshForwardDraftRow["kind"]): string {
return KIND_LABELS.find((option) => option.value === kind)?.label ?? kind;
}
+/** What the chosen kind does and where its port lives, kept beside the row it explains (spec §7). */
+const KIND_HELP: Record = {
+ local: "The port opens on this machine, and programs here reach the target through the destination.",
+ remote:
+ "The destination opens the port and reaches the target through this machine; its own SSH server decides how it binds.",
+ dynamic:
+ "A SOCKS proxy port opens on this machine; tools that use it pick their own targets and reach them through the destination.",
+};
+
export function SshForwardFields({
rows,
allowWideBind,
@@ -136,6 +145,7 @@ export function SshForwardFields({
+ {KIND_HELP[row.kind]}
{problem && (
{problem}
@@ -146,7 +156,7 @@ export function SshForwardFields({
})}
{!allowWideBind && (
- Forwards bind this machine's own loopback address. Its owner can allow other bind addresses on the node's SSH
+ Forwards bind this machine's own loopback address. Its owner can allow other bind addresses in the node's SSH
settings.
)}
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
index 995414d09..1e152ef3a 100644
--- a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -57,6 +57,20 @@ describe("sshForwardListProblems", () => {
expect(sshForwardListProblems([row({ bindHost: "localhost" }), row({ bindHost: "127.0.0.1" })])).toBeNull();
});
+ it("refuses a blank bind beside an explicit 127.0.0.1, and keeps the remote pair distinct", () => {
+ // A blank local bind renders as 127.0.0.1, so the rows are one listener twice.
+ expect(sshForwardListProblems([row(), row({ bindHost: "127.0.0.1", targetHost: "other.internal" })])).toMatch(
+ /same port/i,
+ );
+ // A blank remote bind rides the destination's own policy spelling.
+ expect(
+ sshForwardListProblems([
+ row({ kind: "remote", bindHost: "" }),
+ row({ kind: "remote", bindHost: "127.0.0.1", targetHost: "other.internal" }),
+ ]),
+ ).toBeNull();
+ });
+
it("refuses over the cap", () => {
const many = Array.from({ length: 17 }, (_, i) => row({ listenPort: String(i + 1) }));
expect(sshForwardListProblems(many)).toMatch(/at most 16/i);
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index 559c51622..f5fcbaed7 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -78,7 +78,11 @@ export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): str
}
const listens = new Set();
for (const row of filled) {
- const key = `${row.bindHost.trim().toLowerCase()}|${row.listenPort.trim()}`;
+ // Same canonical as the protocol's dupe key: a blank bind on a local or
+ // dynamic row renders as `127.0.0.1`, so it IS that explicit bind twice;
+ // a remote blank rides the destination's own policy spelling.
+ const bind = row.bindHost.trim() === "" && row.kind !== "remote" ? "127.0.0.1" : row.bindHost.trim();
+ const key = `${bind.toLowerCase()}|${row.listenPort.trim()}`;
if (listens.has(key)) return "Two forwards can't bind the same port. Remove one or change a port.";
listens.add(key);
}
diff --git a/e2e/ports.ts b/e2e/ports.ts
index eb0d20e4e..ea97e0905 100644
--- a/e2e/ports.ts
+++ b/e2e/ports.ts
@@ -67,6 +67,16 @@ export const PORTS = {
* artifact dir the "Set up Subshell here" install fetches from.
*/
relay: envPort("E2E_PORT_RELAY", 3203),
+ /*
+ * `forwards` belongs to spec 26 (the port-forwards legs of spec 2026-10-15
+ * §9): a FOURTH standalone ssh-tier backend for the same HOME-override
+ * reason as `ssh` and `connect`, and a fresh slot rather than a reuse -
+ * the ssh specs keep their instances alive across their own retries, so
+ * parallel ssh backends must never collide on one port. Its forwards are
+ * the spec's own scratch listeners, not fixed ports: the spec binds what
+ * `freePort()` names.
+ */
+ forwards: envPort("E2E_PORT_FORWARDS", 3204),
} as const;
export const BASE_URL = `http://127.0.0.1:${PORTS.backend}`;
diff --git a/e2e/tests/26-ssh-forwarding.spec.ts b/e2e/tests/26-ssh-forwarding.spec.ts
new file mode 100644
index 000000000..a354b9e71
--- /dev/null
+++ b/e2e/tests/26-ssh-forwarding.spec.ts
@@ -0,0 +1,443 @@
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
+import { createServer } from "node:http";
+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";
+
+/**
+ * The port-forwards legs of spec 2026-10-15 §9: a REAL sshd, a REAL launch,
+ * and listeners this spec curls. Everything unit-level already answers the
+ * question "does the rendered file contain the forward lines?" - including a
+ * real `ssh -G` parse. What only a bound port can answer is the fact the
+ * whole render design rests on: that with `ClearAllForwardings` dropped from
+ * the policy block, ssh really binds the approved listens and traffic really
+ * flows, and that a row nobody approved binds nothing.
+ *
+ * The three kinds each get a leg on ONE launch:
+ * - `-L`: curl the loopback port on the connecting side and read the marker
+ * the target server answers with (if the clear survived, or the block
+ * never matched, the port stays unbound and curl fails),
+ * - `-D`: `curl --socks5-hostname` THROUGH the SOCKS listener fetches the
+ * same marker (a proxy that never bound refuses the connect),
+ * - `-R`: curl the listen port sshd opened on the destination side; the
+ * bytes come back through the tunnel to the target server, proving the
+ * remote forward was accepted and bound (the fixture sshd keeps the
+ * OpenSSH default `GatewayPorts no`, so its bind is loopback, which is
+ * exactly what a null `bindHost` means to ride - spec §6).
+ *
+ * Then the wide-bind gate as a door, not a copy: the same kind of row naming
+ * `0.0.0.0` is refused (403 `SSH_FORWARD_BIND_DENIED`) before the node's
+ * setting exists, and after the owner flips it the launch is accepted, the
+ * rendered file spells the wide bind, and the port answers. The audit row's
+ * bounded forwards summary is checked beside the bytes it describes.
+ *
+ * B/D are the same host here (the fixture sshd runs unprivileged beside the
+ * backend), which the legs tolerate: they assert a LISTEN exists that only
+ * the approved forward could have created, and that fact does not depend on
+ * two machines. The binary gate is spec 22's: no ssh binaries, skip LOUDLY.
+ */
+
+const ROOT = path.join(import.meta.dirname, "..", "..");
+const BACKEND_DIR = path.join(ROOT, "apps", "server", "api");
+const ORIGIN = `http://127.0.0.1:${PORTS.forwards}`;
+
+const READY_TIMEOUT = 60_000;
+const SPAWN_TIMEOUT = 60_000;
+
+/** The marker the target server answers with; every leg expects exactly this. */
+const MARKER = "FORWARD-MARKER-OK\n";
+
+const missing = missingSshBins();
+test.skip(
+ missing.length > 0 || spawnSync("curl", ["--version"], { encoding: "utf8" }).status !== 0,
+ `spec 26 needs real ssh binaries${missing.length > 0 ? ` (missing: ${missing.join(", ")})` : ""} and curl for the SOCKS leg`,
+);
+
+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);
+ }
+}
+
+/** A loopback ephemeral port, freed the instant it is named (the spec's own copy of the fixture's trick). */
+async function freePort(): Promise {
+ const srv = createServer();
+ await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve));
+ const { port } = srv.address() as { port: number };
+ await new Promise((resolve) => srv.close(() => resolve()));
+ return port;
+}
+
+/** GET as a yes/no bytes answer; connection refused (an unbound forward) is just "not yet". */
+async function fetchText(url: string): Promise {
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
+ return res.ok ? await res.text() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetch through a SOCKS5 listener (remote-resolving, the way `-D` is meant to
+ * be used). ASYNC on purpose: the marker server lives in this worker process,
+ * and a synchronous curl would park the event loop the server needs to
+ * answer - the forward would stall looking like a refusal to bind.
+ */
+async function socksFetch(proxyPort: number, url: string): Promise {
+ return await new Promise((resolve) => {
+ const child = spawn("curl", ["-s", "--max-time", "10", "--socks5-hostname", `127.0.0.1:${String(proxyPort)}`, url]);
+ let out = "";
+ child.stdout.on("data", (chunk: Buffer) => {
+ out += chunk.toString();
+ });
+ child.on("error", () => resolve(null));
+ child.on("close", (code) => resolve(code === 0 ? out : null));
+ });
+}
+
+interface ForwardInstance {
+ child: ReturnType;
+ dir: string;
+ dataDir: string;
+ tmuxBase: string;
+}
+
+/** Boots the spec's OWN backend on `PORTS.forwards`, HOME + agent socket in its env (spec 22's reason). */
+async function startForwardBackend(fixtureHome: string, sshAuthSock: string): Promise {
+ stopForwardBackend();
+ const dir = mkdtempSync(path.join(tmpdir(), "subshell-e2e-fwd-"));
+ 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.forwards),
+ 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,
+ HOME: fixtureHome,
+ SSH_AUTH_SOCK: sshAuthSock,
+ };
+ 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: ForwardInstance = { 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] forwarding 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] forwarding instance did not become ready at ${ORIGIN}`);
+ await sleep(300);
+ }
+}
+
+let instance: ForwardInstance | undefined;
+
+function stopForwardBackend(): void {
+ const inst = instance;
+ if (!inst) return;
+ instance = undefined;
+ try {
+ if (inst.child.pid) process.kill(-inst.child.pid, "SIGTERM");
+ } catch {
+ // Already gone.
+ }
+ 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: "Fwd Admin", email: "fwd-admin@subshell.test", password: "e2e-fwd-admin-pass-1" } as const;
+
+function freshCtx(playwright: PlaywrightWorkerArgs["playwright"]): Promise {
+ return playwright.request.newContext({ baseURL: ORIGIN, storageState: { cookies: [], origins: [] } });
+}
+
+test.describe("ssh port forwards (spec 2026-10-15)", () => {
+ test.describe.configure({ mode: "serial" });
+
+ const leaks: string[] = [];
+ const specTeardown: (() => Promise | void)[] = [];
+ let fixture: SshFixture | undefined;
+ let fixtureRoot: string | undefined;
+ let agentPid: number | undefined;
+ let adminRef: APIRequestContext | undefined;
+ let targetPort = 0;
+ const livePaneIds: string[] = [];
+
+ test.beforeAll(async ({ playwright }) => {
+ fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-fwdtier-"));
+ fixture = await startSshFixture(fixtureRoot);
+
+ 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 startForwardBackend(fixture.sshConfigHome, agentEnv.SSH_AUTH_SOCK);
+ adminRef = await freshCtx(playwright);
+ const signUp = await adminRef.post("/api/auth/sign-up/email", { data: ADMIN });
+ expect(signUp.ok(), await signUp.text()).toBe(true);
+ const enable = await adminRef.put("/api/nodes/local/ssh-enabled", { data: { on: true } });
+ expect(enable.ok(), await enable.text()).toBe(true);
+ // The marker server every leg reads its bytes back from. It listens on
+ // its own port; a leg's listen port proves the FORWARD bound a listener,
+ // because only the tunnel connects that listener to this server.
+ targetPort = await freePort();
+ const target = createServer((_req, res) => {
+ res.setHeader("content-type", "text/plain");
+ res.end(MARKER);
+ });
+ target.listen(targetPort, "127.0.0.1");
+ specTeardown.push(() => new Promise((resolve) => target.close(() => resolve())));
+ });
+
+ test.afterAll(async () => {
+ for (const [label, id] of [...livePaneIds].reverse().entries()) {
+ try {
+ if (adminRef) {
+ await adminRef.post(`/api/subshells/${id}/terminate`);
+ await adminRef.delete(`/api/subshells/${id}`);
+ }
+ } catch (err) {
+ leaks.push(`cleanup row ${label}: ${String(err)}`);
+ }
+ }
+ for (const close of specTeardown.reverse()) {
+ try {
+ await close();
+ } catch (err) {
+ leaks.push(`teardown: ${String(err)}`);
+ }
+ }
+ try {
+ await adminRef?.dispose();
+ } catch (err) {
+ leaks.push(`admin ctx: ${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 {
+ stopForwardBackend();
+ } catch (err) {
+ leaks.push(`backend stop: ${String(err)}`);
+ }
+ if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true });
+ if (leaks.length > 0) console.error(`[26-ssh-forwarding] cleanup problems: ${leaks.join("; ")}`);
+ });
+
+ test("an approved -L, -D, and -R bind real listeners; the rendered config carries the block without the clear; the audit names the rows", async () => {
+ test.setTimeout(240_000);
+ const admin = adminRef;
+ if (!admin || !fixture) throw new Error("beforeAll did not run");
+ const fx = fixture; // Local const: the poll closures below cannot narrow the outer `let`.
+ const inst = instance;
+ if (!inst) throw new Error("no backend instance");
+
+ const localListen = await freePort();
+ const socksListen = await freePort();
+ const remoteListen = await freePort();
+ const launch = await admin.post("/api/ssh/launch", {
+ data: {
+ node: "local",
+ destination: SSH_FIXTURE_ALIAS,
+ name: "e2e-fwd-three-kinds",
+ forwards: [
+ { kind: "local", listenPort: localListen, bindHost: null, targetHost: "127.0.0.1", targetPort },
+ { kind: "dynamic", listenPort: socksListen, bindHost: null, targetHost: null, targetPort: null },
+ { kind: "remote", listenPort: remoteListen, bindHost: null, targetHost: "127.0.0.1", targetPort },
+ ],
+ },
+ });
+ expect(launch.status(), await launch.text()).toBe(201);
+ const subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id;
+ livePaneIds.push(subshellId);
+
+ 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: the exact-destination block spells all three
+ // lines, the `Host *` policy stands (Tunnel no etc.), and the clear line
+ // the rows made obsolete is GONE - the whole §5 argument in one file.
+ const configPath = path.join(inst.dataDir, "ssh", subshellId, "config");
+ await pollUntil(`the rendered ssh config never appeared at ${configPath}`, () => existsSync(configPath));
+ const config = readFileSync(configPath, "utf8");
+ expect(config).toContain("Tunnel no");
+ expect(config).not.toContain("ClearAllForwardings");
+ expect(config).toContain(`LocalForward 127.0.0.1:${String(localListen)} 127.0.0.1:${String(targetPort)}`);
+ expect(config).toContain(`DynamicForward 127.0.0.1:${String(socksListen)}`);
+ expect(config).toContain(`RemoteForward ${String(remoteListen)} 127.0.0.1:${String(targetPort)}`);
+ expect(config.indexOf("Host *")).toBeLessThan(config.indexOf("# User-declared port forwards"));
+ expect(config).toContain("# User-declared port forwards, approved at launch (spec 2026-10-15).");
+
+ // The sshd shell must be up before the legs read bytes: forwards bind
+ // only after authentication completes ("Starting session" as spec 22's
+ // truth source).
+ await pollUntil(
+ () => `sshd never reported a shell for ${subshellId}`,
+ () => /Starting session: shell/.test(readFileSync(fx.logPath, "utf8")),
+ READY_TIMEOUT,
+ );
+
+ // -L: the connecting side's listener answers with the target's bytes.
+ await pollUntil(
+ () => `-L forward on 127.0.0.1:${String(localListen)} never answered`,
+ async () => (await fetchText(`http://127.0.0.1:${String(localListen)}/`)) === MARKER,
+ );
+ // -D: through the SOCKS listener, remote-resolved like any SOCKS client.
+ await pollUntil(
+ () => `-D SOCKS listener on 127.0.0.1:${String(socksListen)} never proxied`,
+ async () => (await socksFetch(socksListen, `http://127.0.0.1:${String(targetPort)}/`)) === MARKER,
+ );
+ // -R: sshd's own listener on the destination side; the bytes come back
+ // through the tunnel to the target server.
+ await pollUntil(
+ () => `-R forward on the destination never answered on ${String(remoteListen)}`,
+ async () => (await fetchText(`http://127.0.0.1:${String(remoteListen)}/`)) === MARKER,
+ );
+
+ // The audit row names what the pane opened, bounded and secret-free:
+ // kind + canonical listen per row, target for the two kinds that have one.
+ const audit = await admin.get("/api/audit?limit=50");
+ expect(audit.ok(), await audit.text()).toBe(true);
+ const rows = (await audit.json()) as { action: string; metadata: unknown }[];
+ const launchRow = rows.find((r) => r.action === "ssh.launch");
+ expect(launchRow).toBeDefined();
+ expect(launchRow?.metadata).toMatchObject({
+ nodeId: "local",
+ forwards: [
+ { kind: "local", listen: `127.0.0.1:${String(localListen)}`, target: `127.0.0.1:${String(targetPort)}` },
+ { kind: "dynamic", listen: `127.0.0.1:${String(socksListen)}` },
+ { kind: "remote", listen: String(remoteListen), target: `127.0.0.1:${String(targetPort)}` },
+ ],
+ });
+ });
+
+ test("a wide bind is refused before the switch and bound after it", async () => {
+ test.setTimeout(240_000);
+ const admin = adminRef;
+ if (!admin) throw new Error("beforeAll did not run");
+ const inst = instance;
+ if (!inst) throw new Error("no backend instance");
+
+ const wideListen = await freePort();
+ const rows = [{ kind: "local", listenPort: wideListen, bindHost: "0.0.0.0", targetHost: "127.0.0.1", targetPort }];
+
+ // Before: the refusal is pre-launch and names itself; nothing was dialed.
+ const denied = await admin.post("/api/ssh/launch", {
+ data: { node: "local", destination: SSH_FIXTURE_ALIAS, name: "e2e-fwd-wide-denied", forwards: rows },
+ });
+ expect(denied.status()).toBe(403);
+ expect(((await denied.json()) as { code: string }).code).toBe("SSH_FORWARD_BIND_DENIED");
+
+ // The owner flips the machine's own switch.
+ const flipped = await admin.put("/api/nodes/local/ssh-wide-forwards", { data: { on: true } });
+ expect(flipped.ok(), await flipped.text()).toBe(true);
+ expect(((await flipped.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true);
+
+ // After: the same row launches, the file spells the wide bind, and the
+ // listener answers.
+ const launch = await admin.post("/api/ssh/launch", {
+ data: { node: "local", destination: SSH_FIXTURE_ALIAS, name: "e2e-fwd-wide-accepted", forwards: rows },
+ });
+ expect(launch.status(), await launch.text()).toBe(201);
+ const subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id;
+ livePaneIds.push(subshellId);
+
+ const configPath = path.join(inst.dataDir, "ssh", subshellId, "config");
+ await pollUntil(`the wide-bind config never appeared at ${configPath}`, () => existsSync(configPath));
+ expect(readFileSync(configPath, "utf8")).toContain(
+ `LocalForward 0.0.0.0:${String(wideListen)} 127.0.0.1:${String(targetPort)}`,
+ );
+ await pollUntil(
+ () => `wide forward on ${String(wideListen)} never answered`,
+ async () => (await fetchText(`http://127.0.0.1:${String(wideListen)}/`)) === MARKER,
+ );
+ });
+});
diff --git a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
index 95d393613..bbda4c620 100644
--- a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
+++ b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
@@ -106,6 +106,26 @@ describe("parseSshForwardRows", () => {
expect(parseSshForwardRows(rows)).toEqual(rows);
});
+ it("refuses a null bind beside an explicit 127.0.0.1 on the same port: the two render one listener twice", () => {
+ expect(
+ parseSshForwardRows([makeRow(), makeRow({ bindHost: "127.0.0.1", targetHost: "other.example" })]),
+ ).toBeNull();
+ expect(
+ parseSshForwardRows([
+ makeRow({ kind: "dynamic", listenPort: 1080, targetHost: null, targetPort: null }),
+ makeRow({ kind: "dynamic", listenPort: 1080, bindHost: "127.0.0.1", targetHost: null, targetPort: null }),
+ ]),
+ ).toBeNull();
+ });
+
+ it("keeps a remote null bind distinct from a named one (D's GatewayPorts spelling is its own listen spec)", () => {
+ const rows = [
+ makeRow({ kind: "remote", listenPort: 9090, targetHost: "a.internal", targetPort: 1 }),
+ makeRow({ kind: "remote", listenPort: 9090, bindHost: "127.0.0.1", targetHost: "b.internal", targetPort: 2 }),
+ ];
+ expect(parseSshForwardRows(rows)).toEqual(rows);
+ });
+
it("refuses a non-array list", () => {
expect(parseSshForwardRows({ 0: makeRow() })).toBeNull();
expect(parseSshForwardRows("local")).toBeNull();
@@ -134,7 +154,19 @@ describe("isLoopbackForwardBind", () => {
expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(true);
});
}
- const wide = ["0.0.0.0", "::", "[::]", "10.0.0.5", "example.com", "[::ffff:127.0.0.1]", "mybox.internal"];
+ const wide = [
+ "0.0.0.0",
+ "::",
+ "[::]",
+ "10.0.0.5",
+ "example.com",
+ "[::ffff:127.0.0.1]",
+ "mybox.internal",
+ // Not a bindable quad: classifying it loopback would skip the wide gate
+ // on a name ssh simply refuses to bind.
+ "127.999.999.999",
+ "127",
+ ];
for (const bindHost of wide) {
it(`names ${bindHost} wide (fails closed: the test is the written name, not a DNS answer)`, () => {
expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(false);
diff --git a/packages/subshell-protocol/src/ssh-forwards.ts b/packages/subshell-protocol/src/ssh-forwards.ts
index f95256040..ce89d94a7 100644
--- a/packages/subshell-protocol/src/ssh-forwards.ts
+++ b/packages/subshell-protocol/src/ssh-forwards.ts
@@ -130,8 +130,15 @@ export function parseSshForwardRows(value: unknown): SshForwardWire[] | null {
const row = parseSshForwardRow(item);
if (row === null) return null;
// Casefold only for the dupe key: hostnames match case-insensitively, and
- // a "LOCALHOST" beside "localhost" is one bind, not two.
- const key = `${(row.bindHost ?? "").toLowerCase()}|${row.listenPort}`;
+ // a "LOCALHOST" beside "localhost" is one bind, not two. A null bind on a
+ // local/dynamic row is canonically ssh's loopback, which is what the
+ // renderer spells as `127.0.0.1`, so it DUPLICATES a written `127.0.0.1`
+ // rather than riding beside it; two different literal names on one port
+ // ("localhost" vs "127.0.0.1") stay a configuration ssh reports itself.
+ // A remote null renders bare (D's GatewayPorts policy picks the address),
+ // which is a different listen spec from any named bind, so it keys empty.
+ const bindKey = (row.kind === "remote" ? (row.bindHost ?? "") : (row.bindHost ?? "127.0.0.1")).toLowerCase();
+ const key = `${bindKey}|${row.listenPort}`;
if (seen.has(key)) return null;
seen.add(key);
rows.push(row);
@@ -155,8 +162,9 @@ export function isLoopbackForwardBind(row: SshForwardWire): boolean {
// The grammar admits IPv6 only bracketed (HOST_RE), so `[::1]` is the
// spelling a row can actually carry.
if (host === "localhost" || host === "[::1]") return true;
- // IPv4 127.0.0.0/8: dotted-quad only (the grammar's host shape admits the
- // other spellings, but nobody renders those here and refusing them as
- // "wide" fails closed, which is the safe direction).
- return /^127(\.\d{1,3}){3}$/.test(host);
+ // IPv4 127.0.0.0/8: dotted-quad with each octet 0-255 (the grammar's host
+ // shape admits other spellings, and a 127.999.999.999 is not a bindable
+ // address - classifying it "wide" fails closed, which is the safe side).
+ const OCTET = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
+ return new RegExp(`^127(\\.${OCTET}){3}$`).test(host);
}
From ad2c295c901fa66695a4c082a6ecaf84263ad265 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 17:17:37 -0700
Subject: [PATCH 10/35] refactor(ssh): per-side duplicate binds, one refusal
sentence, sharper row copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The three review nits worth shipping: the duplicate-listen rule keys the
listen end per SIDE (a port a local/dynamic row binds on the connecting
machine beside a remote row's port on the destination is two listeners), the
form keys ports numerically so a written-twice port is caught inline, the
launch and preset-save 400s share one sentence exported beside the grammar,
and the row help names the two ends instead of trusting a bare "this machine"
to read right on a remote-node launch. The spec's E2E test line now matches
what §5 ships: with approved rows, the Host * clear is gone.
Co-Authored-By: Claude
---
.../api/src/services/ssh-launch.service.ts | 8 +---
.../api/src/services/ssh-presets.service.ts | 8 +---
.../components/connect/ssh-forward-fields.tsx | 12 ++++--
.../src/lib/__tests__/ssh-forwards.test.ts | 12 ++++++
apps/server/web/src/lib/ssh-forwards.ts | 16 +++++---
.../2026-10-15-ssh-port-forwards-design.md | 11 ++++--
.../src/__tests__/ssh-forwards.test.ts | 8 ++++
packages/subshell-protocol/src/index.ts | 1 +
.../subshell-protocol/src/ssh-forwards.ts | 39 ++++++++++++-------
9 files changed, 77 insertions(+), 38 deletions(-)
diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts
index cb26b4835..442a7bfb1 100644
--- a/apps/server/api/src/services/ssh-launch.service.ts
+++ b/apps/server/api/src/services/ssh-launch.service.ts
@@ -21,6 +21,7 @@ import {
parseNodeSshAliasList,
parseSshConnectionSnapshot,
parseSshForwardRows,
+ SSH_FORWARD_LIST_REFUSAL,
SSH_NAME_MAX_CHARS,
type SshConnectionSnapshotWire,
type SshForwardWire,
@@ -446,12 +447,7 @@ export async function sshLaunch(args: {
let forwards: SshForwardWire[] = [];
if (args.forwards !== undefined) {
const parsed = parseSshForwardRows(args.forwards);
- if (parsed === null)
- return codedRefusal(
- 400,
- BackendErrorCodes.CONFIG_INVALID,
- "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port.",
- );
+ if (parsed === null) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, SSH_FORWARD_LIST_REFUSAL);
forwards = parsed;
}
if (forwards.length > 0 && forwards.some((f) => !isLoopbackForwardBind(f)) && row.sshWideForwards !== 1) {
diff --git a/apps/server/api/src/services/ssh-presets.service.ts b/apps/server/api/src/services/ssh-presets.service.ts
index a6ad89185..babee6248 100644
--- a/apps/server/api/src/services/ssh-presets.service.ts
+++ b/apps/server/api/src/services/ssh-presets.service.ts
@@ -1,4 +1,4 @@
-import { parseSshForwardRows } from "@internal/subshell-protocol";
+import { parseSshForwardRows, SSH_FORWARD_LIST_REFUSAL } from "@internal/subshell-protocol";
import type { Static } from "elysia";
import { loadNodeGate } from "@/api/nodes/node-gate.js";
import { throwCodedRefusal } from "@/api/ssh/ssh-views.js";
@@ -58,11 +58,7 @@ export async function saveSshPreset(
let forwardsJson: string | null = null;
if (body.forwards !== undefined && body.forwards !== null) {
const parsed = parseSshForwardRows(body.forwards);
- if (parsed === null)
- throw new SshPresetError(
- "bad_request",
- "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port.",
- );
+ if (parsed === null) throw new SshPresetError("bad_request", SSH_FORWARD_LIST_REFUSAL);
if (parsed.length > 0) forwardsJson = JSON.stringify(parsed);
}
let destination = body.destination;
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 36d6cd7ff..a8d22b4d5 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -24,13 +24,17 @@ function kindLabel(kind: SshForwardDraftRow["kind"]): string {
return KIND_LABELS.find((option) => option.value === kind)?.label ?? kind;
}
-/** What the chosen kind does and where its port lives, kept beside the row it explains (spec §7). */
+/**
+ * What the chosen kind does and where its port lives, kept beside the row it
+ * explains (spec §7). "The connecting machine" and "the destination" name the
+ * two ends: on a remote-node launch "this machine" would read as the browser.
+ */
const KIND_HELP: Record = {
- local: "The port opens on this machine, and programs here reach the target through the destination.",
+ local: "The port opens on the connecting machine, and programs there reach the target through the destination.",
remote:
- "The destination opens the port and reaches the target through this machine; its own SSH server decides how it binds.",
+ "The destination opens the port and reaches the target through the connecting machine; its own SSH server decides how it binds.",
dynamic:
- "A SOCKS proxy port opens on this machine; tools that use it pick their own targets and reach them through the destination.",
+ "A SOCKS proxy port opens on the connecting machine; tools that use it pick their own targets and reach them through the destination.",
};
export function SshForwardFields({
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
index 1e152ef3a..468b40dc6 100644
--- a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -69,6 +69,18 @@ describe("sshForwardListProblems", () => {
row({ kind: "remote", bindHost: "127.0.0.1", targetHost: "other.internal" }),
]),
).toBeNull();
+ // The same port on the two sides is two listeners, not a dupe.
+ expect(
+ sshForwardListProblems([
+ row({ listenPort: "8080", targetPort: "8080" }),
+ row({ kind: "remote", listenPort: "8080", targetPort: "3000" }),
+ ]),
+ ).toBeNull();
+ // The port keys numerically, so "05432" beside "5432" is one listener
+ // here rather than a form-only near-miss the server then 400s.
+ expect(
+ sshForwardListProblems([row({ listenPort: "05432" }), row({ listenPort: "5432", targetHost: "other.internal" })]),
+ ).toMatch(/same port/i);
});
it("refuses over the cap", () => {
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index f5fcbaed7..1156103cc 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -78,12 +78,18 @@ export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): str
}
const listens = new Set();
for (const row of filled) {
- // Same canonical as the protocol's dupe key: a blank bind on a local or
- // dynamic row renders as `127.0.0.1`, so it IS that explicit bind twice;
- // a remote blank rides the destination's own policy spelling.
+ // Same key as the protocol's dupe rule: per SIDE (a local/dynamic row
+ // binds the connecting machine, a remote row the destination, so one
+ // port on each side is two listeners), with the same canonical bind (a
+ // blank on a local or dynamic row renders as `127.0.0.1`, so it IS that
+ // explicit bind twice; a remote blank rides the destination's policy
+ // spelling) and the port as a number, so "05432" beside "5432" is one
+ // listener and not a form-only near-miss the server then refuses.
+ const side = row.kind === "remote" ? "D" : "B";
const bind = row.bindHost.trim() === "" && row.kind !== "remote" ? "127.0.0.1" : row.bindHost.trim();
- const key = `${bind.toLowerCase()}|${row.listenPort.trim()}`;
- if (listens.has(key)) return "Two forwards can't bind the same port. Remove one or change a port.";
+ const key = `${side}|${bind.toLowerCase()}|${String(Number(row.listenPort.trim()))}`;
+ if (listens.has(key))
+ return "Two forwards can't bind the same port on the same machine. Remove one or change a port.";
listens.add(key);
}
return null;
diff --git a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
index e36e46385..6c8bce491 100644
--- a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
+++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
@@ -180,10 +180,13 @@ flip writes its own row in the nodes family.
bytes; the path byte-check and the config dir sweep must still pass for a
launch that forwards).
- Web: draft round-trip, bind-host gating on the node flag, preset editor rows.
-- E2E: extend spec 22's real-sshd pattern: launch with a `-L` row and curl the bound
- loopback port for the fixture's bytes; a `-D` leg through `curl --socks5-hostname`;
- a `-R` loopback-bind leg; the wide-bind refusal before the toggle and acceptance
- after. Assert the recorded config carries the forward block and the `Host *` clear.
+- E2E (shipped: `e2e/tests/26-ssh-forwarding.spec.ts`): extend spec 22's real-sshd
+ pattern: launch with a `-L` row and curl the bound loopback port for the fixture's
+ bytes; a `-D` leg through `curl --socks5-hostname`; a `-R` loopback-bind leg; the
+ wide-bind refusal before the toggle and acceptance after. Assert the recorded config
+ carries the forward block under the exact-`Host` section and that the rowless
+ `Host *` clear is GONE (§5: the clear is dropped when rows are approved, so asserting
+ its presence would assert the opposite of what ships).
## 10. Non-goals and follow-ups
diff --git a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
index bbda4c620..00303a51b 100644
--- a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
+++ b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
@@ -118,6 +118,14 @@ describe("parseSshForwardRows", () => {
).toBeNull();
});
+ it("accepts one port spelled on both sides: a B bind and a D bind are two listeners", () => {
+ const rows = [
+ makeRow({ listenPort: 8080 }),
+ makeRow({ kind: "remote", listenPort: 8080, bindHost: "127.0.0.1", targetHost: "dev.local", targetPort: 3000 }),
+ ];
+ expect(parseSshForwardRows(rows)).toEqual(rows);
+ });
+
it("keeps a remote null bind distinct from a named one (D's GatewayPorts spelling is its own listen spec)", () => {
const rows = [
makeRow({ kind: "remote", listenPort: 9090, targetHost: "a.internal", targetPort: 1 }),
diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts
index c090ebc54..bbbe27d74 100644
--- a/packages/subshell-protocol/src/index.ts
+++ b/packages/subshell-protocol/src/index.ts
@@ -267,6 +267,7 @@ export {
isSshForwardKind,
parseSshForwardRows,
SSH_FORWARD_KINDS,
+ SSH_FORWARD_LIST_REFUSAL,
SSH_MAX_FORWARD_ROWS,
type SshForwardKind,
type SshForwardWire,
diff --git a/packages/subshell-protocol/src/ssh-forwards.ts b/packages/subshell-protocol/src/ssh-forwards.ts
index ce89d94a7..7fad3c6fe 100644
--- a/packages/subshell-protocol/src/ssh-forwards.ts
+++ b/packages/subshell-protocol/src/ssh-forwards.ts
@@ -72,6 +72,14 @@ export type SshForwardWire = {
*/
export const SSH_MAX_FORWARD_ROWS = 16;
+/**
+ * The sentence every station says when {@link parseSshForwardRows} refuses a
+ * list. Shared so the launch's 400 and the preset save's 400 cannot drift;
+ * the web form mirrors the same rules in its own per-field copy.
+ */
+export const SSH_FORWARD_LIST_REFUSAL =
+ "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port on the same machine.";
+
/** The keys one row may spell; anything else means the caller hand-built a shape this grammar does not have. */
const FORWARD_ROW_KEYS: readonly (keyof SshForwardWire)[] = [
"kind",
@@ -113,10 +121,12 @@ function parseSshForwardRow(value: unknown): SshForwardWire | null {
/**
* Validate a whole forward list: each row through {@link parseSshForwardRow},
- * then the LIST rules. A duplicate listen end (same host case-insensitively,
- * same port) refuses the list: two rows competing for one bind is a bug the
- * user should hear about, not a race ssh silently half-wins. The cap is a
- * refusal too, never a trim.
+ * then the LIST rules. A duplicate listen end refuses the list: two rows
+ * competing for one bind on one machine is a bug the user should hear about,
+ * not a race ssh silently half-wins. The listen end is keyed per SIDE: local
+ * and dynamic rows bind on the connecting machine, remote rows on the
+ * destination, so the same port spelled once on each side is two listeners,
+ * not a dupe. The cap is a refusal too, never a trim.
*
* @param value - candidate rows (parsed JSON; unknown provenance), or null/undefined for "no forwards"
* @returns the narrowed rows (possibly empty), or null when malformed or over cap
@@ -129,16 +139,19 @@ export function parseSshForwardRows(value: unknown): SshForwardWire[] | null {
for (const item of value) {
const row = parseSshForwardRow(item);
if (row === null) return null;
- // Casefold only for the dupe key: hostnames match case-insensitively, and
- // a "LOCALHOST" beside "localhost" is one bind, not two. A null bind on a
- // local/dynamic row is canonically ssh's loopback, which is what the
- // renderer spells as `127.0.0.1`, so it DUPLICATES a written `127.0.0.1`
- // rather than riding beside it; two different literal names on one port
- // ("localhost" vs "127.0.0.1") stay a configuration ssh reports itself.
- // A remote null renders bare (D's GatewayPorts policy picks the address),
- // which is a different listen spec from any named bind, so it keys empty.
+ // The listen end is per SIDE: a local/dynamic row binds B, a remote row
+ // binds D, so one port on each side is two listeners. Casefold only the
+ // name: hostnames match case-insensitively, and "LOCALHOST" beside
+ // "localhost" is one bind, not two. A null bind on a local/dynamic row is
+ // canonically ssh's loopback, which is what the renderer spells as
+ // `127.0.0.1`, so it DUPLICATES a written `127.0.0.1` rather than riding
+ // beside it; two different literal names on one port ("localhost" vs
+ // "127.0.0.1") stay a configuration ssh reports itself. A remote null
+ // renders bare (D's GatewayPorts policy picks the address), which is a
+ // different listen spec from any named bind, so it keys empty.
+ const side = row.kind === "remote" ? "D" : "B";
const bindKey = (row.kind === "remote" ? (row.bindHost ?? "") : (row.bindHost ?? "127.0.0.1")).toLowerCase();
- const key = `${bindKey}|${row.listenPort}`;
+ const key = `${side}|${bindKey}|${row.listenPort}`;
if (seen.has(key)) return null;
seen.add(key);
rows.push(row);
From c73aaf562eb26de563127b856a1efe5d6e3f2f3d Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 17:21:40 -0700
Subject: [PATCH 11/35] =?UTF-8?q?docs(ssh):=20unpack=20the=20=C2=A79=20tes?=
=?UTF-8?q?t=20bullet=20after=20review?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"the rowless Host * clear" named the render the clear belongs to in one
compressed word; the sentence now says which line disappears and when.
Co-Authored-By: Claude
---
.../specs/2026-10-15-ssh-port-forwards-design.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
index 6c8bce491..7da8b0119 100644
--- a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
+++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
@@ -184,9 +184,10 @@ flip writes its own row in the nodes family.
pattern: launch with a `-L` row and curl the bound loopback port for the fixture's
bytes; a `-D` leg through `curl --socks5-hostname`; a `-R` loopback-bind leg; the
wide-bind refusal before the toggle and acceptance after. Assert the recorded config
- carries the forward block under the exact-`Host` section and that the rowless
- `Host *` clear is GONE (§5: the clear is dropped when rows are approved, so asserting
- its presence would assert the opposite of what ships).
+ carries the forward block under the exact-`Host` section and that the `Host *`
+ policy's `ClearAllForwardings` line - present in a rowless render - is GONE
+ (§5: rows approved means the clear dropped, so asserting its presence would
+ assert the opposite of what ships).
## 10. Non-goals and follow-ups
From b3ef68b32bcb2cae0a6c13340dff0b3a0a086009 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 18:14:17 -0700
Subject: [PATCH 12/35] feat(ssh): radio the forward kind and split its
validation tone gold
The kind becomes a three-way radio with the chosen kind's explanation under
the row (the checked radio points AT it for screen readers), replacing the
one-paragraph description of all three. A row's validation sentence now
follows the ruling 2026-09-30 split: an empty required field shows gold
("nothing typed yet"), a typed value that fails the rule stays red.
Co-Authored-By: Claude
---
.../components/connect/ssh-forward-fields.tsx | 62 ++++++++++---------
.../src/lib/__tests__/ssh-forwards.test.ts | 35 +++++++++++
apps/server/web/src/lib/ssh-forwards.ts | 31 ++++++++--
3 files changed, 93 insertions(+), 35 deletions(-)
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index a8d22b4d5..a9a91eaa1 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -1,10 +1,11 @@
-import { Button, Input, Label } from "@internal/node-admin";
+import { Button, cn, Input, Label } from "@internal/node-admin";
import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
import { Plus, X } from "lucide-react";
import { Field, FieldLabel } from "@/components/ui/field";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { RequiredMark } from "@/components/ui/required-mark";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { EMPTY_SSH_FORWARD_ROW, type SshForwardDraftRow, sshForwardRowProblems } from "@/lib/ssh-forwards";
+import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone";
+import { EMPTY_SSH_FORWARD_ROW, type SshForwardDraftRow, sshForwardRowProblem } from "@/lib/ssh-forwards";
/**
* The port-forwards group (spec 2026-10-15 §7): every row a person declared
@@ -20,10 +21,6 @@ const KIND_LABELS = [
{ value: "dynamic", label: "Dynamic SOCKS (-D)" },
] as const;
-function kindLabel(kind: SshForwardDraftRow["kind"]): string {
- return KIND_LABELS.find((option) => option.value === kind)?.label ?? kind;
-}
-
/**
* What the chosen kind does and where its port lives, kept beside the row it
* explains (spec §7). "The connecting machine" and "the destination" name the
@@ -55,36 +52,40 @@ export function SshForwardFields({
Port forwards
- Each forward opens a listening port for as long as this connection lives. A local or dynamic forward binds it on
- the connecting machine, so programs there reach a host the destination can see; a remote one asks the
- destination to bind a port that reaches a host the connecting machine can see.
+ Each forward opens a listening port for as long as this connection lives, and the port closes when the subshell
+ ends.
{rows.map((row, index) => {
- const problem = sshForwardRowProblems(row);
+ const problem = sshForwardRowProblem(row);
const needsTarget = row.kind !== "dynamic";
const idPrefix = `ssh-fwd-${String(index)}`;
+ const helpId = `${idPrefix}-kind-help`;
return (
-
- Kind
- ({ value: option.value, label: kindLabel(option.value) }))}
+
+
+ Kind
+
+ patchRow(index, { kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
>
-
-
-
-
- {KIND_LABELS.map((option) => (
-
+ {KIND_LABELS.map((option) => (
+
+
+
{option.label}
-
- ))}
-
-
+
+
+ ))}
+
@@ -149,10 +150,13 @@ export function SshForwardFields({
-
{KIND_HELP[row.kind]}
+ {/* One sentence per chosen kind, kept below the row it explains; the checked radio points here. */}
+
+ {KIND_HELP[row.kind]}
+
{problem && (
-
- {problem}
+
+ {problem.text}
)}
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
index 468b40dc6..8efd268d3 100644
--- a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -3,6 +3,7 @@ import {
EMPTY_SSH_FORWARD_ROW,
type SshForwardDraftRow,
sshForwardListProblems,
+ sshForwardRowProblem,
sshForwardRowProblems,
sshForwardRowsFromWire,
sshForwardRowsToWire,
@@ -42,6 +43,40 @@ describe("sshForwardRowProblems", () => {
});
});
+describe("sshForwardRowProblem tone", () => {
+ it("calls an empty required field a gap and a typed wrong value a hard error", () => {
+ expect(sshForwardRowProblem(row({ listenPort: "" }))).toEqual({
+ text: "Enter a listen port from 1 to 65535.",
+ gap: true,
+ });
+ expect(sshForwardRowProblem(row({ listenPort: "abc" }))).toEqual({
+ text: "Enter a listen port from 1 to 65535.",
+ gap: false,
+ });
+ expect(sshForwardRowProblem(row({ listenPort: "1.5" }))).toEqual({
+ text: "Enter a listen port from 1 to 65535.",
+ gap: false,
+ });
+ // An empty target host is the same fact as an empty listen port: nothing typed yet.
+ expect(sshForwardRowProblem(row({ targetHost: "" }))).toEqual({
+ text: "Enter the host to reach through the connection.",
+ gap: true,
+ });
+ // A malformed bind host is always a typed value that is wrong.
+ expect(sshForwardRowProblem(row({ bindHost: "-oProxyCommand=x" }))).toEqual({
+ text: "Enter the bind host as a plain name or address, or leave it blank for loopback.",
+ gap: false,
+ });
+ expect(sshForwardRowProblem(row())).toBeNull();
+ });
+
+ it("keeps the text-only voice in step with the toned one", () => {
+ const r = row({ listenPort: "" });
+ expect(sshForwardRowProblems(r)).toBe(sshForwardRowProblem(r)?.text ?? null);
+ expect(sshForwardRowProblems(row())).toBeNull();
+ });
+});
+
describe("sshForwardListProblems", () => {
it("an all-empty list (including untouched blank rows) is ready", () => {
expect(sshForwardListProblems([])).toBeNull();
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index 1156103cc..9ce68eb6a 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -49,18 +49,37 @@ function isHostText(value: string): boolean {
return host.length > 0 && host.length <= 253 && !host.startsWith("-") && !/[\s\p{Cc}]/u.test(host);
}
-/** The first problem with one row, in the voice of the field it belongs to. */
-export function sshForwardRowProblems(row: SshForwardDraftRow): string | null {
- if (!isPortText(row.listenPort)) return "Enter a listen port from 1 to 65535.";
+/** A row problem and its tone: `gap` means "nothing typed yet" (gold), else the typed value is wrong (red). */
+export type SshForwardRowProblem = { text: string; gap: boolean };
+
+/**
+ * The first problem with one row, in the voice of the field it belongs to,
+ * with the gold/red split the form substrate uses (ruling 2026-09-30): an
+ * empty required field is a requirement not yet filled, a typed value that
+ * fails the same rule is a hard error.
+ */
+export function sshForwardRowProblem(row: SshForwardDraftRow): SshForwardRowProblem | null {
+ if (!isPortText(row.listenPort))
+ return { text: "Enter a listen port from 1 to 65535.", gap: row.listenPort.trim() === "" };
if (row.bindHost.trim() !== "" && !isHostText(row.bindHost))
- return "Enter the bind host as a plain name or address, or leave it blank for loopback.";
+ return {
+ text: "Enter the bind host as a plain name or address, or leave it blank for loopback.",
+ gap: false,
+ };
if (row.kind !== "dynamic") {
- if (!isHostText(row.targetHost)) return "Enter the host to reach through the connection.";
- if (!isPortText(row.targetPort)) return "Enter a target port from 1 to 65535.";
+ if (!isHostText(row.targetHost))
+ return { text: "Enter the host to reach through the connection.", gap: row.targetHost.trim() === "" };
+ if (!isPortText(row.targetPort))
+ return { text: "Enter a target port from 1 to 65535.", gap: row.targetPort.trim() === "" };
}
return null;
}
+/** The first problem with one row, as plain text (the gating voice; the component reads {@link sshForwardRowProblem} for the tone). */
+export function sshForwardRowProblems(row: SshForwardDraftRow): string | null {
+ return sshForwardRowProblem(row)?.text ?? null;
+}
+
/**
* The whole-list answer: per-row problems first, then the LIST rules
* (duplicate binds, the cap). Returns null when the list is ready to launch;
From 2567b99aef15b8cc026a0fec6b14d046096e2a67 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 18:20:21 -0700
Subject: [PATCH 13/35] feat(ssh): nest the forward fields under the chosen
kind, like Authentication
Drop the "Kind" heading: the radio group is labelled "Forward kind" for
assistive tech and stands on its own. The chosen option now carries its
caption and its port and target fields in the column beside the radio -
the same shape as the Authentication choice, where picking "Use another
node's keys" reveals the key controls beneath it.
Co-Authored-By: Claude
---
.../components/connect/ssh-forward-fields.tsx | 202 +++++++++---------
1 file changed, 104 insertions(+), 98 deletions(-)
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index a9a91eaa1..10ba2d11e 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -57,109 +57,115 @@ export function SshForwardFields({
{rows.map((row, index) => {
const problem = sshForwardRowProblem(row);
- const needsTarget = row.kind !== "dynamic";
const idPrefix = `ssh-fwd-${String(index)}`;
const helpId = `${idPrefix}-kind-help`;
return (
-
-
- {/* One sentence per chosen kind, kept below the row it explains; the checked radio points here. */}
-
- {KIND_HELP[row.kind]}
-
- {problem && (
-
- {problem.text}
-
- )}
-
+ );
+ })}
+
);
})}
{!allowWideBind && (
From 2a593d64d3feb878063d3f0a033d8bd99c190d27 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 18:29:31 -0700
Subject: [PATCH 14/35] feat(ssh): one-line route diagrams under each kind
caption
The prose names the ends; a mono line traces one request's path with the
example values from the field placeholders (5432, db.internal), so the
direction is legible at a glance without reading the sentence first.
Co-Authored-By: Claude
---
.../components/connect/ssh-forward-fields.tsx | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 10ba2d11e..caeacce96 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -23,15 +23,25 @@ const KIND_LABELS = [
/**
* What the chosen kind does and where its port lives, kept beside the row it
- * explains (spec §7). "The connecting machine" and "the destination" name the
- * two ends: on a remote-node launch "this machine" would read as the browser.
+ * explains (spec §7). The ends are named ("the machine you connect from",
+ * "the destination") because on a remote-node launch a bare "this machine"
+ * would read as the browser. The diagram line traces one request's route with
+ * the example values from the placeholders, so the prose names the ends and
+ * the arrows show the direction.
*/
const KIND_HELP: Record = {
- local: "The port opens on the connecting machine, and programs there reach the target through the destination.",
+ local:
+ "Opens a port on the machine you connect from. Anything you send to that port reaches the target through the destination.",
remote:
- "The destination opens the port and reaches the target through the connecting machine; its own SSH server decides how it binds.",
+ "Opens a port on the destination. Anything sent to that port reaches the target on the machine you connect from. The destination's own SSH settings decide whether the port may bind.",
dynamic:
- "A SOCKS proxy port opens on the connecting machine; tools that use it pick their own targets and reach them through the destination.",
+ "Opens a SOCKS proxy port on the machine you connect from. Tools pointed at it choose their own targets, each reached through the destination.",
+};
+
+const KIND_DIAGRAM: Record = {
+ local: "program → 5432 on the connecting machine → destination → db.internal:5432",
+ remote: "program on the destination → 9090 there → connecting machine → app.local:3000",
+ dynamic: "SOCKS client → 1080 on the connecting machine → destination → any host it asks for",
};
export function SshForwardFields({
@@ -87,6 +97,9 @@ export function SshForwardFields({
{KIND_HELP[option.value]}
+
+ {KIND_DIAGRAM[option.value]}
+
From 6d7143e8eead1513fbdfaf9ea9aea1c632524757 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:11:27 -0700
Subject: [PATCH 15/35] feat(ssh): compose one forward at a time, then list
what's added
The forward fields were a growable list where an incomplete row sat in
the same column as complete ones and gated the whole launch. They're
now an editor plus a table: the person composes one forward under the
chosen kind, Add port forward lights up only when that row is valid,
doesn't re-bind a listed port, and the list has room, and what lands
shows in a table with its plain-spelled bind and target plus a Remove
button. Half-typed text never reaches the draft, so Save and Start are
no longer held by an abandoned row.
The diagram under each kind now traces the ports and host actually
typed, with the kind's example (FORWARD_EXAMPLE, one source for the
placeholders and the diagram) standing in for still-empty slots.
sshForwardRowConflict is the Add-side mirror of the server's duplicate
bind refusal, and the table keys rows on their listen identity because
the list can't hold a pair the server would refuse.
---
.../connect/__tests__/connect-panel.test.tsx | 82 +++-
.../components/connect/ssh-forward-fields.tsx | 353 +++++++++++-------
.../__tests__/ssh-preset-editor.test.tsx | 24 +-
.../src/lib/__tests__/ssh-forwards.test.ts | 37 ++
apps/server/web/src/lib/ssh-forwards.ts | 72 +++-
5 files changed, 401 insertions(+), 167 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 a5d73403c..07d124a1e 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
@@ -765,7 +765,7 @@ it("a delayed URL preset cannot overwrite an edited draft during or after a pend
}
});
-it("carries declared port forwards in the launch body, and holds Start until the row is valid (spec 2026-10-15)", async () => {
+it("carries added port forwards in the launch body, and holds Add until the editor row is valid (spec 2026-10-15)", async () => {
const { calls, restore } = await renderPanel({
nodes: [HOST_A],
launch: () => json({ subshell: { id: "pane-fwd" } }, 201),
@@ -774,18 +774,33 @@ it("carries declared port forwards in the launch body, and holds Start until the
fireEvent.change(destinationInput(), { target: { value: "build.example" } });
await settle();
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
- fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
// Loopback-only machine: no bind field offered, and the reason is on screen.
expect(screen.queryByLabelText(/Bind address/)).toBeNull();
expect(screen.getByText(/loopback/i)).toBeTruthy();
+ // Disabled state rides aria-disabled, not the attribute: the node-admin
+ // Button stays focusable while it explains why Add is held (Base UI).
+ const addDisabled = () => {
+ const b = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement;
+ return b.disabled || b.getAttribute("aria-disabled") === "true";
+ };
+ // A pristine editor adds nothing: Add is disabled and no complaint shows.
+ expect(addDisabled()).toBe(true);
+ expect(screen.queryByText(/listen port/i)).toBeNull();
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
await settle();
- // Half a row: the listen port alone is not a launchable forward.
- expect((screen.getByRole("button", { name: /^Start SSH subshell$/ }) as HTMLButtonElement).disabled).toBe(true);
+ // Half a row: the listen port alone is not a forward.
+ expect(addDisabled()).toBe(true);
+ expect(screen.getByText(/host to reach/i)).toBeTruthy();
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
await settle();
+ expect(addDisabled()).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ // The forward lands in the table and the editor resets for the next one.
+ expect(screen.getByText("loopback:5432")).toBeTruthy();
+ expect((screen.getByLabelText(/Local port/) as HTMLInputElement).value).toBe("");
await clickConnect();
await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true));
expect(calls.find((call) => call.url === "/api/ssh/launch")?.body).toMatchObject({
@@ -798,12 +813,69 @@ it("carries declared port forwards in the launch body, and holds Start until the
}
});
+it("removes an added forward from the table before the launch", async () => {
+ const { calls, restore } = await renderPanel({
+ nodes: [HOST_A],
+ launch: () => json({ subshell: { id: "pane-fwd2" } }, 201),
+ });
+ try {
+ fireEvent.change(destinationInput(), { target: { value: "build.example" } });
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
+ await settle();
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "8080" } });
+ fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "app.internal" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "3000" } });
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ expect(screen.getByText("loopback:8080")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Remove this forward" }));
+ await settle();
+ expect(screen.queryByText("loopback:8080")).toBeNull();
+ await clickConnect();
+ await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true));
+ const launchBody = calls.find((call) => call.url === "/api/ssh/launch")?.body as Record;
+ expect(launchBody.forwards).toBeUndefined();
+ } finally {
+ restore();
+ }
+});
+
+it("refuses to add a second forward on a port the table already holds", async () => {
+ const { restore } = await renderPanel({ nodes: [HOST_A] });
+ try {
+ fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
+ await settle();
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
+ fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ // The next row on the same port is refused at the Add button, not at launch.
+ // Adding cleared the editor, so the whole row is typed again.
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
+ fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "cache.internal" } });
+ fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
+ await settle();
+ const addButton = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement;
+ expect(addButton.disabled || addButton.getAttribute("aria-disabled") === "true").toBe(true);
+ expect(screen.getByText(/already binds that port/i)).toBeTruthy();
+ // A press on the held button changes nothing: the table keeps its one row.
+ fireEvent.click(addButton);
+ await settle();
+ expect(screen.getAllByRole("button", { name: "Remove this forward" })).toHaveLength(1);
+ } finally {
+ restore();
+ }
+});
+
it("offers the bind address on a machine whose owner allows wide binds", async () => {
const HOST_WIDE = node({ id: "w1", name: "router box", sshEnabled: true, sshWideForwards: true });
const { restore } = await renderPanel({ nodes: [HOST_WIDE] });
try {
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
- fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
expect(screen.queryByText(/owner can allow other bind addresses/i)).toBeNull();
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index caeacce96..bfb9a8919 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -1,33 +1,43 @@
-import { Button, cn, Input, Label } from "@internal/node-admin";
+import { Button, Card, CardContent, cn, Input, Label } from "@internal/node-admin";
import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
import { Plus, X } from "lucide-react";
+import { useState } from "react";
import { Field, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { RequiredMark } from "@/components/ui/required-mark";
import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone";
-import { EMPTY_SSH_FORWARD_ROW, type SshForwardDraftRow, sshForwardRowProblem } from "@/lib/ssh-forwards";
+import {
+ EMPTY_SSH_FORWARD_ROW,
+ FORWARD_EXAMPLE,
+ type SshForwardDraftRow,
+ sshForwardRowConflict,
+ sshForwardRowDiagram,
+ sshForwardRowProblem,
+} from "@/lib/ssh-forwards";
/**
- * The port-forwards group (spec 2026-10-15 §7): every row a person declared
- * for this connection, with its kind, ports, and (only on a machine whose
- * owner allowed wide binds) its bind address. Rows are form state until
- * Start; the launch carries them and the rendered config makes them real,
- * and they die with the pane.
+ * The port-forwards group (spec 2026-10-15 §7): the person composes ONE
+ * forward in the editor, presses Add port forward (enabled only when that
+ * row is valid and doesn't re-bind a listed port), and the declared forwards
+ * land in the table below, where each can be removed again. The editor is
+ * form state that never leaves the component until it is added; the table
+ * holds what `onChange` commits and what the launch carries. Forwards die
+ * with the pane, and the intro says so.
*/
-const KIND_LABELS = [
- { value: "local", label: "Local (-L)" },
- { value: "remote", label: "Remote (-R)" },
- { value: "dynamic", label: "Dynamic SOCKS (-D)" },
+const KINDS = [
+ { value: "local", label: "Local (-L)", short: "Local" },
+ { value: "remote", label: "Remote (-R)", short: "Remote" },
+ { value: "dynamic", label: "Dynamic SOCKS (-D)", short: "Dynamic" },
] as const;
/**
- * What the chosen kind does and where its port lives, kept beside the row it
- * explains (spec §7). The ends are named ("the machine you connect from",
+ * What the chosen kind does and where its port lives, kept beside the option
+ * it explains (spec §7). The ends are named ("the machine you connect from",
* "the destination") because on a remote-node launch a bare "this machine"
- * would read as the browser. The diagram line traces one request's route with
- * the example values from the placeholders, so the prose names the ends and
- * the arrows show the direction.
+ * would read as the browser. The diagram (sshForwardRowDiagram) traces one
+ * request with the editor's own typed values, so the prose names the ends
+ * and the arrows show the direction.
*/
const KIND_HELP: Record = {
local:
@@ -38,11 +48,18 @@ const KIND_HELP: Record = {
"Opens a SOCKS proxy port on the machine you connect from. Tools pointed at it choose their own targets, each reached through the destination.",
};
-const KIND_DIAGRAM: Record = {
- local: "program → 5432 on the connecting machine → destination → db.internal:5432",
- remote: "program on the destination → 9090 there → connecting machine → app.local:3000",
- dynamic: "SOCKS client → 1080 on the connecting machine → destination → any host it asks for",
-};
+/** The table's Listen cell: what ssh will really bind, spelled plainly. */
+function listenText(row: SshForwardDraftRow): string {
+ const port = row.listenPort.trim();
+ const bind = row.bindHost.trim();
+ if (bind !== "") return `${bind}:${port}`;
+ return row.kind === "remote" ? `${port} (by policy)` : `loopback:${port}`;
+}
+
+/** The table's Target cell; a SOCKS forward has no fixed target to name. */
+function targetText(row: SshForwardDraftRow): string {
+ return row.kind === "dynamic" ? "any host the tool picks" : `${row.targetHost.trim()}:${row.targetPort.trim()}`;
+}
export function SshForwardFields({
rows,
@@ -56,8 +73,28 @@ export function SshForwardFields({
busy: boolean;
onChange: (rows: SshForwardDraftRow[]) => void;
}) {
- const patchRow = (index: number, value: Partial) =>
- onChange(rows.map((row, i) => (i === index ? { ...row, ...value } : row)));
+ const [editor, setEditor] = useState({ ...EMPTY_SSH_FORWARD_ROW });
+ const patchEditor = (value: Partial) => setEditor((current) => ({ ...current, ...value }));
+ const problem = sshForwardRowProblem(editor);
+ const touched = Object.entries(editor).some(([key, value]) => key !== "kind" && value !== "");
+ const conflict = problem === null && touched && sshForwardRowConflict(editor, rows);
+ const atCap = rows.length >= SSH_MAX_FORWARD_ROWS;
+ const addReason = atCap
+ ? `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`
+ : problem !== null
+ ? problem.text
+ : conflict
+ ? "Another listed forward already binds that port there."
+ : undefined;
+ const addForward = () => {
+ if (problem !== null || conflict || atCap) return;
+ onChange([...rows, { ...editor }]);
+ // Keep the chosen kind (the next forward is usually the same shape);
+ // clear the values, so the editor is never a stale copy of what was added.
+ setEditor({ ...EMPTY_SSH_FORWARD_ROW, kind: editor.kind });
+ };
+ const helpId = "ssh-fwd-kind-help";
+ const example = FORWARD_EXAMPLE[editor.kind];
return (
Port forwards
@@ -65,143 +102,173 @@ export function SshForwardFields({
Each forward opens a listening port for as long as this connection lives, and the port closes when the subshell
ends.
- {rows.map((row, index) => {
- const problem = sshForwardRowProblem(row);
- const idPrefix = `ssh-fwd-${String(index)}`;
- const helpId = `${idPrefix}-kind-help`;
- return (
- patchRow(index, { kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
- >
- {/* The Authentication field's shape: the chosen option carries its
- caption and its controls in the column beside the radio, and the
- other options stay one line each. */}
- {KIND_LABELS.map((option) => {
- const selected = row.kind === option.value;
- return (
-
-
-
-
- {option.label}
-
- {selected && (
- <>
-
- {KIND_HELP[option.value]}
-
-
- {KIND_DIAGRAM[option.value]}
-
-
+
patchEditor({ kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
+ >
+ {/* The Authentication field's shape: the chosen option carries its
+ caption, route diagram and fields in the column beside the radio,
+ and the other options stay one line each. */}
+ {KINDS.map((option) => {
+ const selected = editor.kind === option.value;
+ return (
+
+
+
-
- );
- })}
-
- );
- })}
- {!allowWideBind && (
-
- Forwards bind this machine's own loopback address. Its owner can allow other bind addresses in the node's SSH
- settings.
+ >
+ )}
+ {(allowWideBind || editor.bindHost !== "") && (
+
+ Bind address
+ patchEditor({ bindHost: event.target.value })}
+ />
+
+ )}
+
+ >
+ )}
+
+
+ );
+ })}
+
+ {/* Sentences show once a field is touched, not while it sits pristine. */}
+ {touched && problem && (
+
+ {problem.text}
+
+ )}
+ {touched && conflict && problem === null && (
+
+ Another listed forward already binds that port there. Change the port or the bind, or remove that one.
)}
= SSH_MAX_FORWARD_ROWS}
- disabledReason={
- rows.length >= SSH_MAX_FORWARD_ROWS
- ? `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`
- : undefined
- }
- onClick={() => onChange([...rows, { ...EMPTY_SSH_FORWARD_ROW }])}
+ onClick={addForward}
+ disabled={busy || addReason !== undefined}
+ disabledReason={addReason}
>
Add port forward
+ {!allowWideBind && (
+
+ Forwards bind this machine's own loopback address. Its owner can allow other bind addresses in the node's SSH
+ settings.
+
+ )}
+ {rows.length > 0 && (
+
+
+
+
+
+
+ Kind
+ Listen
+ Target
+
+
+
+
+ {rows.map((row, index) => (
+ // A row's listen identity is unique in this list: the Add
+ // button and the server both refuse a port the list already
+ // binds, so kind + bind spelling + port cannot repeat.
+
+
+ {KINDS.find((option) => option.value === row.kind)?.short ?? row.kind}
+
+ {listenText(row)}
+
+ {targetText(row)}
+
+
+ onChange(rows.filter((_, i) => i !== index))}
+ >
+
+ Remove
+
+
+
+ ))}
+
+
+
+
+
+ )}
);
}
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
index 3ed160d04..d29f1e2d1 100644
--- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
+++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
@@ -122,11 +122,14 @@ it("saves the local-key radio choice without requiring a file or approval", asyn
it("saves a declared port forward alongside the connection choices", async () => {
const { writes, closed } = await setup();
await waitFor(() => expect(disabled()).toBe(false));
- fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
- await waitFor(() => expect(disabled()).toBe(false));
+ await waitFor(() =>
+ expect((screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement).disabled).toBe(false),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await waitFor(() => expect(screen.getByText("loopback:5432")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
await waitFor(() => expect(closed()).toBe(true));
expect(writes[0]?.body.forwards).toEqual([
@@ -134,14 +137,21 @@ it("saves a declared port forward alongside the connection choices", async () =>
]);
});
-it("an incomplete forward row holds Save", async () => {
- const { writes } = await setup();
+it("a half-typed forward never reaches the preset: Add stays off and Save is not held", async () => {
+ const { writes, closed } = await setup();
await waitFor(() => expect(disabled()).toBe(false));
- fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
- await waitFor(() => expect(disabled()).toBe(true));
- expect(writes).toHaveLength(0);
+ // The incomplete editor row gates its own Add button, not the Save button.
+ // Disabled state rides aria-disabled (the node-admin Button explains itself
+ // while focusable), so check both spellings.
+ await waitFor(() => {
+ const b = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement;
+ expect(b.disabled || b.getAttribute("aria-disabled") === "true").toBe(true);
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
+ await waitFor(() => expect(closed()).toBe(true));
+ expect(writes[0]?.body.forwards).toEqual([]);
});
it("allows unrelated edits while the saved connecting node is offline", async () => {
const { writes } = await setup(false);
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
index 8efd268d3..a18f43a9c 100644
--- a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -3,6 +3,7 @@ import {
EMPTY_SSH_FORWARD_ROW,
type SshForwardDraftRow,
sshForwardListProblems,
+ sshForwardRowDiagram,
sshForwardRowProblem,
sshForwardRowProblems,
sshForwardRowsFromWire,
@@ -152,3 +153,39 @@ describe("sshForwardRowsFromWire", () => {
expect(sshForwardRowsFromWire(undefined)).toEqual([]);
});
});
+
+describe("sshForwardRowDiagram", () => {
+ it("traces the route with the ports and host the row actually holds", () => {
+ expect(sshForwardRowDiagram(row({ listenPort: "8080", targetHost: "cache.internal", targetPort: "11211" }))).toBe(
+ "program → 8080 on the connecting machine → destination → cache.internal:11211",
+ );
+ expect(
+ sshForwardRowDiagram(row({ kind: "remote", listenPort: "6000", targetHost: "dev.lan", targetPort: "5173" })),
+ ).toBe("program on the destination → 6000 there → connecting machine → dev.lan:5173");
+ });
+
+ it("trims typed whitespace out of the diagram", () => {
+ expect(sshForwardRowDiagram(row({ listenPort: " 8080 ", targetHost: " db.internal ", targetPort: " 6379 " }))).toBe(
+ "program → 8080 on the connecting machine → destination → db.internal:6379",
+ );
+ });
+
+ it("fills a still-empty slot from the kind's example, matching the field placeholder", () => {
+ // The row helper starts from the local example; blank it to see the fallback.
+ expect(sshForwardRowDiagram(EMPTY_SSH_FORWARD_ROW)).toBe(
+ "program → 5432 on the connecting machine → destination → db.internal:5432",
+ );
+ expect(sshForwardRowDiagram({ ...EMPTY_SSH_FORWARD_ROW, kind: "remote" })).toBe(
+ "program on the destination → 9090 there → connecting machine → app.local:3000",
+ );
+ expect(sshForwardRowDiagram({ ...EMPTY_SSH_FORWARD_ROW, kind: "dynamic" })).toBe(
+ "SOCKS client → 1080 on the connecting machine → destination → any host it asks for",
+ );
+ });
+
+ it("drops the target from a dynamic row's diagram even if a stale target lingers", () => {
+ expect(sshForwardRowDiagram(row({ kind: "dynamic", listenPort: "1080" }))).toBe(
+ "SOCKS client → 1080 on the connecting machine → destination → any host it asks for",
+ );
+ });
+});
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index 9ce68eb6a..3209a3459 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -80,11 +80,37 @@ export function sshForwardRowProblems(row: SshForwardDraftRow): string | null {
return sshForwardRowProblem(row)?.text ?? null;
}
+/**
+ * The protocol's duplicate key for one row: per SIDE (a local/dynamic row
+ * binds the connecting machine, a remote row the destination, so one port on
+ * each side is two listeners), with the canonical bind (a blank on a local or
+ * dynamic row renders as `127.0.0.1`, so it IS that explicit bind twice; a
+ * remote blank rides the destination's policy spelling) and the port as a
+ * number, so "05432" beside "5432" is one listener. Meaningful only on a row
+ * that passes {@link sshForwardRowProblems}.
+ */
+function forwardListenKey(row: SshForwardDraftRow): string {
+ const side = row.kind === "remote" ? "D" : "B";
+ const bind = row.bindHost.trim() === "" && row.kind !== "remote" ? "127.0.0.1" : row.bindHost.trim();
+ return `${side}|${bind.toLowerCase()}|${String(Number(row.listenPort.trim()))}`;
+}
+
+/**
+ * True when a candidate row would bind a port a listed forward already binds.
+ * The editor's Add button checks this so the list can never hold a pair the
+ * server would refuse at launch, and a fresh forward cannot quietly overwrite
+ * a listed one.
+ */
+export function sshForwardRowConflict(row: SshForwardDraftRow, rows: readonly SshForwardDraftRow[]): boolean {
+ if (rows.length === 0) return false;
+ const key = forwardListenKey(row);
+ return rows.some((existing) => forwardListenKey(existing) === key);
+}
+
/**
* The whole-list answer: per-row problems first, then the LIST rules
* (duplicate binds, the cap). Returns null when the list is ready to launch;
- * empty rows (the untyped remainder of "Add forward") do not count against
- * anything.
+ * empty rows do not count against anything.
*/
export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): string | null {
const filled = rows.filter((row) => !sshForwardRowEmpty(row));
@@ -97,16 +123,7 @@ export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): str
}
const listens = new Set();
for (const row of filled) {
- // Same key as the protocol's dupe rule: per SIDE (a local/dynamic row
- // binds the connecting machine, a remote row the destination, so one
- // port on each side is two listeners), with the same canonical bind (a
- // blank on a local or dynamic row renders as `127.0.0.1`, so it IS that
- // explicit bind twice; a remote blank rides the destination's policy
- // spelling) and the port as a number, so "05432" beside "5432" is one
- // listener and not a form-only near-miss the server then refuses.
- const side = row.kind === "remote" ? "D" : "B";
- const bind = row.bindHost.trim() === "" && row.kind !== "remote" ? "127.0.0.1" : row.bindHost.trim();
- const key = `${side}|${bind.toLowerCase()}|${String(Number(row.listenPort.trim()))}`;
+ const key = forwardListenKey(row);
if (listens.has(key))
return "Two forwards can't bind the same port on the same machine. Remove one or change a port.";
listens.add(key);
@@ -127,6 +144,37 @@ export function sshForwardRowsToWire(rows: readonly SshForwardDraftRow[]): SshFo
}));
}
+/**
+ * The example each kind uses for a still-empty slot. It is the single source
+ * of truth for BOTH the field placeholders and the route diagram, so a half
+ * -typed row never shows one example in its input and a different one in its
+ * diagram. `dynamic` has no target, so its host/port go unused.
+ */
+export const FORWARD_EXAMPLE: Record = {
+ local: { listen: "5432", host: "db.internal", port: "5432" },
+ remote: { listen: "9090", host: "app.local", port: "3000" },
+ dynamic: { listen: "1080", host: "db.internal", port: "5432" },
+};
+
+/**
+ * The one-line route diagram for THIS row: the ports and host the person
+ * actually typed, with {@link FORWARD_EXAMPLE} standing in for a still-empty
+ * slot. A diagram of generic numbers stops helping the moment real ones are
+ * filled in; this one tracks the form as it is typed.
+ */
+export function sshForwardRowDiagram(row: SshForwardDraftRow): string {
+ const example = FORWARD_EXAMPLE[row.kind];
+ const listen = row.listenPort.trim() === "" ? example.listen : row.listenPort.trim();
+ if (row.kind === "dynamic") {
+ return `SOCKS client → ${listen} on the connecting machine → destination → any host it asks for`;
+ }
+ const host = row.targetHost.trim() === "" ? example.host : row.targetHost.trim();
+ const targetPort = row.targetPort.trim() === "" ? example.port : row.targetPort.trim();
+ return row.kind === "local"
+ ? `program → ${listen} on the connecting machine → destination → ${host}:${targetPort}`
+ : `program on the destination → ${listen} there → connecting machine → ${host}:${targetPort}`;
+}
+
/** A stored preset's rows back into form spelling. */
export function sshForwardRowsFromWire(rows: readonly SshForwardRow[] | undefined): SshForwardDraftRow[] {
return (rows ?? []).map((row) => ({
From 146e8d039ac1bd5eb17d3408f9f129dbdfce28b3 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:16:25 -0700
Subject: [PATCH 16/35] feat(ssh): the forward form waits for the press
The port-forwards group opened with a full editor a person who wants
zero forwards still has to scroll past. It now follows the app's
add-a-thing lists: the section is one "Add port forward" button, the
press reveals the editor, committing adds the row and folds it away
(the kind sticks, the values clear), and Cancel backs out. Half-typed
text discarded by Cancel is still form state that never reached the
draft, so nothing else changes below the component.
---
.../connect/__tests__/connect-panel.test.tsx | 21 +-
.../components/connect/ssh-forward-fields.tsx | 259 ++++++++++--------
.../__tests__/ssh-preset-editor.test.tsx | 4 +
3 files changed, 164 insertions(+), 120 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 07d124a1e..732b46a6b 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
@@ -775,6 +775,10 @@ it("carries added port forwards in the launch body, and holds Add until the edit
await settle();
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
await settle();
+ // Nobody declared a forward: the section is one button, not a form.
+ expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
// Loopback-only machine: no bind field offered, and the reason is on screen.
expect(screen.queryByLabelText(/Bind address/)).toBeNull();
expect(screen.getByText(/loopback/i)).toBeTruthy();
@@ -798,9 +802,14 @@ it("carries added port forwards in the launch body, and holds Add until the edit
expect(addDisabled()).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
- // The forward lands in the table and the editor resets for the next one.
+ // The forward lands in the table and the editor folds away.
expect(screen.getByText("loopback:5432")).toBeTruthy();
+ expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
+ // Reopening starts clean: the kind sticks, the values do not.
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
expect((screen.getByLabelText(/Local port/) as HTMLInputElement).value).toBe("");
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await clickConnect();
await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true));
expect(calls.find((call) => call.url === "/api/ssh/launch")?.body).toMatchObject({
@@ -823,6 +832,8 @@ it("removes an added forward from the table before the launch", async () => {
await settle();
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "8080" } });
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "app.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "3000" } });
@@ -847,6 +858,8 @@ it("refuses to add a second forward on a port the table already holds", async ()
try {
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
@@ -854,7 +867,9 @@ it("refuses to add a second forward on a port the table already holds", async ()
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
// The next row on the same port is refused at the Add button, not at launch.
- // Adding cleared the editor, so the whole row is typed again.
+ // Adding folded the editor, so it opens again clean and the whole row is retyped.
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "cache.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
@@ -877,6 +892,8 @@ it("offers the bind address on a machine whose owner allows wide binds", async (
try {
fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
expect(screen.queryByText(/owner can allow other bind addresses/i)).toBeNull();
} finally {
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index bfb9a8919..74738ad96 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -16,13 +16,15 @@ import {
} from "@/lib/ssh-forwards";
/**
- * The port-forwards group (spec 2026-10-15 §7): the person composes ONE
- * forward in the editor, presses Add port forward (enabled only when that
- * row is valid and doesn't re-bind a listed port), and the declared forwards
- * land in the table below, where each can be removed again. The editor is
- * form state that never leaves the component until it is added; the table
- * holds what `onChange` commits and what the launch carries. Forwards die
- * with the pane, and the intro says so.
+ * The port-forwards group (spec 2026-10-15 §7), shaped like the app's other
+ * add-a-thing lists: the section starts as one "Add port forward" button and
+ * nothing else. Pressing it reveals the editor for ONE forward; commit puts
+ * that row in the table below (enabled only when the row is valid and doesn't
+ * re-bind a listed port) and folds the editor away again, so a person who
+ * declared nothing never sees a form. The editor is state that never leaves
+ * the component until it is added; the table holds what `onChange` commits
+ * and what the launch carries. Forwards die with the pane, and the intro says
+ * so.
*/
const KINDS = [
@@ -73,25 +75,24 @@ export function SshForwardFields({
busy: boolean;
onChange: (rows: SshForwardDraftRow[]) => void;
}) {
+ const [composing, setComposing] = useState(false);
const [editor, setEditor] = useState({ ...EMPTY_SSH_FORWARD_ROW });
const patchEditor = (value: Partial) => setEditor((current) => ({ ...current, ...value }));
const problem = sshForwardRowProblem(editor);
const touched = Object.entries(editor).some(([key, value]) => key !== "kind" && value !== "");
const conflict = problem === null && touched && sshForwardRowConflict(editor, rows);
const atCap = rows.length >= SSH_MAX_FORWARD_ROWS;
- const addReason = atCap
- ? `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`
- : problem !== null
- ? problem.text
- : conflict
- ? "Another listed forward already binds that port there."
- : undefined;
+ const capReason = `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`;
+ const addReason =
+ problem !== null ? problem.text : conflict ? "Another listed forward already binds that port there." : undefined;
const addForward = () => {
if (problem !== null || conflict || atCap) return;
onChange([...rows, { ...editor }]);
- // Keep the chosen kind (the next forward is usually the same shape);
- // clear the values, so the editor is never a stale copy of what was added.
+ // The row is committed; fold the editor away. Keep the chosen kind (the
+ // next forward is usually the same shape) and clear the values, so the
+ // editor is never a stale copy of what was added.
setEditor({ ...EMPTY_SSH_FORWARD_ROW, kind: editor.kind });
+ setComposing(false);
};
const helpId = "ssh-fwd-kind-help";
const example = FORWARD_EXAMPLE[editor.kind];
@@ -102,118 +103,140 @@ export function SshForwardFields({
Each forward opens a listening port for as long as this connection lives, and the port closes when the subshell
ends.
- patchEditor({ kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
- >
- {/* The Authentication field's shape: the chosen option carries its
+ {!composing ? (
+ setComposing(true)}
+ >
+
+ Add port forward
+
+ ) : (
+
+
patchEditor({ kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
+ >
+ {/* The Authentication field's shape: the chosen option carries its
caption, route diagram and fields in the column beside the radio,
and the other options stay one line each. */}
- {KINDS.map((option) => {
- const selected = editor.kind === option.value;
- return (
-
-
-
-
- {option.label}
-
- {selected && (
- <>
-
- {KIND_HELP[option.value]}
-
-
{sshForwardRowDiagram(editor)}
-
+ >
+ )}
+
+
+ );
+ })}
+
+ {/* Sentences show once a field is touched, not while it sits pristine. */}
+ {touched && problem && (
+
+ {problem.text}
+
+ )}
+ {touched && conflict && problem === null && (
+
+ Another listed forward already binds that port there. Change the port or the bind, or remove that one.
+
+ )}
+
+
+
+ Add port forward
+
+
setComposing(false)}>
+ Cancel
+
+
+
)}
-
-
- Add port forward
-
- {!allowWideBind && (
+ {!allowWideBind && (composing || rows.length > 0) && (
Forwards bind this machine's own loopback address. Its owner can allow other bind addresses in the node's SSH
settings.
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
index d29f1e2d1..c35b51307 100644
--- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
+++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
@@ -122,6 +122,9 @@ it("saves the local-key radio choice without requiring a file or approval", asyn
it("saves a declared port forward alongside the connection choices", async () => {
const { writes, closed } = await setup();
await waitFor(() => expect(disabled()).toBe(false));
+ // The form only appears once the person asks to add a forward.
+ expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
@@ -140,6 +143,7 @@ it("saves a declared port forward alongside the connection choices", async () =>
it("a half-typed forward never reaches the preset: Add stays off and Save is not held", async () => {
const { writes, closed } = await setup();
await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } });
fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } });
// The incomplete editor row gates its own Add button, not the Save button.
From c572d9a6e071031de6d129eea1d9aae4c3f9bbb2 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:22:48 -0700
Subject: [PATCH 17/35] feat(ssh): the declared forwards read like the dialog's
other lists
The bordered Kind/Listen/Target table was the dashboard idiom dropped
into a form; inside a dialog the app shows declared data as line items
(the sharing dialog's grants are the worked example). Each forward is
now one line: kind in muted words, what ssh will listen on in mono, an
arrow to the target, Remove at the right. No card, no header row.
---
.../components/connect/ssh-forward-fields.tsx | 96 +++++++++----------
1 file changed, 43 insertions(+), 53 deletions(-)
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 74738ad96..c51cc3f98 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -1,6 +1,6 @@
-import { Button, Card, CardContent, cn, Input, Label } from "@internal/node-admin";
+import { Button, cn, Input, Label } from "@internal/node-admin";
import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
-import { Plus, X } from "lucide-react";
+import { Plus } from "lucide-react";
import { useState } from "react";
import { Field, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
@@ -19,10 +19,10 @@ import {
* The port-forwards group (spec 2026-10-15 §7), shaped like the app's other
* add-a-thing lists: the section starts as one "Add port forward" button and
* nothing else. Pressing it reveals the editor for ONE forward; commit puts
- * that row in the table below (enabled only when the row is valid and doesn't
+ * that row in the list below (enabled only when the row is valid and doesn't
* re-bind a listed port) and folds the editor away again, so a person who
* declared nothing never sees a form. The editor is state that never leaves
- * the component until it is added; the table holds what `onChange` commits
+ * the component until it is added; the list holds what `onChange` commits
* and what the launch carries. Forwards die with the pane, and the intro says
* so.
*/
@@ -50,7 +50,7 @@ const KIND_HELP: Record = {
"Opens a SOCKS proxy port on the machine you connect from. Tools pointed at it choose their own targets, each reached through the destination.",
};
-/** The table's Listen cell: what ssh will really bind, spelled plainly. */
+/** A line's Listen part: what ssh will really bind, spelled plainly. */
function listenText(row: SshForwardDraftRow): string {
const port = row.listenPort.trim();
const bind = row.bindHost.trim();
@@ -58,7 +58,7 @@ function listenText(row: SshForwardDraftRow): string {
return row.kind === "remote" ? `${port} (by policy)` : `loopback:${port}`;
}
-/** The table's Target cell; a SOCKS forward has no fixed target to name. */
+/** A line's Target part; a SOCKS forward has no fixed target to name. */
function targetText(row: SshForwardDraftRow): string {
return row.kind === "dynamic" ? "any host the tool picks" : `${row.targetHost.trim()}:${row.targetPort.trim()}`;
}
@@ -243,54 +243,44 @@ export function SshForwardFields({
)}
{rows.length > 0 && (
-
-
-
-
-
-
- Kind
- Listen
- Target
-
-
-
-
- {rows.map((row, index) => (
- // A row's listen identity is unique in this list: the Add
- // button and the server both refuse a port the list already
- // binds, so kind + bind spelling + port cannot repeat.
-
-
- {KINDS.find((option) => option.value === row.kind)?.short ?? row.kind}
-
- {listenText(row)}
-
- {targetText(row)}
-
-
- onChange(rows.filter((_, i) => i !== index))}
- >
-
- Remove
-
-
-
- ))}
-
-
+ // The declared forwards as the dialog's other lists read (the sharing
+ // dialog's grants are the worked example): one line each, the control
+ // right, no card and no header row. What ssh will really listen on
+ // stays monospace; a SOCKS forward names no target, so its ends are
+ // prose.
+
+ {rows.map((row, index) => (
+ // A row's listen identity is unique in this list: the Add button
+ // and the server both refuse a port the list already binds, so
+ // kind + bind spelling + port cannot repeat.
+
+
+
+ {KINDS.find((option) => option.value === row.kind)?.short ?? row.kind}
+
+ ·
+ {listenText(row)}
+ {"→"}
+
+ {targetText(row)}
+
+
+ onChange(rows.filter((_, i) => i !== index))}
+ >
+ Remove
+
-
-
+ ))}
+
)}
);
From 23ce0aa327ce925c0c053eda0ee83f9d5320afe0 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:24:51 -0700
Subject: [PATCH 18/35] feat(ssh): drop the loopback note from the forward
fields
The standing sentence about loopback binds and the owner's wide-forwards
setting sat under every open editor and every list. The bind field's
absence already says loopback-only to anyone who looks for the field,
and the node's SSH settings card carries the full explanation where the
switch actually lives.
---
.../src/components/connect/__tests__/connect-panel.test.tsx | 4 +---
.../web/src/components/connect/ssh-forward-fields.tsx | 6 ------
2 files changed, 1 insertion(+), 9 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 732b46a6b..3c2b56cae 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
@@ -779,9 +779,8 @@ it("carries added port forwards in the launch body, and holds Add until the edit
expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
- // Loopback-only machine: no bind field offered, and the reason is on screen.
+ // Loopback-only machine: no bind field offered.
expect(screen.queryByLabelText(/Bind address/)).toBeNull();
- expect(screen.getByText(/loopback/i)).toBeTruthy();
// Disabled state rides aria-disabled, not the attribute: the node-admin
// Button stays focusable while it explains why Add is held (Base UI).
const addDisabled = () => {
@@ -895,7 +894,6 @@ it("offers the bind address on a machine whose owner allows wide binds", async (
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
- expect(screen.queryByText(/owner can allow other bind addresses/i)).toBeNull();
} finally {
restore();
}
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index c51cc3f98..18254278d 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -236,12 +236,6 @@ export function SshForwardFields({
)}
- {!allowWideBind && (composing || rows.length > 0) && (
-
- Forwards bind this machine's own loopback address. Its owner can allow other bind addresses in the node's SSH
- settings.
-
- )}
{rows.length > 0 && (
// The declared forwards as the dialog's other lists read (the sharing
// dialog's grants are the worked example): one line each, the control
From 6680473e8b9ef6ae4a16eba89b49066b01bc2a88 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:29:01 -0700
Subject: [PATCH 19/35] feat(ssh): say what the wide-forwards switch refuses
The off-state sentence described loopback in the abstract and left the
reader guessing whether turning it on moved every forward to 0.0.0.0.
It states the rule and the permission instead: forwards listen on
127.0.0.1 while off; on lets a forward name another address.
---
apps/server/web/src/components/nodes/node-ssh-card.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx
index 0018777de..9ab9b8e0b 100644
--- a/apps/server/web/src/components/nodes/node-ssh-card.tsx
+++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx
@@ -62,7 +62,7 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R
{node.sshWideForwards
? "Port forwards can bind an address other machines can reach, so this machine routes their traffic into whatever the connection reaches. Turning this off refuses future wide binds; running forwards keep going until their pane closes."
- : "Port forwards bind this machine's own loopback address only, so just programs on this machine can use them. Turn this on to let a forward bind an address other machines can reach."}
+ : "Port forwards here listen on 127.0.0.1 only. Turn this on to let a forward bind another address, such as 0.0.0.0 for every interface."}
{!node.canManage && (
Only this node’s owner can change this setting.
From 8c2862fa354e7f2eb5c9d3540cc304fdb9ca37ba Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:30:49 -0700
Subject: [PATCH 20/35] feat(ssh): state the loopback rule in the forward
section
When the selected machine has not allowed wide binds, the section says
up front that its forwards listen on 127.0.0.1 only and where the owner
can change that, in the same words the node's SSH card uses. The bind
field's absence was the only signal, and it reads as a missing feature
rather than a rule.
---
.../src/components/connect/__tests__/connect-panel.test.tsx | 4 ++++
.../web/src/components/connect/ssh-forward-fields.tsx | 6 ++++++
2 files changed, 10 insertions(+)
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 3c2b56cae..dd5e3f913 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
@@ -777,6 +777,8 @@ it("carries added port forwards in the launch body, and holds Add until the edit
await settle();
// Nobody declared a forward: the section is one button, not a form.
expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
+ // The machine's bind rule is stated up front, not discovered at launch.
+ expect(screen.getByText(/127\.0\.0\.1 only/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
// Loopback-only machine: no bind field offered.
@@ -894,6 +896,8 @@ it("offers the bind address on a machine whose owner allows wide binds", async (
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
+ // The owner already allowed wide binds: no loopback-only sentence.
+ expect(screen.queryByText(/127\.0\.0\.1 only/) === null).toBe(true);
} finally {
restore();
}
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 18254278d..50a35c4d6 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -103,6 +103,12 @@ export function SshForwardFields({
Each forward opens a listening port for as long as this connection lives, and the port closes when the subshell
ends.
+ {!allowWideBind && (
+
+ Forwards on this machine listen on 127.0.0.1 only, so just programs running on it can reach the port. Its
+ owner can allow other bind addresses in the node's SSH settings.
+
+ )}
{!composing ? (
Date: Thu, 15 Oct 2026 19:34:46 -0700
Subject: [PATCH 21/35] feat(ssh): name the switch the loopback rule waits on
The forward section's loopback sentence said the owner "can allow"
something without saying where. It now reads as one condition: until
"Allow wide port forwards" is on in the node's SSH settings, forwards
listen on 127.0.0.1 only. The setting's name carries the weight.
---
.../src/components/connect/__tests__/connect-panel.test.tsx | 4 ++--
apps/server/web/src/components/connect/ssh-forward-fields.tsx | 4 ++--
2 files changed, 4 insertions(+), 4 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 dd5e3f913..b1c59f13d 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
@@ -778,7 +778,7 @@ it("carries added port forwards in the launch body, and holds Add until the edit
// Nobody declared a forward: the section is one button, not a form.
expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
// The machine's bind rule is stated up front, not discovered at launch.
- expect(screen.getByText(/127\.0\.0\.1 only/)).toBeTruthy();
+ expect(screen.getByText(/127\.0\.0\.1/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
await settle();
// Loopback-only machine: no bind field offered.
@@ -897,7 +897,7 @@ it("offers the bind address on a machine whose owner allows wide binds", async (
await settle();
expect(screen.getByLabelText(/Bind address/)).toBeTruthy();
// The owner already allowed wide binds: no loopback-only sentence.
- expect(screen.queryByText(/127\.0\.0\.1 only/) === null).toBe(true);
+ expect(screen.queryByText(/127\.0\.0\.1/) === null).toBe(true);
} finally {
restore();
}
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 50a35c4d6..93bc56a79 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -105,8 +105,8 @@ export function SshForwardFields({
{!allowWideBind && (
- Forwards on this machine listen on 127.0.0.1 only, so just programs running on it can reach the port. Its
- owner can allow other bind addresses in the node's SSH settings.
+ Until the owner turns on "Allow wide port forwards" in this node's SSH
+ settings, every forward here listens on 127.0.0.1 and only local programs can reach the port.
)}
{!composing ? (
From f275b6ab575e6e63118bc5005a8142a0b29c88a9 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:39:59 -0700
Subject: [PATCH 22/35] feat(ssh): say what flipping the wide-forwards switch
does
The on-state sentence leaned on 'wide binds' and a semicolon clause to
explain what a flip reaches. It now names the addresses a forward may
bind, who can use them, and closes with the one fact the switch always
raises: turning it off affects new subshells only.
---
apps/server/web/src/components/nodes/node-ssh-card.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx
index 9ab9b8e0b..5082d037f 100644
--- a/apps/server/web/src/components/nodes/node-ssh-card.tsx
+++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx
@@ -61,7 +61,7 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R
{node.sshWideForwards
- ? "Port forwards can bind an address other machines can reach, so this machine routes their traffic into whatever the connection reaches. Turning this off refuses future wide binds; running forwards keep going until their pane closes."
+ ? "Forwards here are no longer confined to 127.0.0.1. One can bind a LAN address or 0.0.0.0, and any machine that reaches it gets through to the target via the SSH connection. Turning this off affects new subshells only."
: "Port forwards here listen on 127.0.0.1 only. Turn this on to let a forward bind another address, such as 0.0.0.0 for every interface."}
{!node.canManage && (
From 03c66020149a4bf027b697398f0fbfabb9d8c6f0 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 19:42:04 -0700
Subject: [PATCH 23/35] feat(ssh): wear the gold the owner-only rule earns
The loopback sentence names a restriction only the node's owner can
lift, which is exactly what the design system keeps gold: routine
information stays muted, held-back capability does not.
---
apps/server/web/src/components/connect/ssh-forward-fields.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 93bc56a79..d6be129a8 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -104,7 +104,7 @@ export function SshForwardFields({
ends.
{!allowWideBind && (
-
+
Until the owner turns on "Allow wide port forwards" in this node's SSH
settings, every forward here listens on 127.0.0.1 and only local programs can reach the port.
From 2ba938fa151f2c2c4af1e6ea32ba59462ffdf572 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 20:03:48 -0700
Subject: [PATCH 24/35] feat(ssh): the pane header lists where it goes and what
it opened
The SSH tooltip on a pane said only that the connection was SSH. The
pane's ssh column now also stores the rows declared at launch (their own
key; the snapshot's forbidden-null forwards member keeps its meaning),
and the subshell view carries a bounded summary of the column: the
canonical user@host:port and the declared forwards, the same disclosure
the ssh.launch audit row already makes. The header tooltip reads them,
and a pane whose column predates the field or can't be parsed falls
back to the plain sentence rather than showing a half line.
---
.changeset/ssh-port-forwards.md | 2 +-
.claude/rules/security-context.md | 2 +-
apps/server/api/src/api/models.ts | 35 ++++++++++
.../__tests__/subshell-view-ssh.test.ts | 33 ++++++++-
.../__tests__/ssh-pane-summary.test.ts | 70 +++++++++++++++++++
.../api/src/services/ssh-launch.service.ts | 5 ++
.../src/services/subshell-manager.service.ts | 47 ++++++++++++-
.../__tests__/ssh-pane-labels.test.tsx | 32 +++++++++
.../components/connect/ssh-forward-fields.tsx | 9 ++-
.../web/src/components/ssh-pane-labels.tsx | 24 ++++++-
.../src/lib/__tests__/ssh-forwards.test.ts | 31 ++++++++
apps/server/web/src/lib/ssh-forwards.ts | 32 +++++++++
apps/server/web/src/types/subshell.ts | 14 ++++
docs/security.md | 4 +-
14 files changed, 328 insertions(+), 12 deletions(-)
create mode 100644 apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
diff --git a/.changeset/ssh-port-forwards.md b/.changeset/ssh-port-forwards.md
index 9734574c7..2d574d6b0 100644
--- a/.changeset/ssh-port-forwards.md
+++ b/.changeset/ssh-port-forwards.md
@@ -2,4 +2,4 @@
"@internal/server": minor
---
-SSH connections can carry port forwards you declare: local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) rows in the connection form and in SSH presets. Forwards bind the connecting node's loopback address unless its owner turns on **Allow wide port forwards** on the node's SSH card, and a remote forward's bind answers to the destination's own SSH server policy. A forward opens with the subshell and closes when it ends. Forwards that came from the connecting account's SSH configuration files are still refused by name; only rows declared at launch open a port.
+SSH connections can carry port forwards you declare: local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) rows in the connection form and in SSH presets. Forwards bind the connecting node's loopback address unless its owner turns on **Allow wide port forwards** on the node's SSH card, and a remote forward's bind answers to the destination's own SSH server policy. A forward opens with the subshell and closes when it ends; the pane header's SSH tooltip lists the destination and the declared rows for the pane's whole life. Forwards that came from the connecting account's SSH configuration files are still refused by name; only rows declared at launch open a port.
diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md
index 3483d3fcd..881ed82b8 100644
--- a/.claude/rules/security-context.md
+++ b/.claude/rules/security-context.md
@@ -124,7 +124,7 @@ for the route inventory and trust limits. `/api/ssh/*` requires a browser sessio
SSH terminal input additionally requires the pane owner and refuses all bearer
actors. The node SSH switch is owner-only (admin for the built-in node), including
implicit enablement on launch. It controls outgoing SSH and agent-key supply,
-not incoming access. Port forwards are declared per connection, never config-derived (that refusal stands): loopback binds by default, and a non-loopback bind needs the node's owner-only wide-forwards switch (`node.ssh_wide_forwards.update`); remote-forward binds answer to the destination's sshd policy. Relays require access to both nodes; the UI requires key selection, while the
+not incoming access. Port forwards are declared per connection, never config-derived (that refusal stands): loopback binds by default, and a non-loopback bind needs the node's owner-only wide-forwards switch (`node.ssh_wide_forwards.update`); remote-forward binds answer to the destination's sshd policy. The subshell view carries a bounded summary (canonical destination, declared rows) for the pane header: disclosure-only, same class as the audit's. Relays require access to both nodes; the UI requires key selection, while the
cookie API can select the current roster when selection is omitted. Private keys
stay at the source. Bounded configuration scanning does not prevent all local
execution during `ssh -G`; trust the connecting account’s SSH configuration. First-use destination confirmation cannot replace
diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts
index 693032d8b..ad1c19180 100644
--- a/apps/server/api/src/api/models.ts
+++ b/apps/server/api/src/api/models.ts
@@ -1,5 +1,26 @@
import { t } from "elysia";
+/**
+ * One declared port forward as the pane header shows it (spec 2026-10-15):
+ * the row exactly as launch approved it, with the nulls the wire carries.
+ * The renderer for words lives in the web app, not here.
+ */
+export const SshForwardViewSchema = t.Object({
+ kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
+ description: "-L local, -R remote, or -D dynamic SOCKS",
+ }),
+ listenPort: t.Number({ description: "Port the forward listens on (1-65535)" }),
+ bindHost: t.Nullable(
+ t.String({
+ description: "Bind address; null = loopback on a local/dynamic row, the destination's policy on a remote one",
+ }),
+ ),
+ targetHost: t.Nullable(
+ t.String({ description: "Host reached through the connection; null on a dynamic row, which has no fixed target" }),
+ ),
+ targetPort: t.Nullable(t.Number({ description: "Target port; null on a dynamic row" })),
+});
+
/**
* Shared TypeBox schemas for API responses (documented in OpenAPI).
*/
@@ -70,6 +91,20 @@ export const SubshellSchema = t.Object({
ssh: t.Boolean({
description: "True when this pane is an SSH-terminal pane: only its owner can type into it.",
}),
+ sshSummary: t.Optional(
+ t.Object(
+ {
+ destination: t.String({ description: "Canonical user@host:port the pane connected to" }),
+ forwards: t.Array(SshForwardViewSchema, {
+ description: "The port forwards declared at launch, in the order added; empty when none (spec 2026-10-15)",
+ }),
+ },
+ {
+ description:
+ "Bounded view of the SSH connection (canonical destination, declared forward rows) for the pane header. Same disclosure class as the ssh.launch audit summary; absent on a pane whose column can't be read",
+ },
+ ),
+ ),
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
index edbd37d11..e79a130ef 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
@@ -66,9 +66,25 @@ describe("ssh panes in subshell views: the non-owner access downgrade", () => {
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.
+ // fact); this one is valid JSON so the fixture also reads plausibly, and
+ // it carries no destination facts, so it must read as NO summary either.
await ownSubshell("s_ssh", JSON.stringify({ destination: "dev.example.com", node: "local" }));
await ownSubshell("s_plain"); // ordinary pane: ssh NULL
+ // A pane launched with declared forwards: the header summary comes from
+ // exactly these stored bytes (spec 2026-10-15 §6).
+ await ownSubshell(
+ "s_fwd",
+ JSON.stringify({
+ alias: "build",
+ host: "build.example",
+ user: "theo",
+ port: 22,
+ forwards: null,
+ declaredForwards: [
+ { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 },
+ ],
+ }),
+ );
const shares = new SubshellSharesRepository(db);
await shares.replaceForSubshell(
"s_ssh",
@@ -122,6 +138,21 @@ describe("ssh panes in subshell views: the non-owner access downgrade", () => {
expect(got.ssh).toBe(false);
});
+ it("carries the bounded summary: destination and declared rows (owner read)", async () => {
+ const got = await get("/s_fwd", aliceCookie);
+ expect(got.ssh).toBe(true);
+ expect((got as Record).sshSummary).toEqual({
+ destination: "theo@build.example:22",
+ forwards: [{ kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }],
+ });
+ });
+
+ it("an ssh pane whose column can't be read has ssh true and no summary", async () => {
+ const got = await get("/s_ssh", aliceCookie);
+ expect(got.ssh).toBe(true);
+ expect((got as Record).sshSummary).toBeUndefined();
+ });
+
it("admins need a grant to see foreign SSH and ordinary panes", async () => {
const cookie = await signIn(adminEmail, pw);
expect((await req("GET", "/s_ssh", cookie)).status).toBe(404);
diff --git a/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
new file mode 100644
index 000000000..58cc6e5f4
--- /dev/null
+++ b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "bun:test";
+import { sshPaneSummary } from "@/services/subshell-manager.service.js";
+
+/**
+ * The bounded ssh view the pane header reads (spec 2026-10-15 §6): what the
+ * column holds, nothing more, and never a throw over unreadable bytes.
+ */
+const column = {
+ alias: "build",
+ host: "build.example",
+ user: "theo",
+ port: 22,
+ identityFiles: [],
+ certificateFiles: [],
+ authAgentSocket: null,
+ knownHostsFiles: [],
+ hostKeyAlias: null,
+ proxyJumps: [],
+ proxyCommand: null,
+ // The config-derived refusal marker stays null; declared rows are the
+ // sibling key, and the summary reads only that one.
+ forwards: null,
+ tunnels: null,
+};
+
+describe("sshPaneSummary", () => {
+ it("names the canonical destination and lists the declared rows", () => {
+ const summary = sshPaneSummary(
+ JSON.stringify({
+ ...column,
+ declaredForwards: [
+ { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 },
+ { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null },
+ ],
+ }),
+ );
+ expect(summary?.destination).toBe("theo@build.example:22");
+ expect(summary?.forwards).toEqual([
+ { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 },
+ { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null },
+ ]);
+ });
+
+ it("omits the user when the connection used the account default", () => {
+ expect(sshPaneSummary(JSON.stringify({ ...column, user: null }))?.destination).toBe("build.example:22");
+ });
+
+ it("an ssh pane that declared nothing has an empty list, not a missing summary", () => {
+ const summary = sshPaneSummary(JSON.stringify(column));
+ expect(summary?.destination).toBe("theo@build.example:22");
+ expect(summary?.forwards).toEqual([]);
+ });
+
+ it("yields nothing for bytes a list must not trip over", () => {
+ expect(sshPaneSummary(null)).toBeNull();
+ expect(sshPaneSummary("{oops")).toBeNull();
+ expect(sshPaneSummary("[1,2]")).toBeNull();
+ // A column without the destination facts (a fixture, a future shape) shows
+ // no half-built line.
+ expect(sshPaneSummary(JSON.stringify({ destination: "dev.example.com" }))).toBeNull();
+ });
+
+ it("keeps the destination when a stored row list fails its grammar", () => {
+ // A header is not where a stored-row bug gets surfaced: the destination
+ // still earned its place, the broken list is withheld.
+ const summary = sshPaneSummary(JSON.stringify({ ...column, declaredForwards: [{ kind: "sideways" }] }));
+ expect(summary?.destination).toBe("theo@build.example:22");
+ expect(summary?.forwards).toEqual([]);
+ });
+});
diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts
index 442a7bfb1..d06914835 100644
--- a/apps/server/api/src/services/ssh-launch.service.ts
+++ b/apps/server/api/src/services/ssh-launch.service.ts
@@ -584,6 +584,11 @@ export async function sshLaunch(args: {
configPath: composed.configPath,
fileContent: composed.fileContent,
snapshot,
+ // The declared rows ride the pane's column beside the snapshot (spec
+ // 2026-10-15 §6): the launch bytes already carry them, and the pane
+ // header states them for the pane's whole life. They were parsed once
+ // above, so what is recorded is exactly what was approved.
+ ...(forwards.length > 0 ? { forwards } : {}),
...(localKey ? { localKey: true as const } : {}),
// Preserve the actual selected keys, including the complete roster
// resolved for callers that omitted selection. Later acts cannot widen it.
diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts
index b5080b9e3..e5d5a2c8f 100644
--- a/apps/server/api/src/services/subshell-manager.service.ts
+++ b/apps/server/api/src/services/subshell-manager.service.ts
@@ -17,7 +17,9 @@ import {
type NodeProbeEntry,
normalizeLabel,
parseNodeProbeEntries,
+ parseSshForwardRows,
type SshConnectionSnapshotWire,
+ type SshForwardWire,
} from "@internal/subshell-protocol";
import { harnessUsable } from "@/api/harness-utils.js";
import type { PresetsRepository } from "@/db/repositories/presets.repository.js";
@@ -146,6 +148,12 @@ export interface SshLaunchPlumbing {
fileContent: string;
/** The snapshot the resolver approved (grammar-validated at each boundary already). */
snapshot: SshConnectionSnapshotWire;
+ /**
+ * User-declared port forwards (spec 2026-10-15), already parsed. Stored on
+ * the pane so its header can list what the connection carries; absent when
+ * the launch declared none, so the column of a plain pane is unchanged.
+ */
+ forwards?: readonly SshForwardWire[];
/**
* 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"
@@ -506,6 +514,11 @@ export class SubshellManagerService {
ssh: ssh
? JSON.stringify({
...ssh.snapshot,
+ // Its own key, never `forwards`: the spread snapshot carries
+ // `forwards: null` as the config-derived refusal marker, and
+ // every grammar that re-reads this column expects that null.
+ // Declared rows are a different fact and say so in their name.
+ ...(ssh.forwards !== undefined && ssh.forwards.length > 0 ? { declaredForwards: ssh.forwards } : {}),
...(ssh.localKey ? { localKey: true } : {}),
...(ssh.relayFingerprints !== undefined ? { relayFingerprints: ssh.relayFingerprints } : {}),
})
@@ -2235,7 +2248,7 @@ export function toSubshellView(
waitingSince: string | null;
lastPushUrgency: number | null;
crossAgent: number;
- /** The ssh snapshot column (migration 0048); read for presence only, never parsed here. */
+ /** The ssh snapshot column (migration 0048); parsed for the bounded summary only (see {@link sshPaneSummary}). */
ssh: string | null;
},
status: string,
@@ -2256,6 +2269,7 @@ export function toSubshellView(
*/
nodeOffline = false,
) {
+ const sshSummary = row.ssh !== null ? sshPaneSummary(row.ssh) : null;
return {
id: row.id,
presetId: row.presetId,
@@ -2296,10 +2310,39 @@ export function toSubshellView(
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).
+ // 0048). The snapshot itself stays never-serialized (its db-types doc);
+ // what rides along below is the bounded summary, not the snapshot.
ssh: row.ssh !== null,
+ ...(sshSummary !== null ? { sshSummary } : {}),
// Agent node unreachable right now (see the param doc) — the UI's
// "node offline" chip; false for every local subshell.
nodeOffline,
};
}
+
+/**
+ * The bounded view of an ssh pane's column: the canonical destination and the
+ * forward rows declared at launch (spec 2026-10-15 §6), which is the same
+ * disclosure the `ssh.launch` audit row already carries. Best-effort by
+ * construction: a column written before a field existed yields what it has,
+ * and a column that cannot be read yields nothing. A list must never throw
+ * over one row's bytes. Exported for direct test (`ssh-pane-summary.test.ts`).
+ */
+export function sshPaneSummary(raw: string | null): { destination: string; forwards: SshForwardWire[] } | null {
+ if (raw === null) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const column = parsed as Record;
+ if (typeof column.host !== "string" || column.host === "" || typeof column.port !== "number") return null;
+ const user = typeof column.user === "string" && column.user !== "" ? `${column.user}@` : "";
+ const destination = `${user}${column.host}:${String(column.port)}`;
+ // A row list that fails its grammar shows no list: the destination still
+ // earned its place, and a header is not the place to surface a stored-row bug.
+ const forwards = parseSshForwardRows(column.declaredForwards ?? []);
+ return { destination, forwards: forwards ?? [] };
+}
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
index 10c9cadfd..95480173c 100644
--- a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
+++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
@@ -59,3 +59,35 @@ it("explains the SSH icon on keyboard focus", async () => {
await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
await waitFor(() => expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy());
});
+
+it("lists the destination and the declared forwards in the tooltip", async () => {
+ render(
+ ,
+ );
+ await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
+ await waitFor(() => expect(screen.getByText("theo@build.example:22")).toBeTruthy());
+ expect(screen.getByText("Local · loopback:5432 → db.internal:5432")).toBeTruthy();
+ expect(screen.getByText("Dynamic · loopback:1080 → any host the tool picks")).toBeTruthy();
+ // The concrete facts replace the vague sentence, they do not stack on it.
+ expect(screen.queryByText(/connects to another machine/) === null).toBe(true);
+});
+
+it("says so when the connection carries no forwards", async () => {
+ render(
+ ,
+ );
+ await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
+ await waitFor(() => expect(screen.getByText("deploy@host:2222")).toBeTruthy());
+ expect(screen.getByText("No port forwards")).toBeTruthy();
+});
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index d6be129a8..8d95942a6 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -10,9 +10,11 @@ import {
EMPTY_SSH_FORWARD_ROW,
FORWARD_EXAMPLE,
type SshForwardDraftRow,
+ sshForwardListenText,
sshForwardRowConflict,
sshForwardRowDiagram,
sshForwardRowProblem,
+ sshForwardTargetText,
} from "@/lib/ssh-forwards";
/**
@@ -52,15 +54,12 @@ const KIND_HELP: Record = {
/** A line's Listen part: what ssh will really bind, spelled plainly. */
function listenText(row: SshForwardDraftRow): string {
- const port = row.listenPort.trim();
- const bind = row.bindHost.trim();
- if (bind !== "") return `${bind}:${port}`;
- return row.kind === "remote" ? `${port} (by policy)` : `loopback:${port}`;
+ return sshForwardListenText(row.kind, row.listenPort.trim(), row.bindHost.trim());
}
/** A line's Target part; a SOCKS forward has no fixed target to name. */
function targetText(row: SshForwardDraftRow): string {
- return row.kind === "dynamic" ? "any host the tool picks" : `${row.targetHost.trim()}:${row.targetPort.trim()}`;
+ return sshForwardTargetText(row.kind, row.targetHost.trim(), row.targetPort.trim());
}
export function SshForwardFields({
diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx
index db6158933..1299ed624 100644
--- a/apps/server/web/src/components/ssh-pane-labels.tsx
+++ b/apps/server/web/src/components/ssh-pane-labels.tsx
@@ -1,5 +1,6 @@
import { SquareTerminal } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { sshForwardWireLine } from "@/lib/ssh-forwards";
import type { SubshellView } from "@/types/subshell";
/** The header sentence a non-owner of an ssh pane sees (spec 2026-10-07 §5.4). */
@@ -20,6 +21,7 @@ export const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell.
*/
export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) {
if (subshell?.ssh !== true) return null;
+ const summary = subshell.sshSummary;
return (
<>
@@ -27,7 +29,27 @@ export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) {
- This subshell connects to another machine over SSH.
+ {/* Where it goes and what it opened, from the row the launch
+ approved. A pane whose column predates the summary (or cannot be
+ read) falls back to the plain sentence, which is still true. */}
+
+ {summary === undefined ? (
+ "This subshell connects to another machine over SSH."
+ ) : (
+
+
{summary.destination}
+ {summary.forwards.length === 0 ? (
+
No port forwards
+ ) : (
+ summary.forwards.map((row) => (
+
+ {sshForwardWireLine(row)}
+
+ ))
+ )}
+
+ )}
+
{subshell.access === "view" && {SSH_VIEW_ONLY_COPY} }
diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
index a18f43a9c..a51fe06e7 100644
--- a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -8,6 +8,7 @@ import {
sshForwardRowProblems,
sshForwardRowsFromWire,
sshForwardRowsToWire,
+ sshForwardWireLine,
} from "@/lib/ssh-forwards";
/** The form-side mirror of the launch-time refusals (spec 2026-10-15). */
@@ -154,6 +155,36 @@ describe("sshForwardRowsFromWire", () => {
});
});
+describe("sshForwardWireLine", () => {
+ it("spells a stored row like the editor's line does", () => {
+ expect(
+ sshForwardWireLine({
+ kind: "local",
+ listenPort: 5432,
+ bindHost: null,
+ targetHost: "db.internal",
+ targetPort: 5432,
+ }),
+ ).toBe("Local · loopback:5432 → db.internal:5432");
+ expect(
+ sshForwardWireLine({
+ kind: "remote",
+ listenPort: 9090,
+ bindHost: "0.0.0.0",
+ targetHost: "app.local",
+ targetPort: 3000,
+ }),
+ ).toBe("Remote · 0.0.0.0:9090 → app.local:3000");
+ // A remote blank bind rides the destination's policy; dynamic has no target.
+ expect(
+ sshForwardWireLine({ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "a", targetPort: 1 }),
+ ).toBe("Remote · 9090 (by policy) → a:1");
+ expect(
+ sshForwardWireLine({ kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }),
+ ).toBe("Dynamic · loopback:1080 → any host the tool picks");
+ });
+});
+
describe("sshForwardRowDiagram", () => {
it("traces the route with the ports and host the row actually holds", () => {
expect(sshForwardRowDiagram(row({ listenPort: "8080", targetHost: "cache.internal", targetPort: "11211" }))).toBe(
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index 3209a3459..ac2674b01 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -175,6 +175,38 @@ export function sshForwardRowDiagram(row: SshForwardDraftRow): string {
: `program on the destination → ${listen} there → connecting machine → ${host}:${targetPort}`;
}
+/**
+ * The Listen half of a row in words: a named bind spelled as `bind:port`, a
+ * blank on a local or dynamic row is ssh's own loopback, and a blank on a
+ * remote row rides the destination's policy. Inputs are the trimmed strings
+ * the form holds, or wire values stringified by {@link sshForwardWireLine}.
+ */
+export function sshForwardListenText(kind: SshForwardDraftRow["kind"], listenPort: string, bindHost: string): string {
+ if (bindHost !== "") return `${bindHost}:${listenPort}`;
+ return kind === "remote" ? `${listenPort} (by policy)` : `loopback:${listenPort}`;
+}
+
+/** The Target half; a SOCKS forward has no fixed target to name. */
+export function sshForwardTargetText(kind: SshForwardDraftRow["kind"], targetHost: string, targetPort: string): string {
+ return kind === "dynamic" ? "any host the tool picks" : `${targetHost}:${targetPort}`;
+}
+
+/**
+ * One stored (wire) row as the pane header lists it: `Local · loopback:5432 →
+ * db.internal:5432`. The same spellings the editor's table uses, so a person
+ * who declared a row recognizes it in the header.
+ */
+export function sshForwardWireLine(row: SshForwardRow): string {
+ const kind = row.kind === "local" ? "Local" : row.kind === "remote" ? "Remote" : "Dynamic";
+ const listen = sshForwardListenText(row.kind, String(row.listenPort), row.bindHost ?? "");
+ const target = sshForwardTargetText(
+ row.kind,
+ row.targetHost ?? "",
+ row.targetPort === null ? "" : String(row.targetPort),
+ );
+ return `${kind} · ${listen} ${"→"} ${target}`;
+}
+
/** A stored preset's rows back into form spelling. */
export function sshForwardRowsFromWire(rows: readonly SshForwardRow[] | undefined): SshForwardDraftRow[] {
return (rows ?? []).map((row) => ({
diff --git a/apps/server/web/src/types/subshell.ts b/apps/server/web/src/types/subshell.ts
index c391d1394..6e56576d2 100644
--- a/apps/server/web/src/types/subshell.ts
+++ b/apps/server/web/src/types/subshell.ts
@@ -1,6 +1,7 @@
/**
* Shared subshell view model (mirrors the backend toSubshellView shape).
*/
+import type { SshForwardRow } from "@/lib/ssh";
/** Coarse activity heuristic: "active" | "idle" | "terminated". */
export type SubshellActivity = "active" | "idle" | "terminated";
@@ -87,6 +88,19 @@ export interface SubshellView {
access: SubshellAccess;
/** True when this pane is an SSH-terminal pane: only its owner can type into it. */
ssh: boolean;
+ /**
+ * Bounded view of the pane's SSH connection for the header: the canonical
+ * destination and the port-forward rows declared at launch (spec
+ * 2026-10-15). Optional because a payload cached before the field exists
+ * must keep typechecking, and a pane with an unreadable ssh column carries
+ * no summary; the header then says only what it always said.
+ */
+ sshSummary?: {
+ /** Canonical `user@host:port` the pane connected to */
+ destination: string;
+ /** The declared forwards in the order added; empty when the launch carried none */
+ forwards: SshForwardRow[];
+ };
/**
* 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
diff --git a/docs/security.md b/docs/security.md
index 46a612b55..e8cc3eb2b 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1290,7 +1290,9 @@ rejects `LocalForward`, `RemoteForward`, and `DynamicForward` found by `ssh -G`
before the snapshot freezes, and the snapshot keeps them as forbidden members.
Forwards that exist come from rows the user declared at launch (or from the
preset storing them): an explicit kind, port, and target, visible in the form
-and summarized in the launch audit. A local or dynamic forward binds the
+and summarized in the launch audit. The subshell view carries that same bounded
+summary (canonical destination, declared rows) to anyone who can see the pane,
+which is disclosure-only like the pane output that already shows the login. A local or dynamic forward binds the
connecting node's loopback address by default, which that node's other OS users
can reach: §0's accepted posture verbatim, the same class the node's loopback
dashboard already carries. Binding anything wider requires the node's
From 7ce1bf742853cde2c35101cf1b842c503c7d5b9a Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 20:06:50 -0700
Subject: [PATCH 25/35] fix(sidebar): let a machine's plate wrap its cells
The flat grid wrapped between plates, but each plate was an unwrappable
inline-flex row: one machine holding more panes than the rail fits on a
line formed a plate wider than the container, and its last cells ran
off the clipped edge. A plate is now a wrapping flex box, so an
over-long machine cluster continues on its own tinted lines.
---
.../web/src/components/sidebar/subshell-cell-grid.tsx | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx
index 08874682b..9bbfecdff 100644
--- a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx
+++ b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx
@@ -471,9 +471,14 @@ export function SubshellCellGrid({
return (
{runs.map((run) => (
+ // The plate wraps too, and `flex` (not `inline-flex`) is what lets it:
+ // one machine with more panes than the rail fits on a line would
+ // otherwise form a plate wider than the container and run its last
+ // cells off the edge, since a single unwrappable item is the one thing
+ // the outer `flex-wrap` cannot rescue.
{run.cells.map((c) => (
{renderCell(c.sub)}
From ea481bd3aa6df4c322bf58ef8b2581677de5efdc Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 20:14:53 -0700
Subject: [PATCH 26/35] test(server): give the cross-subshell child its own
config home
Boot takes instance-state.lock from the server's config dir. The suite
spawned its backend on the real HOME, so with any dev server already
running the child died on the lock and the file failed red on a busy
machine while passing on an idle one. The child now gets the same temp
sandbox it already had for its database, which is how restore's helper
process and the status tests isolate the config dir too.
---
apps/server/api/src/__tests__/e2e-cross-subshell.test.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts
index 39d556af1..58d83f2d3 100644
--- a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts
+++ b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts
@@ -38,13 +38,18 @@ const dbPath = join(dir, "subshell.db");
const backendLog = join(dir, "backend.log");
/** Env every child process needs: dev NODE_ENV (a test NODE_ENV would force
- * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB. */
+ * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB
+ * + a temp config home. The config home matters as much as the DB: boot takes
+ * `instance-state.lock` in it, and left on the real HOME the child dies
+ * ("Instance state is already in use") against any server the developer has
+ * running — this file would only pass on an idle machine. */
function childEnv(extra: Record = {}): Record {
return {
PATH: process.env.PATH ?? "/usr/bin:/bin",
HOME: process.env.HOME ?? tmpdir(),
NODE_ENV: "development",
DATABASE_PATH: dbPath,
+ SUBSHELL_SERVER_CONFIG_DIR: join(dir, "config"),
...extra,
};
}
From ace72fde0e91845bc5ef3c9f4015b17cd2ed0cfa Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 20:40:08 -0700
Subject: [PATCH 27/35] feat(ssh): the pane tooltip names the machine it
connects from
The header states where the connection goes and what it opened but not
where it runs from. The route's existing nodeLabel (the shared
nodeLabelFor ladder, so the display name never derived from the id)
rides the tooltip's top line; a page still loading the node list, or
answered without the row, shows no line rather than a bare id.
---
.../__tests__/ssh-pane-labels.test.tsx | 10 ++++
.../web/src/components/ssh-pane-labels.tsx | 49 ++++++++++++-------
apps/server/web/src/routes/subshells_.$id.tsx | 2 +-
3 files changed, 43 insertions(+), 18 deletions(-)
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
index 95480173c..60cda90cf 100644
--- a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
+++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
@@ -63,6 +63,7 @@ it("explains the SSH icon on keyboard focus", async () => {
it("lists the destination and the declared forwards in the tooltip", async () => {
render(
);
await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
await waitFor(() => expect(screen.getByText("theo@build.example:22")).toBeTruthy());
+ // The machine it connects FROM rides the top line, in its display name.
+ expect(screen.getByText("mac mini")).toBeTruthy();
expect(screen.getByText("Local · loopback:5432 → db.internal:5432")).toBeTruthy();
expect(screen.getByText("Dynamic · loopback:1080 → any host the tool picks")).toBeTruthy();
// The concrete facts replace the vague sentence, they do not stack on it.
expect(screen.queryByText(/connects to another machine/) === null).toBe(true);
});
+it("names the machine even when the pane carries no summary", async () => {
+ render( );
+ await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
+ await waitFor(() => expect(screen.getByText("Server")).toBeTruthy());
+ expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy();
+});
+
it("says so when the connection carries no forwards", async () => {
render(
,
diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx
index 1299ed624..bbc7008b3 100644
--- a/apps/server/web/src/components/ssh-pane-labels.tsx
+++ b/apps/server/web/src/components/ssh-pane-labels.tsx
@@ -19,7 +19,14 @@ export const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell.
* `=== 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 }) {
+export function SshPaneLabels({
+ subshell,
+ nodeLabel,
+}: {
+ subshell?: SubshellView;
+ /** The pane's machine as the page labels it; null means "a node you can't see", which the tooltip omits. */
+ nodeLabel?: string | null;
+}) {
if (subshell?.ssh !== true) return null;
const summary = subshell.sshSummary;
return (
@@ -33,22 +40,30 @@ export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) {
approved. A pane whose column predates the summary (or cannot be
read) falls back to the plain sentence, which is still true. */}
- {summary === undefined ? (
- "This subshell connects to another machine over SSH."
- ) : (
-
-
{summary.destination}
- {summary.forwards.length === 0 ? (
-
No port forwards
- ) : (
- summary.forwards.map((row) => (
-
- {sshForwardWireLine(row)}
-
- ))
- )}
-
- )}
+
+ {/* The machine it connects FROM, in its display name (labels
+ never derive from the id). The destination line below names
+ the machine it connects TO. */}
+ {nodeLabel !== undefined && nodeLabel !== null && nodeLabel !== "" && (
+
{nodeLabel}
+ )}
+ {summary === undefined ? (
+
This subshell connects to another machine over SSH.
+ ) : (
+ <>
+
{summary.destination}
+ {summary.forwards.length === 0 ? (
+
No port forwards
+ ) : (
+ summary.forwards.map((row) => (
+
+ {sshForwardWireLine(row)}
+
+ ))
+ )}
+ >
+ )}
+
diff --git a/apps/server/web/src/routes/subshells_.$id.tsx b/apps/server/web/src/routes/subshells_.$id.tsx
index e71f6c9f8..8310d4d13 100644
--- a/apps/server/web/src/routes/subshells_.$id.tsx
+++ b/apps/server/web/src/routes/subshells_.$id.tsx
@@ -315,7 +315,7 @@ function SubshellPage() {
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. */}
From 8efff9a03534e2c4e23502fd471521b0f1c535a2 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 21:02:04 -0700
Subject: [PATCH 28/35] fix(ssh): answer the review round on the forward
surfaces
Six MINORs and three NITs from reviewing the redesign range:
- The loopback sentences (launch note and node-card off state) overclaimed
for remote forwards: a blank-bind remote row is allowed with the switch
off and binds under the destination's policy. Both now say so.
- Cancel folds the editor clean instead of hiding a stale half-typed row,
shared with the commit path, and the state transition gets a test.
- sshPaneSummary composes its destination through sshCanonicalDestination,
the function the audit row and recent-destinations key already use,
instead of hand-rolling a second canonical spelling.
- The ssh column's db-types doc states what it actually holds now
(localKey, relayFingerprints, declaredForwards) and that the bounded
summary is derived from it for views.
- SshForwardViewSchema documents why it is its own declaration (the same
inference-depth reason ssh-views states for its flattened copy).
- The nodeLabel prop doc matches what nodeLabelFor actually resolves;
the conflict sentence is one constant shared by tooltip and alert;
the quoted setting name wears the UI's curly quotes; FORWARD_EXAMPLE's
unused dynamic target is emptied rather than left to be picked up.
---
apps/server/api/src/api/models.ts | 8 ++++-
.../api/src/db/types/subshells.db-types.ts | 12 +++++--
.../src/services/subshell-manager.service.ts | 7 ++--
.../connect/__tests__/connect-panel.test.tsx | 22 ++++++++++++
.../components/connect/ssh-forward-fields.tsx | 36 ++++++++++++-------
.../src/components/nodes/node-ssh-card.tsx | 2 +-
.../web/src/components/ssh-pane-labels.tsx | 8 ++++-
apps/server/web/src/lib/ssh-forwards.ts | 5 ++-
8 files changed, 79 insertions(+), 21 deletions(-)
diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts
index ad1c19180..74ad7e776 100644
--- a/apps/server/api/src/api/models.ts
+++ b/apps/server/api/src/api/models.ts
@@ -3,7 +3,13 @@ import { t } from "elysia";
/**
* One declared port forward as the pane header shows it (spec 2026-10-15):
* the row exactly as launch approved it, with the nulls the wire carries.
- * The renderer for words lives in the web app, not here.
+ * The renderer for words lives in the web app, not here. A separate
+ * declaration from `ssh-views.ts`'s input envelope on purpose, same reason
+ * that file documents for its own third copy: this one rides `SubshellSchema`,
+ * which answers from routes that also union agent bodies and responses, and
+ * the expressive input kind-census there has already pushed those unions
+ * around the inference-depth edge once (measured the day `forwards` arrived).
+ * The protocol parser owns the grammar for all of them.
*/
export const SshForwardViewSchema = t.Object({
kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
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 57085432e..37767ee00 100644
--- a/apps/server/api/src/db/types/subshells.db-types.ts
+++ b/apps/server/api/src/db/types/subshells.db-types.ts
@@ -81,9 +81,15 @@ 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` plus optional `relayFingerprints: string[]`
- * recording the exact selected keys for relay panes. Never serialized to
- * clients, the launch answer included. See migration 0048.
+ * `SshConnectionSnapshotWire` plus optional members the launch records
+ * beside it: `localKey`, `relayFingerprints: string[]` (the exact keys a
+ * relay used, never widened later), and `declaredForwards` (the rows the
+ * person declared, spec 2026-10-15 - its own key, because the snapshot's
+ * `forwards: null` is the config-derived refusal marker). The snapshot
+ * itself is never serialized to clients; what the view layer derives FROM
+ * this column is the bounded `sshSummary` (canonical destination + declared
+ * rows, `sshPaneSummary`), the same disclosure class as the ssh.launch
+ * audit row. See migration 0048.
*/
ssh: string | null;
/**
diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts
index e5d5a2c8f..d6525c759 100644
--- a/apps/server/api/src/services/subshell-manager.service.ts
+++ b/apps/server/api/src/services/subshell-manager.service.ts
@@ -61,6 +61,7 @@ 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 { sshCanonicalDestination } from "@/services/ssh-destination.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";
@@ -2339,8 +2340,10 @@ export function sshPaneSummary(raw: string | null): { destination: string; forwa
if (typeof parsed !== "object" || parsed === null) return null;
const column = parsed as Record;
if (typeof column.host !== "string" || column.host === "" || typeof column.port !== "number") return null;
- const user = typeof column.user === "string" && column.user !== "" ? `${column.user}@` : "";
- const destination = `${user}${column.host}:${String(column.port)}`;
+ if (!(column.user === null || typeof column.user === "string")) return null;
+ // The same composer the audit row and the recent-destinations key use, so
+ // the header's "canonical" is one spelling, not a second one that drifts.
+ const destination = sshCanonicalDestination({ host: column.host, port: column.port, user: column.user });
// A row list that fails its grammar shows no list: the destination still
// earned its place, and a header is not the place to surface a stored-row bug.
const forwards = parseSshForwardRows(column.declaredForwards ?? []);
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 b1c59f13d..017341b70 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
@@ -823,6 +823,28 @@ it("carries added port forwards in the launch body, and holds Add until the edit
}
});
+it("Cancel means the half-typed row is gone, not hidden", async () => {
+ const { restore } = await renderPanel({ nodes: [HOST_A] });
+ try {
+ fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" }));
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "8080" } });
+ await settle();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ await settle();
+ expect(screen.queryByLabelText(/Local port/) === null).toBe(true);
+ // Reopening shows nothing stale: what Cancel discarded stays discarded.
+ fireEvent.click(screen.getByRole("button", { name: "Add port forward" }));
+ await settle();
+ expect((screen.getByLabelText(/Local port/) as HTMLInputElement).value).toBe("");
+ expect(screen.queryByRole("alert") === null).toBe(true);
+ } finally {
+ restore();
+ }
+});
+
it("removes an added forward from the table before the launch", async () => {
const { calls, restore } = await renderPanel({
nodes: [HOST_A],
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 8d95942a6..1b5a67997 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -52,6 +52,12 @@ const KIND_HELP: Record = {
"Opens a SOCKS proxy port on the machine you connect from. Tools pointed at it choose their own targets, each reached through the destination.",
};
+/**
+ * The duplicate-listen refusal in one sentence, shared by the Add button's
+ * tooltip and the alert under the editor so the two voices can't drift.
+ */
+const CONFLICT_SENTENCE = "Another listed forward already binds that port there.";
+
/** A line's Listen part: what ssh will really bind, spelled plainly. */
function listenText(row: SshForwardDraftRow): string {
return sshForwardListenText(row.kind, row.listenPort.trim(), row.bindHost.trim());
@@ -82,16 +88,19 @@ export function SshForwardFields({
const conflict = problem === null && touched && sshForwardRowConflict(editor, rows);
const atCap = rows.length >= SSH_MAX_FORWARD_ROWS;
const capReason = `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`;
- const addReason =
- problem !== null ? problem.text : conflict ? "Another listed forward already binds that port there." : undefined;
+ const addReason = problem !== null ? problem.text : conflict ? CONFLICT_SENTENCE : undefined;
+ const foldEditor = () => {
+ // Folding is always a clean slate: the values clear and only the chosen
+ // kind stays. Commit and Cancel share this, so whatever closes the editor
+ // - a row landing or a mind changing - means the next opening shows
+ // nothing stale, and "Cancel" means what it says.
+ setEditor({ ...EMPTY_SSH_FORWARD_ROW, kind: editor.kind });
+ setComposing(false);
+ };
const addForward = () => {
if (problem !== null || conflict || atCap) return;
onChange([...rows, { ...editor }]);
- // The row is committed; fold the editor away. Keep the chosen kind (the
- // next forward is usually the same shape) and clear the values, so the
- // editor is never a stale copy of what was added.
- setEditor({ ...EMPTY_SSH_FORWARD_ROW, kind: editor.kind });
- setComposing(false);
+ foldEditor();
};
const helpId = "ssh-fwd-kind-help";
const example = FORWARD_EXAMPLE[editor.kind];
@@ -104,8 +113,9 @@ export function SshForwardFields({
{!allowWideBind && (
- Until the owner turns on "Allow wide port forwards" in this node's SSH
- settings, every forward here listens on 127.0.0.1 and only local programs can reach the port.
+ Until the owner turns on “Allow wide port forwards” in this node's SSH
+ settings, a forward can only bind 127.0.0.1 on this machine, so just local programs can reach the port. A
+ remote forward binds on the destination instead, under the destination's own policy.
)}
{!composing ? (
@@ -219,9 +229,11 @@ export function SshForwardFields({
{problem.text}
)}
- {touched && conflict && problem === null && (
+ {/* `conflict` is only computed when there's no other problem, so one
+ guard here cannot double-say the row's own sentence. */}
+ {touched && conflict && (
- Another listed forward already binds that port there. Change the port or the bind, or remove that one.
+ {CONFLICT_SENTENCE} Change the port or the bind, or remove that one.
)}
@@ -235,7 +247,7 @@ export function SshForwardFields({
Add port forward
-
setComposing(false)}>
+
Cancel
diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx
index 5082d037f..3afcba648 100644
--- a/apps/server/web/src/components/nodes/node-ssh-card.tsx
+++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx
@@ -62,7 +62,7 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R
{node.sshWideForwards
? "Forwards here are no longer confined to 127.0.0.1. One can bind a LAN address or 0.0.0.0, and any machine that reaches it gets through to the target via the SSH connection. Turning this off affects new subshells only."
- : "Port forwards here listen on 127.0.0.1 only. Turn this on to let a forward bind another address, such as 0.0.0.0 for every interface."}
+ : "Port forwards on this machine listen on 127.0.0.1 only. Turn this on to let a forward bind another address, such as 0.0.0.0 for every interface. Remote forwards bind on the destination under its own policy either way."}
{!node.canManage && (
Only this node’s owner can change this setting.
diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx
index bbc7008b3..3b01072f9 100644
--- a/apps/server/web/src/components/ssh-pane-labels.tsx
+++ b/apps/server/web/src/components/ssh-pane-labels.tsx
@@ -24,7 +24,13 @@ export function SshPaneLabels({
nodeLabel,
}: {
subshell?: SubshellView;
- /** The pane's machine as the page labels it; null means "a node you can't see", which the tooltip omits. */
+ /**
+ * The pane's machine through the page's shared `nodeLabelFor` ladder: the
+ * display name once the nodes read answers, a short id fragment while it has
+ * never answered, and "node you can't see" for a node off the viewer's
+ * list. The tooltip shows whatever the ladder resolved, top line; null or
+ * empty (a caller with nothing to say) simply omits the line.
+ */
nodeLabel?: string | null;
}) {
if (subshell?.ssh !== true) return null;
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index ac2674b01..b6b89aacf 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -153,7 +153,10 @@ export function sshForwardRowsToWire(rows: readonly SshForwardDraftRow[]): SshFo
export const FORWARD_EXAMPLE: Record = {
local: { listen: "5432", host: "db.internal", port: "5432" },
remote: { listen: "9090", host: "app.local", port: "3000" },
- dynamic: { listen: "1080", host: "db.internal", port: "5432" },
+ // No target fields are rendered for a SOCKS forward and its diagram names
+ // no target, so the host and port here are deliberately empty: nothing may
+ // start treating them as a real example.
+ dynamic: { listen: "1080", host: "", port: "" },
};
/**
From c99109c1d7b4f48d471b2d7baea01b0e9a62c2b2 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 21:19:38 -0700
Subject: [PATCH 29/35] fix(ssh): answer the second review round
MINOR: a stored forward list that fails its grammar is now withheld as
null, not folded into []. An empty list is the claim that the connection
opened no ports, and the failed-read-vs-empty distinction the app holds
everywhere else was being rendered as an affirmative absence beside
running ports. The destination still shows; the header stays silent
where the read failed.
NITs: the view-schema comment names the real inference-depth site (the
App-type-wide agent-or-ssh unions that flattened the input copy) rather
than misdescribing SubshellSchema's bare consumers; the gold note says
"the machine you connect from", the file's own vocabulary; and a
half-typed JSDoc line stopped wrapping as "half -typed".
---
apps/server/api/src/api/models.ts | 17 ++++++-------
.../__tests__/ssh-pane-summary.test.ts | 5 ++--
.../src/services/subshell-manager.service.ts | 13 ++++++----
.../__tests__/ssh-pane-labels.test.tsx | 11 +++++++++
.../components/connect/ssh-forward-fields.tsx | 4 ++--
.../web/src/components/ssh-pane-labels.tsx | 24 ++++++++++++-------
apps/server/web/src/lib/ssh-forwards.ts | 6 ++---
apps/server/web/src/types/subshell.ts | 8 +++++--
8 files changed, 57 insertions(+), 31 deletions(-)
diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts
index 74ad7e776..eee4fbd94 100644
--- a/apps/server/api/src/api/models.ts
+++ b/apps/server/api/src/api/models.ts
@@ -4,12 +4,12 @@ import { t } from "elysia";
* One declared port forward as the pane header shows it (spec 2026-10-15):
* the row exactly as launch approved it, with the nulls the wire carries.
* The renderer for words lives in the web app, not here. A separate
- * declaration from `ssh-views.ts`'s input envelope on purpose, same reason
- * that file documents for its own third copy: this one rides `SubshellSchema`,
- * which answers from routes that also union agent bodies and responses, and
- * the expressive input kind-census there has already pushed those unions
- * around the inference-depth edge once (measured the day `forwards` arrived).
- * The protocol parser owns the grammar for all of them.
+ * declaration from `ssh-views.ts`'s input envelope on purpose: every schema
+ * here joins one exported `App` type, and the expressive input kind-census
+ * has already pushed the agent-or-ssh unions in that type past TypeScript's
+ * inference-depth edge once (measured the day `forwards` arrived; it is why
+ * `SshForwardRowInputSchema` is flat). The view copy stays constraint-light
+ * for the same reason. The protocol parser owns the grammar for all of them.
*/
export const SshForwardViewSchema = t.Object({
kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], {
@@ -101,8 +101,9 @@ export const SubshellSchema = t.Object({
t.Object(
{
destination: t.String({ description: "Canonical user@host:port the pane connected to" }),
- forwards: t.Array(SshForwardViewSchema, {
- description: "The port forwards declared at launch, in the order added; empty when none (spec 2026-10-15)",
+ forwards: t.Nullable(t.Array(SshForwardViewSchema, { description: "A declared forward row" }), {
+ description:
+ "The port forwards declared at launch, in the order added; empty when none; null when the pane's stored rows can't be read, which the header must not render as 'none' (spec 2026-10-15)",
}),
},
{
diff --git a/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
index 58cc6e5f4..308912dc8 100644
--- a/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
+++ b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts
@@ -62,9 +62,10 @@ describe("sshPaneSummary", () => {
it("keeps the destination when a stored row list fails its grammar", () => {
// A header is not where a stored-row bug gets surfaced: the destination
- // still earned its place, the broken list is withheld.
+ // still earned its place, and the broken list is WITHHELD as null - never
+ // folded into [], because [] is the claim that no ports were opened.
const summary = sshPaneSummary(JSON.stringify({ ...column, declaredForwards: [{ kind: "sideways" }] }));
expect(summary?.destination).toBe("theo@build.example:22");
- expect(summary?.forwards).toEqual([]);
+ expect(summary?.forwards).toBeNull();
});
});
diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts
index d6525c759..d6daff0d8 100644
--- a/apps/server/api/src/services/subshell-manager.service.ts
+++ b/apps/server/api/src/services/subshell-manager.service.ts
@@ -2329,7 +2329,7 @@ export function toSubshellView(
* and a column that cannot be read yields nothing. A list must never throw
* over one row's bytes. Exported for direct test (`ssh-pane-summary.test.ts`).
*/
-export function sshPaneSummary(raw: string | null): { destination: string; forwards: SshForwardWire[] } | null {
+export function sshPaneSummary(raw: string | null): { destination: string; forwards: SshForwardWire[] | null } | null {
if (raw === null) return null;
let parsed: unknown;
try {
@@ -2344,8 +2344,11 @@ export function sshPaneSummary(raw: string | null): { destination: string; forwa
// The same composer the audit row and the recent-destinations key use, so
// the header's "canonical" is one spelling, not a second one that drifts.
const destination = sshCanonicalDestination({ host: column.host, port: column.port, user: column.user });
- // A row list that fails its grammar shows no list: the destination still
- // earned its place, and a header is not the place to surface a stored-row bug.
- const forwards = parseSshForwardRows(column.declaredForwards ?? []);
- return { destination, forwards: forwards ?? [] };
+ // A row list that fails its grammar is WITHHELD (null), not folded into the
+ // empty list: an empty list is the claim "this pane opened no ports", and a
+ // stored row a tightened rule would now refuse may well have opened them.
+ // Failed reads stay distinct from empty ones; the destination still earned
+ // its place, and a header is not where a stored-row bug gets surfaced.
+ const forwards = column.declaredForwards === undefined ? [] : parseSshForwardRows(column.declaredForwards);
+ return { destination, forwards };
}
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
index 60cda90cf..d1fb85ff3 100644
--- a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
+++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx
@@ -93,6 +93,17 @@ it("names the machine even when the pane carries no summary", async () => {
expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy();
});
+it("says nothing about forwards when the stored rows could not be read", async () => {
+ // A null list is a failed read, not an empty one: the destination stands
+ // and no "No port forwards" claim is made beside it.
+ render(
+ ,
+ );
+ await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus());
+ await waitFor(() => expect(screen.getByText("deploy@host:2222")).toBeTruthy());
+ expect(screen.queryByText("No port forwards") === null).toBe(true);
+});
+
it("says so when the connection carries no forwards", async () => {
render(
,
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index 1b5a67997..e20189aee 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -114,8 +114,8 @@ export function SshForwardFields({
{!allowWideBind && (
Until the owner turns on “Allow wide port forwards” in this node's SSH
- settings, a forward can only bind 127.0.0.1 on this machine, so just local programs can reach the port. A
- remote forward binds on the destination instead, under the destination's own policy.
+ settings, a forward can only bind 127.0.0.1 on the machine you connect from, so just programs there can reach
+ the port. A remote forward binds on the destination instead, under the destination's own policy.
)}
{!composing ? (
diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx
index 3b01072f9..739e05836 100644
--- a/apps/server/web/src/components/ssh-pane-labels.tsx
+++ b/apps/server/web/src/components/ssh-pane-labels.tsx
@@ -58,15 +58,21 @@ export function SshPaneLabels({
) : (
<>
{summary.destination}
- {summary.forwards.length === 0 ? (
- No port forwards
- ) : (
- summary.forwards.map((row) => (
-
- {sshForwardWireLine(row)}
-
- ))
- )}
+ {/* An empty list is the claim "this connection opened no
+ ports" - made only when the pane can make it. A null
+ list (the stored rows can't be read) stays silent: the
+ destination line stands, and a failed read is never
+ rendered as absence. */}
+ {summary.forwards !== null &&
+ (summary.forwards.length === 0 ? (
+ No port forwards
+ ) : (
+ summary.forwards.map((row) => (
+
+ {sshForwardWireLine(row)}
+
+ ))
+ ))}
>
)}
diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts
index b6b89aacf..d06398678 100644
--- a/apps/server/web/src/lib/ssh-forwards.ts
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -146,9 +146,9 @@ export function sshForwardRowsToWire(rows: readonly SshForwardDraftRow[]): SshFo
/**
* The example each kind uses for a still-empty slot. It is the single source
- * of truth for BOTH the field placeholders and the route diagram, so a half
- * -typed row never shows one example in its input and a different one in its
- * diagram. `dynamic` has no target, so its host/port go unused.
+ * of truth for BOTH the field placeholders and the route diagram, so a
+ * half-typed row never shows one example in its input and a different one in
+ * its diagram. `dynamic` has no target, so its host/port are empty.
*/
export const FORWARD_EXAMPLE: Record
= {
local: { listen: "5432", host: "db.internal", port: "5432" },
diff --git a/apps/server/web/src/types/subshell.ts b/apps/server/web/src/types/subshell.ts
index 6e56576d2..2f7fb874b 100644
--- a/apps/server/web/src/types/subshell.ts
+++ b/apps/server/web/src/types/subshell.ts
@@ -98,8 +98,12 @@ export interface SubshellView {
sshSummary?: {
/** Canonical `user@host:port` the pane connected to */
destination: string;
- /** The declared forwards in the order added; empty when the launch carried none */
- forwards: SshForwardRow[];
+ /**
+ * The declared forwards in the order added; empty when the launch carried
+ * none. Null means the stored rows could not be read: absence of data,
+ * not absence of forwards, and the header says nothing rather than "none".
+ */
+ forwards: SshForwardRow[] | null;
};
/**
* How many sharing grants this subshell carries; 0 = private to its owner.
From 0c86fc2afb8494d0852717fa603bb4f7b7ec39e5 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Thu, 15 Oct 2026 22:13:55 -0700
Subject: [PATCH 30/35] docs: showcase SSH forwarding and refresh sharing
controls
---
.changeset/sharing-permission-selectors.md | 5 +
.changeset/ssh-forwarding-guide-screenshot.md | 7 ++
.changeset/website-ssh-sharing-screenshots.md | 5 +
apps/docs/content/docs/guides/sharing.mdx | 8 +-
apps/docs/content/docs/guides/ssh.mdx | 57 ++++++++--
apps/docs/content/docs/nodes/manage.mdx | 2 +-
.../public/screenshots/session-sharing.png | Bin 88434 -> 76445 bytes
.../public/screenshots/ssh-connection.png | Bin 0 -> 252680 bytes
.../__tests__/sharing-dialog.test.tsx | 9 +-
.../src/components/sharing-dialog-core.tsx | 105 ++++++++++++------
apps/website/AGENTS.md | 2 +-
apps/website/components/feature-showcase.tsx | 20 +++-
.../website/public/shots/session-sharing.webp | Bin 48728 -> 40648 bytes
apps/website/public/shots/ssh-connection.webp | Bin 0 -> 121536 bytes
e2e/AGENTS.md | 2 +-
e2e/capture-docs.ts | 19 ++++
16 files changed, 182 insertions(+), 59 deletions(-)
create mode 100644 .changeset/sharing-permission-selectors.md
create mode 100644 .changeset/ssh-forwarding-guide-screenshot.md
create mode 100644 .changeset/website-ssh-sharing-screenshots.md
create mode 100644 apps/docs/public/screenshots/ssh-connection.png
create mode 100644 apps/website/public/shots/ssh-connection.webp
diff --git a/.changeset/sharing-permission-selectors.md b/.changeset/sharing-permission-selectors.md
new file mode 100644
index 000000000..bb43a0eb0
--- /dev/null
+++ b/.changeset/sharing-permission-selectors.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Use aligned permission dropdowns and a styled, searchable recipient picker in subshell and node sharing dialogs. Clear added recipients from the picker and require a valid selection before adding a grant.
diff --git a/.changeset/ssh-forwarding-guide-screenshot.md b/.changeset/ssh-forwarding-guide-screenshot.md
new file mode 100644
index 000000000..ddb2e9c86
--- /dev/null
+++ b/.changeset/ssh-forwarding-guide-screenshot.md
@@ -0,0 +1,7 @@
+---
+"@internal/docs": patch
+---
+
+Expand the SSH port-forwarding guide with connection steps, local and remote examples, access rules, troubleshooting, and a screenshot of the SSH panel.
+
+Refresh the sharing screenshot and instructions for permission dropdowns and the searchable recipient picker.
diff --git a/.changeset/website-ssh-sharing-screenshots.md b/.changeset/website-ssh-sharing-screenshots.md
new file mode 100644
index 000000000..ef3545394
--- /dev/null
+++ b/.changeset/website-ssh-sharing-screenshots.md
@@ -0,0 +1,5 @@
+---
+"@internal/website": patch
+---
+
+Show SSH port forwarding on the homepage with a new connection-panel screenshot, and refresh the sharing screenshot with permission dropdowns and recipient search.
diff --git a/apps/docs/content/docs/guides/sharing.mdx b/apps/docs/content/docs/guides/sharing.mdx
index 2b12f3c28..72ab741a6 100644
--- a/apps/docs/content/docs/guides/sharing.mdx
+++ b/apps/docs/content/docs/guides/sharing.mdx
@@ -12,12 +12,12 @@ Only the owner can change sharing. The recipient needs an account on the same in
## Grant access
1. In the server dashboard's sidebar, choose **Subshells**. Open the **⋯** menu on your session's card or row and choose **Share…**.
-2. Choose named users or **Everyone**.
-3. Select view or edit access.
-4. Save the sharing settings.
+2. In **Search people…**, search by email or name and choose a recipient, or choose **Everyone**.
+3. Select **View** or **View + edit** from the access dropdown, then select **Add**. Repeat for other recipients.
+4. Review the listed recipients and their access levels, then select **Save**.
-
+
Select an access level for each recipient, then save the grants. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx
index c0ae50743..69654fe2b 100644
--- a/apps/docs/content/docs/guides/ssh.mdx
+++ b/apps/docs/content/docs/guides/ssh.mdx
@@ -93,7 +93,7 @@ For these connections, Subshell stores the public host key and destination in th
Connections using the connecting node's own keys or a password use that node's OpenSSH trust records instead. They automatically remember a previously unknown host key and reject a changed one. To verify a destination before that first connection, have the node's owner establish its trusted host key on the node first.
-Once connected, the terminal shows the destination's shell. Its top bar has an **SSH connection** icon with a tooltip identifying it as an SSH connection. If you selected password authentication, wait for its password prompt before typing. Only the owner signed in to the dashboard can type in an SSH subshell; MCP and other bearer credentials cannot send input. Session [sharing](/guides/sharing) lets other users view its output.
+Once connected, the terminal shows the destination's shell. Its top bar has an **SSH connection** icon whose tooltip shows the connecting node, destination, and any port forwards declared for that subshell. If you selected password authentication, wait for its password prompt before typing. Only the owner signed in to the dashboard can type in an SSH subshell; MCP and other bearer credentials cannot send input. Session [sharing](/guides/sharing) lets other users view its output.
## SSH presets and recent destinations
@@ -118,24 +118,57 @@ If you select **Save as preset** while launching and the connection fails, the s
## Port forwards
-Some services, like a database or an admin API, live behind the destination and have no other route. A connection can open a port that reaches them: in the connection form, choose **Advanced SSH options**, then **Add port forward**. Every forward belongs to that connection, and the port closes when the subshell ends.
+Forward ports through an SSH connection to reach a web app, database, or other TCP service. Each listening port lasts for the SSH connection and closes when the subshell ends.
-| Kind | Where the port opens | What it does |
+### Add a port forward
+
+1. In the dashboard, open **Subshells** → **New subshell** → **SSH terminal**. Choose **Connect from**, enter the **SSH destination**, and choose how to authenticate.
+2. Under **Port forwards**, select **Add port forward**.
+3. Choose a kind from the table below. Enter **Local port** or **Remote port** and, for local or remote forwarding, **Target host** and **Target port**.
+4. Select **Add port forward** again to add the completed row to the connection. Repeat for more ports, up to 16 forwards. Use **Remove** to discard a row; to change one, remove it and add its replacement.
+5. Review the listed forwards, then select **Start SSH subshell**. Follow the authentication and destination-verification steps above.
+
+| Kind | Where the port opens | Where the target is reached from |
| --- | --- | --- |
-| **Local** (`-L`) | The connecting node | Programs on that node reach a host the destination can see. |
-| **Remote** (`-R`) | The destination | The destination reaches a host the connecting node can see. The destination's own SSH server configuration decides whether and how it binds the port. |
-| **Dynamic SOCKS** (`-D`) | The connecting node | Tools configured to use the SOCKS proxy pick their own destinations at runtime and reach them through the connection. |
+| **Local (-L)** | The connecting node | The SSH destination. Use it for a web app or database reachable from that destination. |
+| **Remote (-R)** | The SSH destination | The connecting node. Use it to make a service reachable from the destination. Its SSH server decides whether and how the listening port binds. |
+| **Dynamic SOCKS (-D)** | The connecting node | The SSH destination. Configure a tool to use this SOCKS proxy; the tool chooses the target for each request. |
+
+“Local” means the node selected under **Connect from**, which may be a different machine from your browser. Choosing a key from your laptop doesn't move the listening port to your laptop.
+
+
+
+ Review the listed ports beneath the authentication options before connecting. Select the image to enlarge it.
+
+
+### Reach a web app on the destination
+
+Suppose a web app listens on `127.0.0.1:3000` on the SSH destination. Add a **Local (-L)** forward with **Local port** `8080`, **Target host** `127.0.0.1`, and **Target port** `3000`. After connecting, open `http://127.0.0.1:8080` in a browser running on the connecting node. Here, the target's `127.0.0.1` means the SSH destination.
+
+For the reverse direction, choose **Remote (-R)** with **Remote port** `8080` and the same target values. Programs on the SSH destination can then use its port `8080` to reach port `3000` on the connecting node. The target's `127.0.0.1` now means the connecting node.
+
+For **Dynamic SOCKS (-D)**, enter only a **Local port**, such as `1080`, then configure a SOCKS-capable tool on the connecting node to use `127.0.0.1:1080`.
+
+### Bind addresses and access
+
+Local and dynamic forwards bind `127.0.0.1` on the connecting node by default. Programs on that node, including those run by other OS users, can reach the port. Opening the dashboard elsewhere doesn't make that port available there.
+
+> [!warning] Allowing access from other machines
+> A non-loopback bind can let other machines route traffic through your SSH connection to services it can reach. Only enable it when you intend to give those machines that access.
+
+The node's owner can open **Nodes**, select the connecting node, and turn on **Allow wide port forwards** in its **SSH** card while **Allow SSH** is enabled. An administrator manages this for the built-in node on the server host. See [Manage a node](/nodes/manage#outgoing-ssh).
+
+With wide forwards allowed, the form shows **Bind address**. Leaving it blank keeps the default; a value such as `0.0.0.0` requests all IPv4 interfaces. A non-loopback address requires this permission even for a remote forward, and the destination's SSH server still decides how a remote port binds. Turning the setting off blocks future wide-bind launches; existing forwards keep running until their connection ends.
-Fill in a **Local port** (or **Remote port** for a remote forward), and for local and remote kinds the **Target host** and **Target port** you want to reach. A local or dynamic forward can also name a **Bind address**; left blank, the port binds the node's loopback address, so only programs on that node use it.
+### Reuse and troubleshoot forwards
-A non-loopback bind lets other machines route through the port into whatever the connection reaches. That's a decision about the machine, not the connection, so it stays off until the node's owner turns on **Allow wide port forwards** on the node's SSH card. A launch that asks for a wide bind is refused until then; turning the setting off again refuses future wide binds while forwards that are already running keep going until their subshell closes.
+SSH presets save the listed forwards with the other connection choices. You can add them in **Presets** → **New preset** → **New SSH preset**, or select **Save as preset** in the connection form. Applying a preset fills the rows for review before you connect. Editing the preset doesn't change an existing subshell.
-If the port you asked for is already taken on the node, the destination's shell still opens, and the terminal prints SSH's warning that the forward didn't bind.
+If a requested port is already taken, the destination's shell can still open while SSH prints a warning that the forward couldn't bind. Check the terminal and try the service through the listening port; a working shell alone doesn't prove the forward is available. Choose an unused port and start a new SSH subshell if needed.
-A preset carries forwards beside its other connection choices, so connecting from the preset fills the same rows; you can still edit or remove them before you start. The forward rows themselves are not stored anywhere else, and every launch records them in the audit trail with the connection.
+Forwards declared in the connecting node's SSH configuration through `LocalForward`, `RemoteForward`, or `DynamicForward` remain unsupported and cause connection resolution to be refused. Remove those settings from the configuration used by the connection and add the forwards explicitly in Subshell's form or preset instead. Agent forwarding, X11 forwarding, and tunnel devices remain unsupported.
-> [!note]
-> A forward extends the reach your SSH login already grants; it doesn't add a new credential or a new trust. Forwards end with the subshell: to reopen a port after a disconnect, start a new SSH subshell.
+The **SSH connection** tooltip shows the declared forwards, not a live check of their availability. Users who can view the subshell can see this summary, and each launch records a forwarding summary in the audit trail. If a node is too old to support forwards, update the machine named in the error before trying again.
## Make the destination a node
diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx
index a1ffcce16..7f62a83ab 100644
--- a/apps/docs/content/docs/nodes/manage.mdx
+++ b/apps/docs/content/docs/nodes/manage.mdx
@@ -27,7 +27,7 @@ Only the node’s owner can change this setting. An administrator manages it for
You can also enable it when starting an SSH subshell: the connection form explains the change before you submit. See [Connect over SSH](/guides/ssh).
-With SSH allowed, the card gains **Allow wide port forwards**. While it's off, port forwards opened from this node bind the node's own loopback address, so only programs on that node can use them. Turning it on lets a launch bind a port other machines can reach, which routes their traffic through that connection. It's off by default and sits behind the same owner-only gate as **Allow SSH**; turning it off affects new launches, while forwards already running keep going until their subshell closes.
+With SSH allowed, the card gains **Allow wide port forwards**. While it's off, local and dynamic port forwards bind the node's own loopback address, so only programs on that node can use them. Remote forwards listen on the SSH destination under its SSH server's policy. See [Port forwards](/guides/ssh#port-forwards) for examples and bind-address rules. Turning it on lets a launch bind a port other machines can reach, which routes their traffic through that connection. It's off by default and sits behind the same owner-only gate as **Allow SSH**; turning it off affects new launches, while forwards already running keep going until their subshell closes.
## Service and logs
diff --git a/apps/docs/public/screenshots/session-sharing.png b/apps/docs/public/screenshots/session-sharing.png
index 219702a5f6e57b8a60156e4626cd96907ac1bcd9..6a0ce3907ab818d9fd9dd9850f2721be1fdaba17 100644
GIT binary patch
delta 29212
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 VRC`iPoa8r8cUHt1^`7Q{U
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