Repository navigation
feat(ssh): port forwards declared per connection - #388
Merged
Merged
Conversation
The backup engine refuses a snapshot carrying migrations it does not know, and the era fixture strips the ledger tail that the pin names: advancing the pin without moving the strip set leaves an intermediate state where the restored snapshot keeps the newer DDL under a re-applied older name. One commit is the fixture's own rule. Co-Authored-By: Claude <noreply@anthropic.com>
…changesets Co-Authored-By: Claude <noreply@anthropic.com>
…e2e forward legs Round-two review of spec 2026-10-15: a null bind on a local or dynamic row renders exactly what a written 127.0.0.1 renders, so the grammar's duplicate rule now sees them as one bind (mirrored in the form); the loopback test now bounds each 127/8 octet; each forward row carries its own kind explanation where the port lives (spec §7); the preset schema's clear sentence names the editor's real empty-list path, with a test for the branch; and the spec §9 E2E legs ship - real -L, -D, and -R listeners, the wide-bind door before and after the switch, and the rendered file byte-checked without the clear. Co-Authored-By: Claude <noreply@anthropic.com>
…r row copy The three review nits worth shipping: the duplicate-listen rule keys the listen end per SIDE (a port a local/dynamic row binds on the connecting machine beside a remote row's port on the destination is two listeners), the form keys ports numerically so a written-twice port is caught inline, the launch and preset-save 400s share one sentence exported beside the grammar, and the row help names the two ends instead of trusting a bare "this machine" to read right on a remote-node launch. The spec's E2E test line now matches what §5 ships: with approved rows, the Host * clear is gone. Co-Authored-By: Claude <noreply@anthropic.com>
"the rowless Host * clear" named the render the clear belongs to in one compressed word; the sentence now says which line disappears and when. Co-Authored-By: Claude <noreply@anthropic.com>
The kind becomes a three-way radio with the chosen kind's explanation under
the row (the checked radio points AT it for screen readers), replacing the
one-paragraph description of all three. A row's validation sentence now
follows the ruling 2026-09-30 split: an empty required field shows gold
("nothing typed yet"), a typed value that fails the rule stays red.
Co-Authored-By: Claude <noreply@anthropic.com>
…tication Drop the "Kind" heading: the radio group is labelled "Forward kind" for assistive tech and stands on its own. The chosen option now carries its caption and its port and target fields in the column beside the radio - the same shape as the Authentication choice, where picking "Use another node's keys" reveals the key controls beneath it. Co-Authored-By: Claude <noreply@anthropic.com>
The prose names the ends; a mono line traces one request's path with the example values from the field placeholders (5432, db.internal), so the direction is legible at a glance without reading the sentence first. Co-Authored-By: Claude <noreply@anthropic.com>
The forward fields were a growable list where an incomplete row sat in the same column as complete ones and gated the whole launch. They're now an editor plus a table: the person composes one forward under the chosen kind, Add port forward lights up only when that row is valid, doesn't re-bind a listed port, and the list has room, and what lands shows in a table with its plain-spelled bind and target plus a Remove button. Half-typed text never reaches the draft, so Save and Start are no longer held by an abandoned row. The diagram under each kind now traces the ports and host actually typed, with the kind's example (FORWARD_EXAMPLE, one source for the placeholders and the diagram) standing in for still-empty slots. sshForwardRowConflict is the Add-side mirror of the server's duplicate bind refusal, and the table keys rows on their listen identity because the list can't hold a pair the server would refuse.
The port-forwards group opened with a full editor a person who wants zero forwards still has to scroll past. It now follows the app's add-a-thing lists: the section is one "Add port forward" button, the press reveals the editor, committing adds the row and folds it away (the kind sticks, the values clear), and Cancel backs out. Half-typed text discarded by Cancel is still form state that never reached the draft, so nothing else changes below the component.
The bordered Kind/Listen/Target table was the dashboard idiom dropped into a form; inside a dialog the app shows declared data as line items (the sharing dialog's grants are the worked example). Each forward is now one line: kind in muted words, what ssh will listen on in mono, an arrow to the target, Remove at the right. No card, no header row.
The standing sentence about loopback binds and the owner's wide-forwards setting sat under every open editor and every list. The bind field's absence already says loopback-only to anyone who looks for the field, and the node's SSH settings card carries the full explanation where the switch actually lives.
The off-state sentence described loopback in the abstract and left the reader guessing whether turning it on moved every forward to 0.0.0.0. It states the rule and the permission instead: forwards listen on 127.0.0.1 while off; on lets a forward name another address.
When the selected machine has not allowed wide binds, the section says up front that its forwards listen on 127.0.0.1 only and where the owner can change that, in the same words the node's SSH card uses. The bind field's absence was the only signal, and it reads as a missing feature rather than a rule.
The forward section's loopback sentence said the owner "can allow" something without saying where. It now reads as one condition: until "Allow wide port forwards" is on in the node's SSH settings, forwards listen on 127.0.0.1 only. The setting's name carries the weight.
The on-state sentence leaned on 'wide binds' and a semicolon clause to explain what a flip reaches. It now names the addresses a forward may bind, who can use them, and closes with the one fact the switch always raises: turning it off affects new subshells only.
The loopback sentence names a restriction only the node's owner can lift, which is exactly what the design system keeps gold: routine information stays muted, held-back capability does not.
The SSH tooltip on a pane said only that the connection was SSH. The pane's ssh column now also stores the rows declared at launch (their own key; the snapshot's forbidden-null forwards member keeps its meaning), and the subshell view carries a bounded summary of the column: the canonical user@host:port and the declared forwards, the same disclosure the ssh.launch audit row already makes. The header tooltip reads them, and a pane whose column predates the field or can't be parsed falls back to the plain sentence rather than showing a half line.
The flat grid wrapped between plates, but each plate was an unwrappable inline-flex row: one machine holding more panes than the rail fits on a line formed a plate wider than the container, and its last cells ran off the clipped edge. A plate is now a wrapping flex box, so an over-long machine cluster continues on its own tinted lines.
Boot takes instance-state.lock from the server's config dir. The suite spawned its backend on the real HOME, so with any dev server already running the child died on the lock and the file failed red on a busy machine while passing on an idle one. The child now gets the same temp sandbox it already had for its database, which is how restore's helper process and the status tests isolate the config dir too.
The header states where the connection goes and what it opened but not where it runs from. The route's existing nodeLabel (the shared nodeLabelFor ladder, so the display name never derived from the id) rides the tooltip's top line; a page still loading the node list, or answered without the row, shows no line rather than a bare id.
Six MINORs and three NITs from reviewing the redesign range: - The loopback sentences (launch note and node-card off state) overclaimed for remote forwards: a blank-bind remote row is allowed with the switch off and binds under the destination's policy. Both now say so. - Cancel folds the editor clean instead of hiding a stale half-typed row, shared with the commit path, and the state transition gets a test. - sshPaneSummary composes its destination through sshCanonicalDestination, the function the audit row and recent-destinations key already use, instead of hand-rolling a second canonical spelling. - The ssh column's db-types doc states what it actually holds now (localKey, relayFingerprints, declaredForwards) and that the bounded summary is derived from it for views. - SshForwardViewSchema documents why it is its own declaration (the same inference-depth reason ssh-views states for its flattened copy). - The nodeLabel prop doc matches what nodeLabelFor actually resolves; the conflict sentence is one constant shared by tooltip and alert; the quoted setting name wears the UI's curly quotes; FORWARD_EXAMPLE's unused dynamic target is emptied rather than left to be picked up.
MINOR: a stored forward list that fails its grammar is now withheld as null, not folded into []. An empty list is the claim that the connection opened no ports, and the failed-read-vs-empty distinction the app holds everywhere else was being rendered as an affirmative absence beside running ports. The destination still shows; the header stays silent where the read failed. NITs: the view-schema comment names the real inference-depth site (the App-type-wide agent-or-ssh unions that flattened the input copy) rather than misdescribing SubshellSchema's bare consumers; the gold note says "the machine you connect from", the file's own vocabulary; and a half-typed JSDoc line stopped wrapping as "half -typed".
Move spec 26 onto the Testcontainers sshd so B and D are finally apart. That let each leg prove what one-host fixtures could only gesture at: a -L/-D row's far end opens at the DESTINATION (darkhttpd in the container serves the marker), a -R row's at the client, a blank -R bind answers a loopback dial and refuses the container's interface address under GatewayPorts clientspecified, and a wide -L bind is reached BY the destination container dialing this machine's interface address. Fixture gains GatewayPorts clientspecified, exec passthrough, and curl/iproute2/darkhttpd in the image; the spec self-checks the marker server before any leg depends on it.
Two ends of the same unreadability, from the SSH launch report: - The combobox popup had a min-w anchor rule and no max, so one long muted reason stretched the dropdown past the field and the dialog. Both bounds now sit on the anchor width; rows truncate inside it. - A machine's row says "SSH not enabled" in gold when its own SSH switch is the sole refusal, instead of a server paragraph cut to "An adm...". Multi-cause refusals keep the full sentences (naming only the switch when something else also blocks would mislead), and the whole story still reads under the field once a row is chosen.
The only outline-variant icon in a row of ghost ones (workspace link, actions menu), which made it read as a different kind of control than it is.
CI's e2e caught what dev could not: the job is itself a container, and a sibling testcontainer cannot route to it across user-defined docker networks. The wide -L escape now reads as an address pair on the connecting side too - the loopback-bound -L port refuses a dial to this machine's interface address (test 1), the wide one answers the same dial (test 2) - which proves the same bind-scope fact in both topologies. Spec 23's row expectation follows the new picker copy: "SSH not enabled" in gold. From the review round: the marker httpd's daemonizing & moved inside a brace group so the exit-code guard sees a failed setup (a trailing & backgrounds the whole chain and exits 0 no matter what), the ss diagnostic no longer throws over its own message on a host without iproute2, and the picker/split-button work gets its missing @internal/server changeset. e2e's AGENTS picks up the port override the forwards slot earned and the honest account of why no dial crosses the container boundary.
Leftover phrasing from the superseded design where the destination container dialed the connecting machine; the address it holds is what the destination-side dials are aimed at.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
SSH connections can carry user-declared port forwards: local (-L), remote (-R) and dynamic SOCKS (-D) rows, composed at launch or saved on an SSH preset. The form starts as one "Add port forward" button, composes one row at a time, and lists declared rows as plain dialog line items; the pane header's SSH tooltip names the machine it connects from, the destination, and the forwards for the pane's whole life.
The public SSH guide includes forwarding setup, examples, bind-address permissions, presets, and troubleshooting. The homepage shows a real SSH connection-panel screenshot. Sharing dialogs now use aligned permission dropdowns and a styled recipient picker that searches the existing roster by email or name; the website and guide use a refreshed sharing screenshot.
Why this inverts the old refusal
The subsystem refused forwarding at three layers because ambient config-derived forwards were never consciously approved. That refusal stands and is load-bearing: the resolver still refuses
LocalForward/RemoteForward/DynamicForwardfound byssh -G, and the snapshot keeps them as forbidden-null members. What lands here is the opposite class: rows the person declared at launch, visible everywhere the pane speaks, loopback-bound by default.Decisions
node.ssh_wide_forwards.update, enforced plane-side before any dial).GatewayPorts/PermitRemoteOpenpolicy.Hostblock rendered after the policy block (aClearAllForwardings yesline is dropped only when rows exist), so they survive jump-child re-reads with the every-hop policy intact.declaredForwards(the snapshot'sforwards: nullmarker keeps its meaning), are re-validated by the node before rendering, and thessh.launchaudit row carries a bounded summary.Validation
Route envelope ->
parseSshForwardRowsgrammar (ports, host hygiene, kind rules, per-side duplicate listens, cap 16) -> wide-bind gate -> renderer re-parse before bytes. The web form mirrors the same rules for inline sentences only; the server is the authority.Security docs
docs/security.mdgains the "Port forwards are declared, never ambient" paragraph and the audit/gate inventory; mirrored into the rules summary.Tests
Protocol accept/refuse census, render byte-snapshots, launch route gate matrix, preset round-trips, node re-validation.
Web suites for the form, presets and the header tooltip.
E2E spec 26: real sshd, real launches; curl legs through bound
-L/-D/-Rports, the wide-bind door refused pre-toggle and accepted after, recorded config bytes asserted.Full: verify-types, lint (code/prose/design),
bun run test,turbo build, full Playwright e2e (81 passed / 5 skipped).Code review loop: three rounds, final pass zero findings at every level.
Documentation and website production builds, 167 documentation tests, export verification, and focused sharing/combobox tests pass. Browser checks cover recipient search and aligned permission controls at desktop and mobile widths.
Spec:
docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md. Changesets:@internal/serverminor,@internal/docsminor.