Skip to content

SSH launcher tier: discovery, resolve, rendered ssh-terminal panes, owner-only input - #336

Open
theogravity wants to merge 30 commits into
feat/ssh-anywhere-1from
feat/ssh-anywhere-2
Open

theogravity wants to merge 30 commits into
feat/ssh-anywhere-1from
feat/ssh-anywhere-2

Conversation

@theogravity

@theogravity theogravity commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Tier 2 of docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md (Plan: docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md). Stacked on #334 (tier-1 foundations). Milestone 1's machine half: an enabled node now actually opens SSH-terminal panes.

Surface

  • GET /api/ssh/aliases, POST /api/ssh/resolve, POST /api/ssh/launch, saved/recent hosts CRUD + default connecting machine (all cookie-gated, all behind the tier-1 ssh_enabled gate + nodeCanSsh owner/admin-on-local rule, held agents 409, invisible nodes 404).
  • New built-in ssh harness (type terminal) so streaming/logging/sharing stay the ordinary pane path.
  • The pane's config is rendered from the approved snapshot (-F only, every-hop policy: forwarding/control-sockets/local-commands/escapes dead; StrictHostKeyChecking accept-new per spec §9); written 0600 by the spawning machine at a byte-derived path and swept with the pane (agent watcher on death, plane-side sweep at exit/terminate/maintenance-kill/delete, node hourly orphan sweep).
  • SSH_AUTH_SOCK is exported only for ssh launches (pane command IS the ssh process); the rendered policy keeps ForwardAgent no, so the agent never reaches D.
  • SSH-terminal panes are view-only to sharees: REST input/exec, MCP-via-REST, WS keystrokes (required-positional canInput, no fallback), and channel nudge all refuse non-owners.
  • Audit: ssh.launch success-only (ids + destination only), mirrored in docs/security.md §10 and the rules summary.
  • NODE_PROTOCOL_VERSION 16 -> 17: a lagging agent that would ignore the launch frame's ssh member is held (update-only) until crossed. Server before nodes (changeset says so).

Bugs this tier's e2e exposed and fixed

  • stripAnsi swallowed whole pane lines when OSC was ST-terminated (shell integration output) - fixed test-first.
  • terminate revoked the pane token synchronously, so the dying pane's reportExit sweep could 401 - sweeps now run plane-side too.

Verification

bun run verify-types && bun run lint:check && bun run lint:prose && bun run test all green (54/54 tasks, 0 failures). e2e/tests/22-ssh-terminal.spec.ts passes for real on this host (OpenSSH 10.2p1): gated launch -> owner types echo, evaluated string comes back over the real ssh session -> edit-grantee sharee 403 SSH_OWNER_INPUT_ONLY -> config dir swept. Full e2e not re-run locally; CI owns it. The e2e-cross-subshell boot failure named on #334 is unchanged/environmental (not touched here).

Known limitation

Keystrokes typed during the initial ssh handshake can lose their Enter before the remote prompt exists (documented in the changeset).

Whole-branch review disposition (2026-10-08)

A final pre-merge review of all Milestone 1 (main...feat/ssh-anywhere-3) found
zero Critical and zero Important. Its only merge-blocking items were two stale
protocol comments (fixed in 0744591). The remaining three Minors are
considered and intentionally not shipped at this gate:

  • Boot-time orphan sweep for a config dir written before spawn: create commits
    the row BEFORE the write, so a dir always has a committed row; a process killed
    mid-window leaves the row running, and the reconcile sweep (which now routes
    through #applyDeath, commit e08c863) reclaims the dir within one 60 s pass.
    A row-less dir cannot exist, so no boot deletion code is warranted.
  • ssh_enabled re-check inside create: the gate is checked at request start; a
    flip mid-request is the same owner racing themselves in a sub-second window,
    which the node mirror already races (spec §4.3 "checked when the act is asked").
  • Symmetric validator wrap on the LOCAL resolve path: the local engine shares the
    agent's resolver and the result is re-validated at compose; wrapping is pure
    symmetry and risks a currently-green path for no security gain.

theogravity added a commit that referenced this pull request Oct 8, 2026
Not an ssh change: the #318 backup test 'restricts download jobs to their owner
and cancels temporary output' polls a fixed ~1s for the async job-cleanup to
retire the record. CI's throttled slice ran it at ~1.8s and it flaked twice
across this stack (#336 rerun, #337 shard 1) while passing elsewhere - a
runner-speed-dependent budget. Swapped the fixed attempt-count for a wall
deadline that exits the instant 404 lands. Pre-existing flake, surfaced here,
same 'expose it, fix it' call as the web fetch-reset earlier this branch.
…esolve, rendered ssh panes, gated surface, owner-only input)
…launch block, result validators join node-results
…tant, and the unreadable-mirror refusal is pinned on both ssh arms
…ion 0048)

