{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)}
diff --git a/apps/server/web/src/components/split-subshell-button.tsx b/apps/server/web/src/components/split-subshell-button.tsx
index 86f98e585..d9afb7c36 100644
--- a/apps/server/web/src/components/split-subshell-button.tsx
+++ b/apps/server/web/src/components/split-subshell-button.tsx
@@ -77,10 +77,13 @@ export function SplitSubshellButton({ subshell }: { subshell: SubshellView }): J
cannot afford the word). The tooltip carries it, and the dialog it
opens is titled "Split" so the word survives where the choice is
actually made. */}
+ {/* `ghost` like its header neighbours (operator ruling 2026-10-16): the
+ outline border made this one icon-only button read as a different
+ kind of control than the workspace link and menu beside it. */}
setOpen(true)}
>
diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx
index db6158933..739e05836 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). */
@@ -18,8 +19,22 @@ 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 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;
+ const summary = subshell.sshSummary;
return (
<>
@@ -27,7 +42,41 @@ 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. */}
+
+
+ {/* 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}
+ {/* 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)}
+
+ ))
+ ))}
+ >
+ )}
+
+
{subshell.access === "view" && {SSH_VIEW_ONLY_COPY}}
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/components/ui/combobox.tsx b/apps/server/web/src/components/ui/combobox.tsx
index 25eeba458..c3537c9b6 100644
--- a/apps/server/web/src/components/ui/combobox.tsx
+++ b/apps/server/web/src/components/ui/combobox.tsx
@@ -11,6 +11,12 @@ export interface ComboboxOption {
disabled?: boolean;
/** Muted trailing copy on the row (a disabled row's refusal, or a detail line) */
reason?: string;
+ /**
+ * Render `reason` in gold instead of muted: a switch-not-on restriction at
+ * this decision point (an SSH-gated machine's row says "SSH not enabled",
+ * short and gold) rather than something wrong the reader did.
+ */
+ reasonWarning?: boolean;
/** Node shown before the label (aria-hidden — the accessible name stays the label) */
icon?: ReactNode;
/**
@@ -192,7 +198,14 @@ export function SearchableSelect({
{(option: ComboboxOption, index: number) => {
@@ -225,7 +238,13 @@ export function SearchableSelect({
)}
{option.label}
{option.reason ? (
-
+
{option.reason}
) : null}
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..a51fe06e7
--- /dev/null
+++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from "bun:test";
+import {
+ EMPTY_SSH_FORWARD_ROW,
+ type SshForwardDraftRow,
+ sshForwardListProblems,
+ sshForwardRowDiagram,
+ sshForwardRowProblem,
+ sshForwardRowProblems,
+ sshForwardRowsFromWire,
+ sshForwardRowsToWire,
+ sshForwardWireLine,
+} 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("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();
+ 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 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();
+ // 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", () => {
+ 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([]);
+ });
+});
+
+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(
+ "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/__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__/ssh.test.ts b/apps/server/web/src/lib/__tests__/ssh.test.ts
index a00d31f16..02676a075 100644
--- a/apps/server/web/src/lib/__tests__/ssh.test.ts
+++ b/apps/server/web/src/lib/__tests__/ssh.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol";
-import { sshSelectionError } from "@/lib/ssh";
+import { type SshMachineReadiness, sshMachineRowReason, sshSelectionError } from "@/lib/ssh";
/**
* The client-side half of the grant selection rule (spec 2026-10-08 §5.4,
@@ -26,3 +26,46 @@ describe("sshSelectionError (the over-cap hard error)", () => {
expect(error).toContain(String(SSH_MAX_SELECTED_FINGERPRINTS));
});
});
+
+/**
+ * The one-line explanation an unlaunchable machine wears in the launch
+ * pickers (ruling 2026-10-16): gold and short when the machine's own SSH
+ * switch is the sole refusal, the server's full remedy sentences otherwise
+ * (naming only the switch when something ELSE also blocks would mislead).
+ */
+describe("sshMachineRowReason", () => {
+ const machine = (overrides: {
+ sshEnabled: boolean;
+ blockers: { code: string; message: string }[];
+ }): SshMachineReadiness =>
+ ({
+ node: { id: "n1", name: "box", sshEnabled: overrides.sshEnabled },
+ canConnect: overrides.blockers.length === 0,
+ canConfigure: true,
+ blockers: overrides.blockers,
+ }) as unknown as SshMachineReadiness;
+
+ const gateOff = { code: "SSH_GATE_OFF", message: "Outgoing SSH is disabled on this node. Ask its owner." };
+
+ it("says nothing for a machine with no blockers", () => {
+ expect(sshMachineRowReason(machine({ sshEnabled: true, blockers: [] }))).toBeUndefined();
+ });
+
+ it("shortens to gold when the switch is the sole refusal", () => {
+ expect(sshMachineRowReason(machine({ sshEnabled: false, blockers: [gateOff] }))).toEqual({
+ reason: "SSH not enabled",
+ warning: true,
+ });
+ });
+
+ it("keeps the server's sentences when something else blocks too", () => {
+ const also = { code: "NODE_OFFLINE", message: "Start Subshell on this machine to bring it online." };
+ const row = sshMachineRowReason(machine({ sshEnabled: false, blockers: [gateOff, also] }));
+ expect(row).toEqual({ reason: `${gateOff.message} ${also.message}`, warning: false });
+ });
+
+ it("keeps the sentence when SSH_GATE_OFF is not the machine's switch (the server gate, lockdown)", () => {
+ const row = sshMachineRowReason(machine({ sshEnabled: true, blockers: [gateOff] }));
+ expect(row).toEqual({ reason: gateOff.message, warning: false });
+ });
+});
diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts
index 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..d06398678
--- /dev/null
+++ b/apps/server/web/src/lib/ssh-forwards.ts
@@ -0,0 +1,222 @@
+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);
+}
+
+/** 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 {
+ 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 { 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 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 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 = 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);
+ }
+ 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()),
+ }));
+}
+
+/**
+ * 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 are empty.
+ */
+export const FORWARD_EXAMPLE: Record = {
+ local: { listen: "5432", host: "db.internal", port: "5432" },
+ remote: { listen: "9090", host: "app.local", port: "3000" },
+ // 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: "" },
+};
+
+/**
+ * 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}`;
+}
+
+/**
+ * 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) => ({
+ 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..42339488a 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[];
}
/**
@@ -195,6 +217,24 @@ export interface SshMachineReadiness {
blockers: { code: string; message: string }[];
}
+/**
+ * The explanation a machine row carries in the launch pickers. When the
+ * machine's OWN SSH switch is the SOLE refusal, the row says it in three gold
+ * words: the server's full sentence is a paragraph in a one-line row and
+ * truncates mid-word (report 2026-10-16). The sole-cause discipline mirrors
+ * `sshCanEnableOnStart`: with anything else also blocking, naming only the
+ * switch would mislead, so the row keeps the server's joined sentences, which
+ * name the remedies. The whole story reads below the field once a row is
+ * chosen.
+ */
+export function sshMachineRowReason(machine: SshMachineReadiness): { reason: string; warning: boolean } | undefined {
+ const only = machine.blockers.length === 1 ? machine.blockers[0] : undefined;
+ if (only !== undefined && only.code === "SSH_GATE_OFF" && !machine.node.sshEnabled)
+ return { reason: "SSH not enabled", warning: true };
+ if (machine.blockers.length === 0) return undefined;
+ return { reason: machine.blockers.map((b) => b.message).join(" "), warning: false };
+}
+
/** Only a disabled SSH flag may be changed as part of starting a connection. */
export function sshCanEnableOnStart(machine: SshMachineReadiness | undefined): boolean {
return (
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/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. */}
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/apps/server/web/src/types/subshell.ts b/apps/server/web/src/types/subshell.ts
index c391d1394..2f7fb874b 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,23 @@ 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. 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.
* Optional for the same reason `nodeId` is — a payload cached by a client
diff --git a/apps/website/AGENTS.md b/apps/website/AGENTS.md
index 13b5f4f6b..edc456820 100644
--- a/apps/website/AGENTS.md
+++ b/apps/website/AGENTS.md
@@ -84,7 +84,7 @@ and descriptive captions that do not require knowing abbreviations such as PWA.
## Feature screenshots
-The five feature screenshots come from `e2e/capture-docs.ts`, shared with the
+The six feature screenshots come from `e2e/capture-docs.ts`, shared with the
documentation site. Capture at 2× with `e2e/screenshot.ts`, retain lossless WebP
encoding, and use the product-colored screenshot frame tokens. The PNGs already
include a consistent 12px margin; don't add another inset or a modal backdrop.
diff --git a/apps/website/components/feature-showcase.tsx b/apps/website/components/feature-showcase.tsx
index 237d8215b..3c244e989 100644
--- a/apps/website/components/feature-showcase.tsx
+++ b/apps/website/components/feature-showcase.tsx
@@ -27,10 +27,17 @@ const SHOTS = {
sharing: {
name: "session-sharing",
width: 1072,
- height: 768,
- alt: "Subshell sharing dialog with separate View and View + edit permissions",
+ height: 760,
+ alt: "Subshell sharing dialog with permission dropdowns and a searchable recipient picker",
caption: "Choose who can watch and who can type into the subshell.",
},
+ ssh: {
+ name: "ssh-connection",
+ width: 1200,
+ height: 1964,
+ alt: "SSH connection form with a connecting node, destination, authentication choices, and a local port forward",
+ caption: "Choose how to connect and forward ports through the same SSH connection.",
+ },
nodes: {
name: "node-enrollment",
width: 1072,
@@ -188,17 +195,18 @@ export function FeatureShowcase() {
>
Open a remote shell from your dashboard.
-
-
-
+
Connect over SSH and work in the terminal alongside your agents. Use keys from a connected node, choose a
key from your own machine, or enter a password when connecting. Save frequently used connections as SSH
- presets.
+ presets, including port forwards to reach a web app, database, or other service through the connection.
Explore SSH connections
+
+
+
diff --git a/apps/website/public/shots/session-sharing.webp b/apps/website/public/shots/session-sharing.webp
index c115f0fc3..fa913fe71 100644
Binary files a/apps/website/public/shots/session-sharing.webp and b/apps/website/public/shots/session-sharing.webp differ
diff --git a/apps/website/public/shots/ssh-connection.webp b/apps/website/public/shots/ssh-connection.webp
new file mode 100644
index 000000000..74097ac85
Binary files /dev/null and b/apps/website/public/shots/ssh-connection.webp differ
diff --git a/docs/security.md b/docs/security.md
index e124688d7..e8cc3eb2b 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,34 @@ 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. 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
+**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 +2601,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 +2637,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
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..7da8b0119
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md
@@ -0,0 +1,202 @@
+# 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 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
+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.
+- **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
+
+- **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),
+ 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 (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 `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
+
+- 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.
diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md
index 5f85f282f..1e80835cd 100644
--- a/e2e/AGENTS.md
+++ b/e2e/AGENTS.md
@@ -54,7 +54,7 @@ runs discovery against this fake), and no spec ever dials a real IdP.
Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`,
`E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`,
-`E2E_PORT_SSH`, `E2E_PORT_CONNECT`, `E2E_PORT_RELAY`) with the committed
+`E2E_PORT_SSH`, `E2E_PORT_CONNECT`, `E2E_PORT_RELAY`, `E2E_PORT_FORWARDS`) with the committed
defaults unchanged, the escape hatch for two suites running side by side on one
machine, which the defaults collide on.
@@ -133,13 +133,21 @@ asserts recipient-filtered reads.
## SSH container coverage
-`23-connect-flow.spec.ts` requires a running Docker-compatible daemon.
-Testcontainers builds `fixtures/ssh-container/Dockerfile`, publishes sshd on a
-random port, and owns container cleanup. Missing Docker fails setup rather
-than silently skipping coverage. The container creates disposable developer
-and operator accounts for real password login; no host accounts or SSH files
-are changed. Host keys are generated under the test's temporary directory and
-trusted explicitly by the connecting process.
+`23-connect-flow.spec.ts` and `26-ssh-forwarding.spec.ts` require a running
+Docker-compatible daemon. Testcontainers builds
+`fixtures/ssh-container/Dockerfile`, publishes sshd on a random port, and owns
+container cleanup. Missing Docker fails setup rather than silently skipping
+coverage. The container creates disposable developer and operator accounts for
+real password login; no host accounts or SSH files are changed. Host keys are
+generated under the test's temporary directory and trusted explicitly by the
+connecting process. The image also carries `curl`, `iproute2` and `darkhttpd`,
+and its sshd runs `GatewayPorts clientspecified`: spec 26 reads each port
+forward from the side that owns it, and proves each wide bind as an address
+pair - a loopback-bound port does not answer a dial to its machine's
+interface address, the wide one does, destination-side `-R` read from inside
+the container. The dial never crosses from the container to the job's own
+network: on CI the job is itself a container, and docker does not route
+sibling containers across user-defined networks (measured 2026-10-16).
The browser exercises key login, password prompts and retry, terminal I/O,
view-only sharing, and saving named SSH presets for distinct usernames on one host. Password checks
@@ -176,7 +184,7 @@ layout changes. Never point it at the live server.
For the other documentation screenshots, run `bun e2e/capture-docs.ts` after
building the SPA. It uses the same isolated stack with sample presets, prompts,
a workspace, and real terminal sessions running a small temporary project.
-No coding-agent responses are simulated. The script also refreshes the five
+No coding-agent responses are simulated. The script also refreshes the six
marketing-site WebP copies using `ffmpeg`, which must be installed.
Review every capture, update intrinsic dimensions in the referring MDX and
diff --git a/e2e/capture-docs.ts b/e2e/capture-docs.ts
index 62fd77ef5..71e3dfc89 100644
--- a/e2e/capture-docs.ts
+++ b/e2e/capture-docs.ts
@@ -97,6 +97,24 @@ try {
await dialog.getByRole("button", { name: "Close", exact: true }).click();
}
+ const enableSsh = await api.put("/api/nodes/local/ssh-enabled", { data: { on: true } });
+ expect(enableSsh.ok(), await enableSsh.text()).toBe(true);
+ await page.goto("/new?kind=ssh");
+ await expect(dialog.getByRole("button", { name: "SSH terminal", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await page.locator("#ssh-wizard-destination").fill("developer@build.example.com:22");
+ await dialog.getByRole("button", { name: "Add port forward", exact: true }).click();
+ await page.locator("#ssh-fwd-port").fill("8080");
+ await page.locator("#ssh-fwd-target-host").fill("127.0.0.1");
+ await page.locator("#ssh-fwd-target-port").fill("3000");
+ await dialog.getByRole("button", { name: "Add port forward", exact: true }).click();
+ await expect(dialog.getByRole("button", { name: "Remove this forward" })).toBeVisible();
+ // Capture connection choices without opening a connection to the sample address.
+ await capture(page, "ssh-connection", dialog);
+ await close();
+
await page.goto("/presets");
await expect(page.getByRole("button", { name: "Repository review", exact: true })).toBeVisible();
await capture(page, "preset-library", page.locator("main h1"), card.last());
@@ -188,6 +206,7 @@ try {
"preset-library",
"session-sharing",
"node-enrollment",
+ "ssh-connection",
]) {
const encoded = spawnSync("ffmpeg", [
"-v",
diff --git a/e2e/fixtures/ssh-container/Dockerfile b/e2e/fixtures/ssh-container/Dockerfile
index 45085d1d0..7e1957da2 100644
--- a/e2e/fixtures/ssh-container/Dockerfile
+++ b/e2e/fixtures/ssh-container/Dockerfile
@@ -1,4 +1,4 @@
FROM alpine:3.22.2
-RUN apk add --no-cache openssh-server bash && mkdir -p /run/sshd && adduser -D -s /bin/bash developer && adduser -D -s /bin/bash operator
+RUN apk add --no-cache openssh-server bash curl iproute2 darkhttpd && mkdir -p /run/sshd && adduser -D -s /bin/bash developer && adduser -D -s /bin/bash operator
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D", "-e", "-f", "/etc/ssh/sshd_config"]
diff --git a/e2e/fixtures/sshd-container.ts b/e2e/fixtures/sshd-container.ts
index 1eccccde6..ce390591b 100644
--- a/e2e/fixtures/sshd-container.ts
+++ b/e2e/fixtures/sshd-container.ts
@@ -32,6 +32,11 @@ export async function startContainerSshFixture(root: string) {
"PasswordAuthentication yes",
"KbdInteractiveAuthentication no",
"PubkeyAuthentication yes",
+ // The destination's own bind policy (spec 2026-10-15 §6): a remote
+ // forward that names an address may bind it; a blank bind still
+ // lands on loopback. `no` would flatten both to loopback and hide
+ // the distinction spec 26 exists to prove.
+ "GatewayPorts clientspecified",
"UsePAM no",
"StrictModes yes",
"LogLevel VERBOSE",
@@ -82,6 +87,8 @@ export async function startContainerSshFixture(root: string) {
user,
host,
port,
+ /** Run a command INSIDE the destination (spec 26 reads its own forwarded ports from here). */
+ exec: (cmd: string[]) => container.exec(cmd),
password,
hostKeyLine,
trustedKeyPath,
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/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts
index 9b17ef662..b14c40297 100644
--- a/e2e/tests/23-connect-flow.spec.ts
+++ b/e2e/tests/23-connect-flow.spec.ts
@@ -31,7 +31,13 @@ test.skip(
/** The exact strings the page ships (components/connect/* + components/ssh-pane-labels.tsx). */
const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell.";
-const MACHINE_OFF_REASON = "Outgoing SSH is disabled on this node.";
+/**
+ * A gated-off machine's dropdown row (ruling 2026-10-16): short and gold.
+ * The server's full sentence ("Outgoing SSH is disabled on this node.") no
+ * longer fits a one-line row; the decision-point explanation is the notice
+ * this test asserts beside the field below.
+ */
+const MACHINE_OFF_ROW = "SSH not enabled";
/** The seeded control-plane host's display name (seed-local.ts): the picker reads names, not ids. */
const LOCAL_NODE_NAME = "Server";
/** `configGroupLabel(selectedNode.name)` for that row. */
@@ -301,7 +307,7 @@ test("gate off: the form explains enablement on Start", async ({ page }) => {
await page.getByLabel("SSH destination", { exact: true }).fill(SSH_FIXTURE_ALIAS);
await page.getByRole("combobox", { name: "Connect from", exact: true }).click();
const serverRow = page.getByRole("option", { name: /Server/ });
- await expect(serverRow).toContainText(MACHINE_OFF_REASON);
+ await expect(serverRow).toContainText(MACHINE_OFF_ROW);
await serverRow.click();
await expect(page.getByRole("button", { name: "Enable SSH", exact: true })).toHaveCount(0);
await expect(page.getByText("Starting this subshell will enable outgoing SSH on this node.")).toBeVisible();
diff --git a/e2e/tests/26-ssh-forwarding.spec.ts b/e2e/tests/26-ssh-forwarding.spec.ts
new file mode 100644
index 000000000..f6bc7e801
--- /dev/null
+++ b/e2e/tests/26-ssh-forwarding.spec.ts
@@ -0,0 +1,636 @@
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
+import { createServer } from "node:http";
+import { networkInterfaces, tmpdir } from "node:os";
+import path from "node:path";
+import { type APIRequestContext, expect, type PlaywrightWorkerArgs, test } from "@playwright/test";
+import { missingSshBins, SSH_FIXTURE_ALIAS } from "../fixtures/sshd";
+import { startContainerSshFixture } from "../fixtures/sshd-container";
+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 destination is the TESTCONTAINERS sshd: B is this worker's own machine
+ * (or job container), D is a sibling container. Where a row's TARGET lives
+ * follows ssh's own semantics, and with two machines those ends finally
+ * differ: a `-L`/`-D` connection's far end opens at the DESTINATION, so the
+ * marker those two read comes from a tiny `darkhttpd` the spec starts inside
+ * the container; a `-R` connection's far end opens at the CLIENT, so that
+ * row's target is this spec's own in-process marker server. Each listener is
+ * read from the side that owns it:
+ * - `-L`: curl the loopback port on the connecting side and read the marker
+ * the destination's httpd 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 from the same httpd (a proxy that never bound refuses the
+ * connect),
+ * - `-R`: sshd opened this listener ON THE CONTAINER, so the read runs
+ * inside it (a `container.exec` curl) and the bytes come back through the
+ * tunnel from this spec's marker server. The fixture sshd runs
+ * `GatewayPorts clientspecified`, and a row that names no bind address
+ * still lands on loopback (spec §6) - a dial to the container's own
+ * interface address must draw a refusal on that port.
+ *
+ * Then the wide-bind gate as a door, not a copy: wide rows are 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 both
+ * wide binds, and each escape is proved where a loopback bind cannot follow:
+ * the wide `-L` answers a dial to this machine's INTERFACE address (a socket
+ * bound to 127.0.0.1 does not exist at that address, whoever dials it), and
+ * the wide `-R` answers the same kind of dial INSIDE the destination container
+ * at the container's interface address. That dial from the worker, not from
+ * the container, is measured CI doctrine: the e2e job itself runs in a
+ * container, and sibling testcontainers do not route to the job's network.
+ * The audit row's bounded forwards summary is checked beside the bytes it
+ * describes.
+ *
+ * Like spec 23, the container legs need a Docker daemon: with none, beforeAll
+ * fails rather than quietly skipping. 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";
+
+/**
+ * Ports sshd binds INSIDE the destination container for the `-R` legs. They
+ * are not probed for freedom like the B-side ports are: the container's
+ * network namespace is freshly started for this spec and holds nothing but
+ * sshd and these forwards, so a clash would mean the fixture itself broke.
+ */
+const DEST_BLANK_R_PORT = 18801;
+const DEST_WIDE_R_PORT = 18802;
+
+/**
+ * The container's own marker server, started below with `darkhttpd`. `-L`
+ * and `-D` rows open their far end ON THE DESTINATION, so this is the target
+ * they point at (loopback inside the container).
+ */
+const DEST_MARKER_PORT = 8080;
+
+// The client-side binaries only: the destination now runs in the container,
+// so this host's own sshd is no longer something this spec needs to find.
+const missing = missingSshBins().filter((bin) => bin !== "sshd");
+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));
+ });
+}
+
+/**
+ * This machine's first routable interface address - the address a wide `-L`
+ * bind is dialed AT. A socket bound to 127.0.0.1 simply does not exist there,
+ * so "answers at this address" is the bind-scope proof no matter who dials.
+ * The dialer is deliberately NOT the destination container: the CI e2e job
+ * runs in its own docker network, and sibling testcontainers cannot route to
+ * a container on a different user-defined network (measured on CI, 2026-10-16
+ * - the container-to-job dial gets connection refused). The worker dialing
+ * its own interface address tests exactly the same socket fact from the same
+ * namespace, in both topologies.
+ */
+function connectingMachineAddress(): string {
+ for (const entries of Object.values(networkInterfaces())) {
+ for (const entry of entries ?? []) {
+ if (!entry.internal && entry.address.split(".").length === 4) return entry.address;
+ }
+ }
+ throw new Error("no routable IPv4 interface to dial a wide-bound port at");
+}
+
+/** What ssh printed into the pane, plus what actually LISTENs, for a leg that timed out. */
+function forwardDiagnostics(inst: ForwardInstance, id: string, port: number): string {
+ const parts: string[] = [];
+ try {
+ parts.push(`pane tail: ${readFileSync(path.join(inst.dataDir, "subshells", `${id}.log`), "utf8").slice(-1500)}`);
+ } catch {
+ parts.push("pane: (no log)");
+ }
+ // A host without iproute2 answers with no stdout at all; the diagnostics
+ // must not throw over the real failure's message it exists to carry.
+ const ss = spawnSync("ss", ["-ltn"], { encoding: "utf8" });
+ parts.push(
+ `listeners on ${String(port)}: ${
+ (ss.stdout ?? "")
+ .split("\n")
+ .filter((l) => l.includes(`:${String(port)}`))
+ .join(" | ") || "none (or ss unavailable)"
+ }`,
+ );
+ return parts.join("\n");
+}
+
+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: Awaited> | undefined;
+ let fixtureRoot: string | undefined;
+ let agentPid: number | undefined;
+ let adminRef: APIRequestContext | undefined;
+ let targetPort = 0;
+ /** The container's own non-loopback address, read from inside it once at boot. */
+ let destAddress = "";
+ const livePaneIds: string[] = [];
+
+ /** Curl run INSIDE the destination container: the reader for every port sshd bound there. */
+ async function destFetch(url: string): Promise {
+ const fx = fixture;
+ if (!fx) throw new Error("beforeAll did not run");
+ const res = await fx.exec(["sh", "-c", `curl -s --max-time 10 "${url}"`]);
+ return res.exitCode === 0 ? res.output : null;
+ }
+
+ test.beforeAll(async ({ playwright }) => {
+ fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-fwdtier-"));
+ fixture = await startContainerSshFixture(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}`);
+
+ // The address the container itself answers on: destination-side dials
+ // aim there, and a loopback bind cannot be reached at it.
+ const addrProbe = await fixture.exec(["ip", "-4", "-o", "addr", "show", "dev", "eth0"]);
+ destAddress = addrProbe.output.match(/inet (\d+\.\d+\.\d+\.\d+)\//)?.[1] ?? "";
+ if (destAddress === "") throw new Error(`container eth0 address unparseable: ${addrProbe.output}`);
+
+ // The far end `-L`/`-D` open at: darkhttpd inside the container serving
+ // the same marker this spec's own server answers with. It runs because
+ // the forwarded connection's target host is resolved THERE, not on the
+ // connecting side - with B and D finally apart, that distinction is part
+ // of what the spec proves. The daemonizing `&` sits INSIDE a brace group:
+ // a trailing `&` would background the whole `&&` chain and make sh exit 0
+ // whatever mkdir or printf does, so the exit-code guard below would
+ // check nothing. With the group, only the server is backgrounded and the
+ // guard sees a failed setup; the poll behind it still proves it answers.
+ const httpd = await fixture.exec([
+ "sh",
+ "-c",
+ `mkdir -p /var/www && printf '${MARKER.trim()}\\n' > /var/www/index.html && { nohup darkhttpd /var/www --port ${String(DEST_MARKER_PORT)} >/dev/null 2>&1 & }`,
+ ]);
+ if (httpd.exitCode !== 0) throw new Error(`container marker httpd failed to start: ${httpd.output}`);
+ // Prove it answers BEFORE any leg depends on it: a silent background
+ // daemon failure would otherwise surface as a forward that "never
+ // answered" and send the next reader hunting for a bug in ssh.
+ await pollUntil(
+ "the container's own marker httpd never answered",
+ async () => (await destFetch(`http://127.0.0.1:${String(DEST_MARKER_PORT)}/`)) === MARKER,
+ 15_000,
+ );
+
+ 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 the `-R` rows point at: a `-R` connection's far end
+ // opens on the CLIENT side, so sshd's forwarded connection lands here.
+ // (The `-L`/`-D` rows point at the container's own httpd instead.)
+ 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 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: DEST_MARKER_PORT,
+ },
+ { kind: "dynamic", listenPort: socksListen, bindHost: null, targetHost: null, targetPort: null },
+ {
+ kind: "remote",
+ listenPort: DEST_BLANK_R_PORT,
+ 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(DEST_MARKER_PORT)}`);
+ expect(config).toContain(`DynamicForward 127.0.0.1:${String(socksListen)}`);
+ expect(config).toContain(`RemoteForward ${String(DEST_BLANK_R_PORT)} 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.
+ try {
+ await pollUntil(
+ () => `-L forward on 127.0.0.1:${String(localListen)} never answered`,
+ async () => (await fetchText(`http://127.0.0.1:${String(localListen)}/`)) === MARKER,
+ );
+ } catch (err) {
+ throw new Error(`${String(err)}\n${forwardDiagnostics(inst, subshellId, localListen)}`);
+ }
+ // ...and the loopback-bound port does NOT exist at the machine's interface
+ // address: the same dial the wide bind answers in test 2 is refused here.
+ // Bind scope, shown as a pair on one machine, no routing assumptions.
+ expect(await fetchText(`http://${connectingMachineAddress()}:${String(localListen)}/`)).toBeNull();
+ // -D: through the SOCKS listener; the chosen target resolves where a
+ // SOCKS-over-ssh proxy resolves it - on the destination - so it names
+ // the container's httpd.
+ await pollUntil(
+ () => `-D SOCKS listener on 127.0.0.1:${String(socksListen)} never proxied`,
+ async () => (await socksFetch(socksListen, `http://127.0.0.1:${String(DEST_MARKER_PORT)}/`)) === MARKER,
+ );
+ // -R: sshd's listener ON THE CONTAINER; the bytes come back through the
+ // tunnel to this spec's marker server. Read from inside D, because D owns
+ // the port and this spec is no longer on D's loopback.
+ await pollUntil(
+ () => `-R forward on the destination never answered on ${String(DEST_BLANK_R_PORT)}`,
+ async () => (await destFetch(`http://127.0.0.1:${String(DEST_BLANK_R_PORT)}/`)) === MARKER,
+ );
+ // The blank bind rides the destination's default, and under
+ // `GatewayPorts clientspecified` that default is loopback: the SAME port
+ // refuses a dial to the container's interface address. One machine
+ // answers, the other address does not - the bind really is narrow.
+ expect(await destFetch(`http://${destAddress}:${String(DEST_BLANK_R_PORT)}/`)).toBeNull();
+
+ // 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(DEST_MARKER_PORT)}` },
+ { kind: "dynamic", listen: `127.0.0.1:${String(socksListen)}` },
+ { kind: "remote", listen: String(DEST_BLANK_R_PORT), 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 || !fixture) throw new Error("beforeAll did not run");
+ const fx = fixture;
+ const inst = instance;
+ if (!inst) throw new Error("no backend instance");
+
+ const wideListen = await freePort();
+ const deniedRows = [
+ {
+ kind: "local",
+ listenPort: wideListen,
+ bindHost: "0.0.0.0",
+ targetHost: "127.0.0.1",
+ targetPort: DEST_MARKER_PORT,
+ },
+ ];
+
+ // 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: deniedRows },
+ });
+ 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, plus the remote twin, so the wide escape
+ // gets proved on BOTH machines the feature touches.
+ const launch = await admin.post("/api/ssh/launch", {
+ data: {
+ node: "local",
+ destination: SSH_FIXTURE_ALIAS,
+ name: "e2e-fwd-wide-accepted",
+ forwards: [
+ ...deniedRows,
+ {
+ kind: "remote",
+ listenPort: DEST_WIDE_R_PORT,
+ bindHost: "0.0.0.0",
+ 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);
+
+ const configPath = path.join(inst.dataDir, "ssh", subshellId, "config");
+ await pollUntil(`the wide-bind config never appeared at ${configPath}`, () => existsSync(configPath));
+ const config = readFileSync(configPath, "utf8");
+ expect(config).toContain(`LocalForward 0.0.0.0:${String(wideListen)} 127.0.0.1:${String(DEST_MARKER_PORT)}`);
+ expect(config).toContain(`RemoteForward 0.0.0.0:${String(DEST_WIDE_R_PORT)} 127.0.0.1:${String(targetPort)}`);
+
+ // This is the second ssh session against the container; the forwards bind
+ // only once its shell is up.
+ await pollUntil(
+ () => `the second sshd session never reached its shell for ${subshellId}`,
+ () => (readFileSync(fx.logPath, "utf8").match(/Starting session: shell/g) ?? []).length >= 2,
+ READY_TIMEOUT,
+ );
+
+ // The B side still answers its own loopback curl (unchanged from before
+ // the switch - the wide bind is an ADDITION, not a replacement).
+ await pollUntil(
+ () => `wide forward on ${String(wideListen)} never answered locally`,
+ async () => (await fetchText(`http://127.0.0.1:${String(wideListen)}/`)) === MARKER,
+ );
+ // THE connecting-side escape: the wide-bound listener answers a dial to
+ // this machine's interface address, where a 127.0.0.1 bind does not exist
+ // at all. Pre-switch this port bound loopback only, and test 1 dials the
+ // loopback-bound -L port at this same address to show it refused.
+ await pollUntil(
+ () =>
+ `the wide -L port never answered at the interface address ${connectingMachineAddress()}:${String(wideListen)}`,
+ async () => (await fetchText(`http://${connectingMachineAddress()}:${String(wideListen)}/`)) === MARKER,
+ );
+ // And the wide -R escape on the destination's own side: the same kind of
+ // dial that refused the blank bind now answers, at the container's
+ // interface address.
+ await pollUntil(
+ () => `the wide -R port never answered at the container's interface address`,
+ async () => (await destFetch(`http://${destAddress}:${String(DEST_WIDE_R_PORT)}/`)) === MARKER,
+ );
+ });
+});
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,
+ },
};
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).
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");
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..00303a51b
--- /dev/null
+++ b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts
@@ -0,0 +1,194 @@
+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 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("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 }),
+ 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();
+ 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",
+ // 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);
+ });
+ }
+});
+
+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..bbbe27d74 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,16 @@ export {
SSH_ERROR_DESCRIPTIONS,
type SshErrorCode,
} from "./ssh-errors.js";
+export {
+ isLoopbackForwardBind,
+ isSshForwardKind,
+ parseSshForwardRows,
+ SSH_FORWARD_KINDS,
+ SSH_FORWARD_LIST_REFUSAL,
+ 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..7fad3c6fe
--- /dev/null
+++ b/packages/subshell-protocol/src/ssh-forwards.ts
@@ -0,0 +1,183 @@
+/**
+ * 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 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",
+ "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 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
+ */
+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;
+ // 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 = `${side}|${bindKey}|${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 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);
+}