Repository navigation
SSH launcher tier: discovery, resolve, rendered ssh-terminal panes, owner-only input - #336
Open
theogravity wants to merge 30 commits into
Open
theogravity wants to merge 30 commits into
theogravity wants to merge 30 commits into
Conversation
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.
theogravity
force-pushed
the
feat/ssh-anywhere-2
branch
from
October 11, 2026 08:48
fbddbc4 to
bdca2ce
Compare
theogravity
force-pushed
the
feat/ssh-anywhere-1
branch
from
October 11, 2026 08:48
c5a04be to
8812be4
Compare
…esolve, rendered ssh panes, gated surface, owner-only input)
…launch block, result validators join node-results
… terminal auth left to the server
…tant, and the unreadable-mirror refusal is pinned on both ssh arms
…derived path, removed with the pane
…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).
…egration output cannot swallow a line
… the wire, sharee refused, files cleaned
… 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.
…nse-map key gained)
…cal-destination doc sheds its reader half
…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.
theogravity
force-pushed
the
feat/ssh-anywhere-2
branch
from
October 12, 2026 00:36
bdca2ce to
f7379a3
Compare
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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 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-1ssh_enabledgate +nodeCanSshowner/admin-on-local rule, held agents 409, invisible nodes 404).sshharness (typeterminal) so streaming/logging/sharing stay the ordinary pane path.-Fonly, every-hop policy: forwarding/control-sockets/local-commands/escapes dead;StrictHostKeyChecking accept-newper 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_SOCKis exported only for ssh launches (pane command IS the ssh process); the rendered policy keepsForwardAgent no, so the agent never reaches D.canInput, no fallback), and channel nudge all refuse non-owners.ssh.launchsuccess-only (ids + destination only), mirrored indocs/security.md§10 and the rules summary.NODE_PROTOCOL_VERSION16 -> 17: a lagging agent that would ignore the launch frame'ssshmember is held (update-only) until crossed. Server before nodes (changeset says so).Bugs this tier's e2e exposed and fixed
stripAnsiswallowed whole pane lines when OSC was ST-terminated (shell integration output) - fixed test-first.Verification
bun run verify-types && bun run lint:check && bun run lint:prose && bun run testall green (54/54 tasks, 0 failures).e2e/tests/22-ssh-terminal.spec.tspasses for real on this host (OpenSSH 10.2p1): gated launch -> owner typesecho, evaluated string comes back over the real ssh session -> edit-grantee sharee 403SSH_OWNER_INPUT_ONLY-> config dir swept. Full e2e not re-run locally; CI owns it. Thee2e-cross-subshellboot 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) foundzero 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:
createcommitsthe 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 routesthrough
#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_enabledre-check insidecreate: the gate is checked at request start; aflip 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").
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.