The ledger is keyed on the canonical host:port destination (optional user@
prefix) with a unique (owner_user_id, destination) index and a recency index
on (owner_user_id, last_connect_at desc); the snapshot column's presence is
the owner-only-input rule's trigger. The backup ceiling advances with the
registration and the older-snapshot fixture's strip-set now removes
0046+0047+0048 rows and DDL so the simulated ledger stays a valid
contiguous prefix (the tier-1 coupling, commit 0ebd6ce).
…dger, createSubshell ssh params, exit sweep, restart refusal

The plane half beneath the routes: BackendErrorCodes joins (SSH_GATE_OFF,
NODE_PROTOCOL_HELD, ALIAS_UNSAFE, SSH_NODE_REFUSED, SSH_NO_RESTART), the
ssh_saved_hosts repository (touch/markSaved/listSaved/listRecent/remove;
the New* insert shape beside it, per task-6's note), ssh-rpc.ts wrapping
the two node commands behind their frozen result parsers, and
ssh-launch.service.ts: gate -> held -> wire-grammar destination check ->
resolve -> compose (parseSshConnectionSnapshot re-validated at the
boundary; configPath derived from the target machine's dataDir; the
-F-first option tail with the '-- host' end, SSH_AUTH_SOCK only when
named) -> createSubshell -> recency touch -> ssh.launch audit on success
only. createSubshell (service + manager) grows the honest plumbing
params; the row's ssh column is written and the delete path's kind fact
re-points from harnessId to the column (the comment's own promise).
reportExit sweeps the LOCAL ssh config dir best-effort, ssh panes refuse
restart with SSH_NO_RESTART, and the five hand-migrated manager suites
get migration 0048 in their chains (the repository now names the
column).
…h, saved hosts

/api/ssh (mounted inside the nodeRoutes basket for the Elysia depth
ceiling): GET /aliases, POST /resolve, POST /launch, the saved-hosts
read/write/delete pair and PATCH /preferences. Cookie-only, gated
before any command leaves the plane, refusal-shaped resolve outcomes
answer 200 in the data and refuse a launch with 422 {outcome}. The
saved rows key on the CANONICAL destination (never the edited alias);
the delete answers 204 with no body and 404 identically for foreign and
absent; the default machine keeps a vanished node's id rather than
silently clearing a human's choice. security.md s10 and the
security-context mirror name ssh.launch (success-only, no saved-host
CRUD rows, per the prompts precedent).
An ssh pane runs a real shell under the OWNER's ssh identity on the far
machine, so typing into it is not an `edit` act a grantee holds — it is the
owner's own hands on a remote session. Every input door now asks ONE predicate,
`lib/ssh-pane-access.ts` `paneInputAllowed(row, actorUserId, bearerActor)`:
an ordinary pane (`ssh === null`) passes untouched; an ssh pane admits the
owner's human account and refuses everyone else. `bearerActor` is a separate
input (not folded into the id test) because raw REST resolves a pane's OWN
subshell key as its OWNER, so a bare owner comparison would let an ssh pane
type into itself; the WS door carries the same fact as the scoped-token marker
(identity.subshellId !== null). Refuses carry the named 403 code
SSH_OWNER_INPUT_ONLY (T7's error-code module, a distinct refusal from the
gate-off and no-restart codes).

Doors:
- REST input (`sendSubshellInput`) and exec (`execInTerminal`): the check rides
  the `#gate` follow-up (a stranger still 404s, a `view` grantee the plain 403)
  and precedes the running/harness/quiet checks, so a non-owner types and reads
  nothing. `requireInputAccess`/`resolvePaneInputAccess` named in the plan are
  not the real shape — the two service verbs gate in-method.
- WS live input: `resolveAttach` computes `canInput` ONCE (edit/owner grant AND
  the ssh carve-out) and both attach paths (local `handleSubshellWs` and the
  node relay) carry it; the keystroke branch already drops `canInput:false`
  frames silently, so the ssh carve-out lands exactly like a `view` drop. The
  relay's `access`-only derivation stays as the test-fixture fallback.

Also freezes the snapshot: `SubshellUpdate` now excludes `ssh` beside
`crossAgent`/`id`/`userId` — the approved snapshot is written once at create and
it IS the input rule's kind fact, so no later act may rewrite it (compile-time
pinned; the predicate suite carries the `@ts-expect-error`).

No route response-map or treaty change: both verbs already declare 403
ApiErrorResponse (like SSH_NO_RESTART reuses its 409), so the new code rides the
existing type. Tests: the predicate directly (pure), the input and exec doors
(each: ordinary+edit-grantee 200 unchanged, ssh+owner 200, ssh+edit-grantee 403
named code, ssh + the pane's own key 403), and the WS authority via
resolveAttach.canInput (ssh+view/edit-grantee, ssh+owner, ssh + own scoped key,
and the ordinary-row regression pins).
…el nudge never targets an ssh pane

Two review findings on the ssh launcher tier:

- attachRemoteSubshellWs took `canInput` as an OPTIONAL last argument and
  fell back to `accessAtLeast(access, "edit")` - the pre-§5.4 grant-only
  rule. A future caller that omitted the argument would silently reopen
  the ssh owner-only relay door, exactly the trap the file's own
  AttachParams comment records for `hidden`. The argument is now a
  REQUIRED positional (`canInput: boolean`), the fallback is gone, and
  the `access` slot stays (underscored: deliberately unread) as the
  relay's positional contract with its one production caller. Test call
  sites pass the truthful grant value (`accessAtLeast(access, "edit")`: true for owner/edit fixtures, false for view) and a new relay case
  pins the CONSUMPTION (edit access + canInput:false types nothing)
  beside Task 8's attach-resolve pins of the COMPUTATION.

- the channel nudge loop typed its fixed cue into RUNNING recipient panes
  with a tmux socket, which includes ssh panes: raw shells under the
  owner's ssh identity on a far host, where input is the owner's alone
  (spec §5.4) and no agent would read the line. The skip guard now also
  excludes `row.ssh !== null`; the new channels-route case posts to a
  live ssh pane and a plain pane in one breath and asserts only the
  plain one is touched (red-check: the test fails without the guard).
… via the dying hook; doc-truth pass on tier-2 comments
The step apt-installed missing TOOL names, but ssh/ssh-keygen/sshd are
binaries shipped by openssh-client/openssh-server, so spec 22's fixture
skipped in CI: the ssh proof never ran. Map each missing binary to its
package and install the deduped set, assert an sshd binary exists
post-install so a package rename fails loudly instead of skipping
quietly, and make the spec's header sentence match the step. Also teach
package-releases' expected list about @subshell-ai/plugin-ssh, the
non-private workspace the ssh tier added (CI runs test:scripts; the
branch was red there).

Whole-branch review of feat/ssh-anywhere-2, finding 1.
…d terminal-equivalence

The ssh launch frame's ssh member DOES carry the plane's derived
configPath; the invariant is that the receiving end byte-checks it
against its own derivation (agent commands/launch.ts, LocalLauncher
in-process), which is what the render and compose comments now say. The
local ssh sweep names four teardown points, not three (reportExit,
terminate, terminateForMaintenance, delete). The agent sweep's id gate
is hex+hyphen (uppercase hex included), not a no-uppercase rule. The
saved-host destination doc drops its Socratic residue for a declarative
spelling. The ssh plugin records the whole-branch review's
terminal-equivalence ruling: a preset-composed ssh launch without the
ssh column is reachable on a gate-off node on purpose; what the gate
reserves are the subsystem's privileges that path cannot reach. Fixture
comment names the live spec (22-ssh-terminal).

Whole-branch review of feat/ssh-anywhere-2, findings 2-5.
…n points at real sites

The snapshot column is never serialized, the launch answer included. The
disclosure rides the plane's route descriptions now, not a claimed past.
The local artifacts doc names what actually sweeps the dir. The inversion
attribution says the plane builds argv and the node resolves the binary.
Two quoted spec sentences that name no spec are now plain substance.
…ugin

The ssh plugin is type terminal, its binary is nearly always present, and
the catalog arrives id-sorted: ssh stood before terminal and the
last-resort usable[0] defaulted a first launch (onboarding wizard, every
launch dialog, mobile New) to a bare ssh that exits without a destination.
CI e2e 15-onboarding caught it; both picker rules now carry a plain-shell
tier between the agent tier and the anything-usable fallback. Mirrored by
hand on mobile per the rule's own comment. Spec 22's death-sweep poll gets
its own doubled budget (it overran 60s once on a contended runner).
Focused: web compat + full web suite (2466), mobile suite (126), e2e 15
and 22 green on this host.
…lookup

The shell tier added in 62980d6 reads its id off options, so the @PARAM
plugins line no longer means 'terminal lookup by id'; corrected to say what it
now feeds (the typeById agent-harness map). Mirror parity: mobile's rule cited
subshell-compat.ts (it is .tsx) and spelled the shell id as an inline literal
where web uses a named constant; both now match. No behavior change.
Focused: web compat 32, mobile agent-default 16; biome + verify-types clean.
Natural-death cleanup hinged on a single best-effort signal: the tmux
pane-died hook firing reportExit, which was the only path that removed
<dataDir>/ssh/<id> for a local ssh pane. When the hook was lost (a dropped
exec, a 401), the 60s reconcile still parked the row dead but never swept,
so the config dir leaked until a manual teardown. CI e2e spec 22 flaked
exactly here - on all retries - while the same code passed elsewhere, because
the sweep had no deterministic driver.

sweepLocalSshDir now sits in #applyDeath, the ONE place a running row is
stamped alive->dead, beside previewCacheDrop and the tmux-server reap - the
pane's artifacts die with the pane, whichever hand first notices. Guarded on
!restarted and by the helper (local AND ssh). The exit-report sweep in
subshells.service stays for the hook-after-retire race, where
applySelfReportedExit returns early and never reaches #applyDeath: the two
sweeps cover two different orderings, not one job.
Focused: manager-ssh death-sweep (+2 new cases), exit/terminate sweep, remote/
service/maintenance/restart suites, full server 4082 pass.
…eep docs

Reviewer follow-ups to e08c863:
- the reconcile no-socket fast path stamps alive->dead directly, bypassing
  #applyDeath, so it now repeats the (guarded) sweep - no alive->dead path
  misses the dir; the #applyDeath doc no longer claims to be the ONE such
  place (it never was: this branch existed before, unreachable for real ssh
  rows but present in fixtures).
- the create rollback (launch threw after writing the config) never swept, so
  a half-built local <dataDir>/ssh/<id> leaked to manual delete - now retired
  with the row, guard no-ops remote/non-ssh creates.
- sweepLocalSshDir's helper docblock said 'all four teardown points'; it names
  the death transition and the create rollback now that they call it too.
- test: the agent-row death case asserts the transition landed (alive=0) so the
  sweep's decline is proven exercised, not skipped; file doc lists the new
  describe.
Full server suite 4082 pass; verify-types + biome clean.
The reviewer's optional follow-up: the no-socket alive->dead fast path stamps
directly (bypasses #applyDeath) yet must still sweep a local ssh dir. A
dedicated user keeps reconcile() off every other test's socketed rows, and the
no-socket branch precedes any tmux probe. Closes the last untested edge of the
death-sweep work.
… false

Whole-branch review Minor: ssh-config still said the resolve command's
node-side dispatch 'is not wired on this tier' (the launcher tier wired it),
and ssh-frames said 'protocol 17 never shipped' (this branch ships 17) plus
pointed at a brokered-session frames file that exists nowhere in this lineage
(those commands are the M2 sealed-relay milestone). Truth now; same
comments-tell-the-truth class the branch has zeroed. Comments only. Protocol
suite 372 pass; biome + prose clean.
Not an ssh change: the #318 backup test 'restricts download jobs to their owner
and cancels temporary output' polls a fixed ~1s for the async job-cleanup to
retire the record. CI's throttled slice ran it at ~1.8s and it flaked twice
across this stack (#336 rerun, #337 shard 1) while passing elsewhere - a
runner-speed-dependent budget. Swapped the fixed attempt-count for a wall
deadline that exits the instant 404 lands. Pre-existing flake, surfaced here,
same 'expose it, fix it' call as the web fetch-reset earlier this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant