Skip to content

SSH remote runtime: work on a destination over SSH as an ordinary Subshell session (replaces the destination-execution model) - #330

Closed
theogravity wants to merge 142 commits into
mainfrom
feat/ssh-support
Closed

theogravity wants to merge 142 commits into
mainfrom
feat/ssh-support

Conversation

@theogravity

@theogravity theogravity commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What this is

The corrected experience from SSH-SUPPORT.md (revised 2026-10-05): pick a machine to connect from, pick an SSH host from that machine's config, start the Subshell runtime on the destination THROUGH SSH, pick a remote folder, and launch ordinary panes there (terminal or an installed harness). SSH is the ongoing transport: no destination enrollment, no setup key, no service, no network route from the destination back to the server. The earlier destination-execution product this PR first implemented is retired here, per the plan's §5 disposition, in the same change that lands the replacement.

Architecture (Gate A frozen contract: docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md)

  • subshell runtime-serve: a session-scoped mode of the node binary; it drives tmux on the destination with its own namespaced dataDir/socket and answers a framed protocol over the SSH child's stdio (4-byte length-prefixed JSON, bounded, fail-closed; hello negotiates runtimeProtocol 1; node link protocol bumps to 17).
  • Broker: the connecting node (the machine whose SSH config/keys/agent are used; Subshell Client's own machine is exactly such a node) spawns the policy-rendered ssh child (deny-by-default render, ProxyJump, BatchMode, keys-only, HostKeyAlias) and pumps frames over its existing encrypted link; supervisor with durable accept, group-kill, boot reconcile.
  • Plane: sessions as hidden runtime-kind node rows behind a RuntimeSessionLauncher; panes are ordinary subshells rows, so list/detail/log/attach/sharing stay normal; disconnect marks unavailable-not-completed, never replays.
  • MCP/reporter: per-pane unix callback doors on the destination (0600, attribution by socket); the plane executes callbacks as the pane's own token behind a fixed allowlist; the real bearer never crosses the wire (credential-scan tests on every launch frame and pane env; sentinel base URL is DNS-dead).
  • UX: sidebar Connect over SSH for every signed-in user: connecting machine (owner-gated, machine-labeled) -> discovered alias -> connect as the test, with named remedies (runtime-missing says install the binary, never subshell setup) -> folder browse -> terminal/harness launch; personal sessions history with honest lost/closed states; trusted server-facts identity line on the pane.

Retirement (same PR, deliberate)

Old admin SSH page, per-pane grants, coarse ssh token scope, run/terminal REST + six ssh MCP tools, snapshot ceremony, old ssh protocol arms (no extra bump: 16/17 never shipped), e2e spec 21, and migrations 0047/0048 tables via 0050 including a sweep that deletes the old managed panes' zombie rows before the drop. Salvage kept: discovery/diagnostics/render/fixture, bounded subprocess primitives, the input-generation fence seam, presetless terminals, and the fenced launch posture.

Review record

Gate A design + vertical slice reviewed clean over three rounds; the detect/preset + UX waves reviewed clean (UX four rounds, backend two); the retirement reviewed clean at round 2 (Important: zombie pane rows, fixed with a tested sweep). Full repo suite exit 0; e2e specs 22 (runtime, 11 tests) and 23 (sidebar journey as a non-admin owner) green; dep-audit unblocked via in-range bumps + one justified ignore. Remaining acceptance before merge: the §7 walkthrough record on a real browser (§6 verification task).

Co-ordinate: this PR replaces #330's original product shape; do not treat any review of the pre-pivot head as certifying the current code.

@theogravity

Copy link
Copy Markdown
Contributor Author

SSH UX review: corrections required before merge

Reviewed head f18f168724505f840831ad3f1f494c6112716798 against SSH-SUPPORT.md and the user's report that the SSH page is in the admin section and does not explain its purpose. This is a static review of navigation, connection setup, grants, and documentation; it is not a new whole-branch security approval or a claim that browser tests were run.

The intended feature is: let an agent already running in Subshell work on another machine over SSH, without installing Subshell on that destination. Connections are personal, and a human chooses which agents may use them. Keep those ownership and security rules while correcting the UX.

1. Move SSH out of admin navigation (P1; direct plan deviation)

SSH-SUPPORT.md §3 explicitly says “Add SSH connections to user settings.” sidebar-nav.ts instead puts it beneath Server Settings, whose entire group requires admin access. The route itself is deliberately not admin-gated, and its comment acknowledges this mismatch. A regular user can own an eligible node and connections but cannot discover the page from the sidebar.

  • Put SSH connections in navigation available to signed-in users, outside the admin group. A peer entry near Nodes is a concrete fit for the current navigation; do not create another server-admin entry. Keeping /settings/ssh is fine: the URL is not the defect.
  • Keep private per-user records and existing backend authorization. Do not “fix” the mismatch by making the page admin-only or exposing everybody's connections.
  • Remove the comments accepting the placement conflict and update the navigation tests that currently enshrine it.
  • Acceptance: a non-admin owner reaches this page through visible navigation, sees their own connections, and still sees no Server Settings group. An admin sees their own connections through the same entry.

Sources: navigation, route.

2. Explain the user's task before configuration details (P2; UX completion)

The page currently says “Saved destinations agents can reach through an enrolled node,” and its empty state explains account scope. Neither gives a newcomer a concrete task or the next steps. The plan specified these mechanisms more precisely than onboarding copy, so this is clarification of the intended experience, not a claim that exact missing wording was mandated.

  • Lead with: “Let your agents run commands on another machine over SSH. The destination does not need Subshell installed.”
  • Add one concrete example: “Use your Laptop node to let an agent work on a development server.”
  • Explain the short sequence: choose the node that connects, choose a destination already configured for SSH on that node, test/save, then choose agents that may use it.
  • Distinguish Open terminal (work there yourself) from Agent access (allow selected running agents to work there).
  • Provide a contextual setup-guide link and useful empty states for no eligible nodes and no discovered aliases. Do not lead with credential generations, revisions, host-trust implementation, or protocol language.

Source: page.

3. Offer only usable connecting nodes (P2; frontend/backend mismatch)

The editor filters with n.canLaunch only. Ordinary node launch permission is broader than SSH configuration permission. sshNodeGate requires the actual enrolled-node owner (not an admin boost or share), with a separate local-node admin exception and availability/maintenance gates. The UI therefore offers choices that discovery/resolution reject.

  • Match the SSH eligibility policy using server-derived facts; expose a dedicated eligibility projection if the existing node view cannot express it completely.
  • Shared-only nodes must not appear as selectable SSH configuration targets. Unavailable eligible nodes may be shown disabled with a reason; do not silently offer them as working choices.
  • Explain which account supplies SSH config/keys and why an empty picker is empty. Preserve backend enforcement.
  • Acceptance: member owner, shared-node member, admin who does not own an enrolled node, local-node admin, offline, maintenance, and server-launch-disabled cases.

Sources: editor, server eligibility.

4. Fix editor state across New/Edit/open cycles (P1; functional defect)

SshConnectionsPage always mounts one editor without a changing key. The editor initializes node, alias, name, and directory with useState(connection?... ) only once. Initially connection is null; selecting Edit later changes the prop without initializing those fields. Switching connections or reopening New also retains prior state. In edit mode the node selector is disabled, making stale/empty node state particularly problematic.

  • Give each editor opening a fresh, explicit form session initialized from the selected connection (or clean defaults for New). A keyed/conditional mount is sufficient; do not add uncontrolled effects that erase current typing.
  • Clear resolution, test result, touched state, and errors between sessions. Bind async resolution/test results to the current session and inputs so late responses cannot validate a different connection.
  • Prevent Save from silently retaining the old destination when a newly typed alias has not been reviewed successfully.
  • Acceptance: open existing A directly; edit A then B; create then reopen New; cancel/reopen; change inputs while resolution/test is pending. Assert the saved request matches the displayed connection.

5. Make setup and agent access action-oriented (P2)

  • Label “Alias” as SSH host or alias, with an example and an explanation that it is resolved on the selected connecting node. Replace the bare Resolve action with Review destination (or equivalent plain wording).
  • Keep security-relevant helper-execution disclosure before the action that may execute configuration helpers. Put detailed configuration diagnostics behind optional details, not in the primary task explanation.
  • Show the reviewed destination and connecting account clearly. Tie test feedback to the current inputs; no stale “Passed” state. Make the prescribed review → test → save sequence explicit. If retaining save-without-test behavior, label it honestly rather than presenting an untested connection as ready.
  • Replace the Grants button with Agent access. Explain: “Choose which running agents can use this connection. Restarting an agent removes its access until you allow it again.” Keep per-pane grants, no inheritance, and human-only changes.
  • Remove primary-flow strings such as “The name carries the route line,” “current credential lives,” and prominent revision numbers. Explain consequences in user terms. Technical/audit details may remain in a details view.
  • Distinguish loading/error/empty states for discovery and access lists. A failed read must not look like “no aliases” or “no running agents.”

Sources: editor, connection card, access dialog.

6. Align docs and verify the journey, not just components (P2)

apps/docs/content/docs/ssh/index.mdx currently sends users to “Settings → SSH connections,” while the implementation is “Server Settings → SSH.” Match the final labels exactly and document one complete example: where SSH must already work, why the destination needs no Subshell install, saving the connection, allowing an existing agent, and asking it to run a harmless command there. Separate advanced security/implementation accounting from the walkthrough.

Add browser coverage that starts from the sidebar as a non-admin node owner, rather than navigating directly to /settings/ssh. Cover the editor lifecycle cases above and verify that saving alone does not give any agent access. Exercise discovery failure and unavailable-node guidance as well.

Update this PR's review-state/verification section after these corrections. “Gate C … zero open findings” may describe the prior security review, but it must not imply these UX and functional findings are resolved. Provide new test evidence and a short recorded/manual walkthrough or screenshots of the member navigation, empty state, setup, and agent-access flow. These corrections belong in this PR, not a deferred release-time navigation pass.

No change requested to v1's keys-only authentication, private ownership, explicit per-pane grants, no automatic replay, or no remote Subshell-install requirement.

@theogravity

Copy link
Copy Markdown
Contributor Author

Architecture correction: match the remote-runtime-over-SSH experience

This supersedes the old SSH-SUPPORT.md architecture and the narrower UX correction request in #330 (comment). The user clarified the intended product and requested an update to the plan and PR instructions, not implementation in this session.

The current PR keeps an agent on an existing node and offers tools for executing commands on an SSH destination without Subshell installed. The desired experience instead runs the Subshell runtime and the selected agent on the destination, with SSH carrying the ongoing runtime connection. Moving the settings page cannot close that gap. Do not merge the existing architecture as fulfilling the corrected request.

Required user journey

  1. Choose Connect over SSH.
  2. Select an existing SSH host.
  3. Check/start a compatible installed remote subshell runtime through SSH.
  4. Choose a remote directory.
  5. Launch an installed remote agent harness/preset or terminal there and use ordinary Subshell panes/workspaces/MCP.

Support both origins, explicitly confirmed by the user:

  • Subshell Client: use the local computer's SSH account/configuration/credentials, through trusted bundled/native code.
  • Web UI: select an eligible already-enrolled connecting node, use its account/configuration/credentials, and broker the same SSH runtime session through its existing authenticated node link. Only actual node owners may use that account's credentials; the server-hosted node retains its admin/launch/maintenance gates.

There is no separate enrollment of the SSH destination, no setup key or daemon service install, and no requirement for the destination to connect back to the server. The existing connecting node in the web flow is intentional; that is not the destination. Internal node/launcher reuse must not become a user-facing enrollment prerequisite.

Reference and boundaries

The official Codex documentation describes local SSH-config discovery, a remotely installed/authenticated Codex CLI, starting/managing its app server through SSH, and choosing a remote project. Match that product experience. It does not establish automatic installation, internal wire details, or our implementation's reliability.

Require the remote Subshell binary initially and give useful missing/version-incompatible instructions without sending the user through subshell setup. Automatic installation/upgrades need separate signed-artifact handling. Agent CLIs and authentication belong on the remote host; no local execution fallback or automatic credential copying. Keep Linux/macOS and keys-only scope unless explicitly changed.

Rework, do not polish the old product

  • Replace the administrative destination/grants flow with personal host selection and remote-folder launch. A saved-host Connections view is fine; a separate per-agent destination-grant ceremony is not required.
  • Retire the standalone SSH command-run API/MCP families, old destination grants, and run supervisor/database as the primary architecture. Reuse normal remote pane operations through a transport adapter.
  • Reuse SSH discovery/diagnostics, safe process primitives, and isolated fixtures where appropriate. Keep independently useful terminal fencing, timeout/unknown-result fixes, bounded scanning, and Agent-created labeling, or split them into focused PRs.
  • Preserve presetless terminals, cursor reads, and enrolled-node archive transfer already on main.
  • Inventory old routes, permissions, migrations, tests, docs, and changesets. Remove obsolete entry points deliberately; do not delete development data or rewrite applied migrations without checking existing state.
  • Rewrite the PR title/body/review status around the final replacement implementation. The old Gate C result and passing tests do not certify this new architecture.

Design gate before parallel implementation

Freeze and prove:

  1. A session-scoped remote runtime transport over SSH with bounded framing and version negotiation, without enrollment configuration or listeners.
  2. Authenticated desktop/node broker binding to the correct user, control plane, SSH host, and session. Preserve the desktop's narrow server-loaded webview ACL; do not grant that window arbitrary SSH/native commands.
  3. Ordinary launch, detection, directory, attach, and lifecycle adapters.
  4. Remote MCP and reporter callbacks through the SSH/broker connection. Today's direct HTTP callbacks must not quietly reintroduce remote-to-server reachability requirements. No general-purpose unauthenticated proxy or full server credential on the destination.
  5. Disconnect/reconnect/close behavior, resource ownership, and crash recovery without duplicate launches or replay of uncertain mutations. Coexist with any separately enrolled daemon on the destination.

Then parallelize runtime, connection brokers, and control-plane adapter under frozen contracts; follow with UX/MCP and independent verification. One coordinator owns shared wire contracts, registries, migrations, and integration files.

Acceptance: independently demonstrate desktop-origin SSH and non-admin web-origin SSH through an owned existing node. On a destination with Subshell installed but no enrollment config and no route to the control plane, select a directory, launch the agent there, attach, and complete an authorized MCP round trip through the session. Also verify missing runtime/harness/authentication, host-key refusal, jump hosts, cross-account isolation, remote-webview boundaries, disconnect/reconnect, stale input, bounded resources, and safe cleanup. Agent model-provider access is a separate requirement from access to the Subshell server.

SSH-SUPPORT.md has been replaced in the working tree with this corrected direction and multi-agent handoff; it has not been committed/pushed by this review. This comment records the new instruction on the PR immediately. Application code is unchanged.

@theogravity theogravity changed the title SSH support: agent-operated destinations over SSH (protocol 16) [DO NOT MERGE - superseded by revised SSH plan] SSH: node-side destination model (old design; salvage record) Oct 5, 2026
@theogravity

Copy link
Copy Markdown
Contributor Author

Disposition recorded per the revised SSH-SUPPORT.md (commit d192fa6): this PR implements the superseded destination model and must not be merged as satisfying the clarified remote-runtime-over-SSH experience. Branch kept as the implementation + review record for selective salvage (§5). The revised plan authorizes plan/PR-instruction updates only; implementation rework is not yet requested.

@theogravity

Copy link
Copy Markdown
Contributor Author

Rework started per the architecture-correction comment: Gate A design frozen at 53de2f4 (docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md); the vertical-slice build (runtime-serve over a brokered SSH session, protocol 17, e2e/tests/22-ssh-runtime.spec.ts) is in progress on this branch. Old doors retire with the replacement, not before.

@theogravity theogravity changed the title [DO NOT MERGE - superseded by revised SSH plan] SSH: node-side destination model (old design; salvage record) SSH remote runtime: work on a destination over SSH as an ordinary Subshell session (replaces the destination-execution model) Oct 6, 2026
@theogravity

Copy link
Copy Markdown
Contributor Author

Acceptance record (plan §7), supersedes the pre-pivot verification sections

Gate A: design frozen (docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md); vertical slice proven on the fixture sshd with no destination enrollment and no destination->plane route. Review loop: slice 3 rounds, detect/UX waves to zero findings (UX 4 rounds, backend 2), retirement clean at round 2, acceptance fixes clean at round 2 - final verdicts all Approved, 0 open Critical/Important/Minor at HEAD 0c6cdb1.

Independent verification: cold-stack runs of e2e specs 22+23 green (16/16 at the fix wave, twice), destination-isolation measured green (pane env carries no token; foreign callback forbidden; sentinel base URL dies at DNS; zero TCP listeners/established from runtime pids via ss), and a 20-screenshot walkthrough of the actual journey as a non-admin node owner (sidebar connect -> machine -> alias -> folder -> launch -> typed markers -> close -> kill/reopen re-adopt -> missing-runtime guidance). The walkthrough caught three real gaps, all fixed and pinned: live /ws attach for runtime panes (plus the execProbe socket-derivation blocker), named dir_missing refusal before any lying row, and reopen re-adoption of surviving panes with token rotation on the same row - race-pinned by genuine-await interleaving tests.

Known scope edges: destinations paired with old agents refuse runtime attaches until the paired agent ships (17/17 same-release posture, plan's protocol policy); agent-CLI authentication absence on the destination is detected by the CLI's own failure output, not by us (v1); desktop-origin is the same web flow over the client's own enrolled node (design §1), no native code needed.

@theogravity

Copy link
Copy Markdown
Contributor Author

CI is green (16 pass, 0 fail). The one red that surfaced was a real product race the container exposed and local timing hid: pipe-pane streams only post-attach output, so a pane program that prints in its first microseconds loses its banner to tmux history. Fixed with a shared seedLogFromHistory (pane-runtime) wired into both attach twins - backfills the log from pane history exactly once, only while the log is still empty (a non-empty file IS the stream flowing, so reconstruction is never prepended), never throws. Reviewed, corrected after review caught that the capture mode-preamble defeated the silent-pane guard, and re-verified green.

@theogravity

Copy link
Copy Markdown
Contributor Author

Final review loop closed: the seed-log fix's re-review came back Clean (0 MAJOR / 0 MINOR / 0 NIT), with all three prior findings independently re-confirmed against real tmux. Every workstream review on this branch is now clean and CI is green (16 pass / 1 skip / 0 fail, including End-to-end).

The branch is review-clean and merge-ready. Merging is the operator's call; nothing further until then.

theogravity and others added 20 commits October 6, 2026 19:46
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Protocol (Apache, Metro-safe barrel): nine ssh_* node wire commands with
per-arm parsers delegated from parseNodeCommandBody, approved normalized
connection snapshot (eight absent-forbidden members typed literal null),
run facts with accepted/running/completed/unknown plus honest 255 and
local-vs-remote exit grammar, frozen limit constants and error code set,
six result validators, and an additive inputGeneration fence on input
and prompt_deliver. NODE_PROTOCOL_VERSION stays 15; the bump is owned by
integration.

Server (AGPL): five Kysely tables (ssh_connections, ssh_grants, ssh_runs,
ssh_panes, ssh_terminal_execs) with migrations 0047/0048 registered at
both sites, partial indexes with revocation/quota rationale, the deny-by-
default SshPolicy interface (no implementation), and the frozen REST/MCP
response shapes. Backup engine updated with the migration registration:
LATEST_BACKUP_MIGRATION and the shipped-partial-index allowlist. Shared
fixtures for the four parallel workers.
…s split)

Restore the commented MCP reference block (it was missing from the first
commit); make the unknown lifecycle's ONLY admissible remote observation the
ambiguous 255, enforced in readSshRunFacts; drop generation_stale from the
error-code set in favor of the single NODE_RESULT_SSH_GENERATION_STALE wire
spelling; extend the human policy arm with run/terminal acts and fix the
gateGrantedUse bypass implication; split ssh-frames.ts into ssh-frames +
ssh-run-facts + ssh-results inside the size ceiling with the barrel re-pointed
(names unchanged); bound isAbsPath by SSH_PATH_MAX_CHARS; rewrite the grant
history rationale (pane cascade takes revoked rows too, api_key_id is the
survivor); True=/1= doc fixes; pin SSH_PROBE_DEADLINE_MS.
…lost

The commented MCP tool-shape block (list_ssh_connections,
execute_ssh_command, read_ssh_command, cancel_ssh_command, open_ssh_terminal,
get_terminal_execution) was present at edit time but missing from the
committed file tail a second time; re-appended and verified in the committed
blob by grep before and after commit.
…railing export-attached docs)

The block was dropped from the last two commits by biome's assist actions, which the pre-commit lint:staged runs with --write --unsafe and which delete a trailing JSDoc attached to an empty export {}. Reproduced directly; re-landed as detached line comments after the last interface, verified to survive a --write --unsafe pass and present in the committed blob. Six tools with snake_case args and camelCase outputs named after the frozen views: list_ssh_connections, execute_ssh_command, read_ssh_command, cancel_ssh_command, open_ssh_terminal, get_terminal_execution.
… a bypass

IMPORTANT-4 second half: the old last sentence said human callers reach uses 'through the owning-human path, not this one', which read as a way around SshPolicy. The five run/terminal actions already live in SshHumanConfigAction; the doc now says human use is gated through gateHumanConfig, the same interface's other arm.
…im drop, control-arm ownership, runs service split, audit doc per family
…cy, durable runs, terminal log

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inal, sweep) wired into dispatch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…solve tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…classification under stop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
theogravity and others added 13 commits October 6, 2026 19:46
… Minor)

The node-frames arm has always promised raw stdout 'in <= 192 KiB
pieces'; the broker sealed whatever one stdout read returned, and a
bigger read was a bigger event. The seam now splits at the bound
(pumpChunks: exact offsets, byte-identical concat - the session codec
reassembles frames across pushes, so a cut is free) and throttles per
piece. Unit test pins the at-bound pass-through, the split, and the
empty read.
…(Minors)

The loopback-only self-fetch means a NON-loopback HOST bind answers
runtime callbacks with 504 - the note says so and why a bind-host
fallback is the wrong repair (it reopens the outbound-hop hole the
hardcode prevents). The input-hold doc gains the re-adoption case:
holds keyed to a settled runtime node never re-fire, die with the
socket, bounded by the client's own queue.
The re-review's one open Minor. launch refuses a relative cwd with the
named code dir_relative at HTTP 409 (the F3 dir-shape family), but the
route's flat refusal mapper listed dir_missing in the EXISTS_ERROR branch
and not dir_relative, so the browser saw a 403 for a typo while a missing
dir sees 409. Add dir_relative to the same 409 branch; the sshCode still
rides in metadataSafe. Route test pins 409 + sshCode dir_relative.

Co-Authored-By: Claude <noreply@anthropic.com>
…exists, race stated (review m1)

The comment credited a plane-side refuse-a-second-live-session gate that was
deliberately never built (openSession checks only the per-node quota, and a
pre-open refusal would break design 6's reopen-and-adopt journey). The bind's
live-listener probe is the one enforcement point; the non-atomic
probe/unlink/bind window is now stated as the accepted residual.
…issing (review m2)

The second serve on a busy destination dies at its callback-door bind BEFORE
hello, and the pre-hello classifier's default arm dressed that up as
"install the binary" on a machine whose binary is running the first session.
The serve now exits with its own code (SSH_RUNTIME_SERVE_IN_USE_EXIT, mapped
only by the LiveCallbackDoorError bind refusal - fresh dirs and stale inodes
keep their old readings), the broker maps it to the new equality code
session_in_use ahead of the 127/255/default arms, mapOpenError names it, the
route answers 409 EXISTS like its quota sibling, and the SPA copy says the
truth: close the live session, then connect again. The frozen 127 ->
runtime_missing path (e2e 22/23's cells) is untouched, pinned by the new
classifier test's control arm.
…osed (review m3)

After I3 made isNodeOffline read a live runtime session as online, the route's
remote branch sailed past the offline gate and composed the non-session
write_file RPC - the session frame set does not speak it - answering
NODE_UNREACHABLE with restart advice the runtime seal makes impossible. The
route now reads the pane's node kind first: a `runtime` row is a session's
hidden destination, reached only through its framed channel, so uploads are
refused with the new RUNTIME_UPLOAD_UNSUPPORTED 409 and the honest remedy
(put the file on the destination yourself). Kind-first, so a lost session's
runtime pane gets the same truth instead of "start the node"; local,
ordinary-agent and the offline-409 paths are untouched (pinned by the
existing relay suite, 10 cells green alongside the two new repro cells).
Plane-side a node-link close is terminal for the sessions riding it
(markSessionsLostForNode; design 6: a lost session is never resumable), but
nothing agent-side drained the SSH-session supervisor: commandWs.send
swallows events with no socket, the emitBytes self-heal cannot fire on an
idle session, and the daemon's close path stopped only the tails. Every
brokered child therefore outlived the flap - holding a supervisor quota slot
and the destination-deterministic callback door that EVERY future open to
that host:port:user needs (the child's serve would die at the bind probe and
mis-map, the m2 case). The daemon's finish now drains the supervisor through
its own close: closed recorded first (the stopping latch; no loss spam into a
socket that could not deliver it), group-kill second. The destination's tmux
server and panes stay up, which is what a future session's census re-adopts.
Daemon test drives the real fake-plane through one flap: the child is gone,
the live map drains, no ssh_session_lost rides the reconnect, and a later
open to the same destination lands (red: the child sat alive; the pid
assertion is real - an earlier draft's quoted-$$ fake recorded a literal
and passed vacuously).
…review round-3 MAJOR)

Wave 2's drain made the agent kill its brokered children on EVERY link
close; the plane's terminality did not follow through the superseded
arm. A black-hole flap (the plane never gets FIN for the old socket;
the agent's 15 s heartbeat notices, drains, and redials) lands the old
close in the held/superseded branch, which deliberately touches no
session, so the plane kept active rows and online hidden runtime nodes
over runtimes the agent had already killed - ssh_session_send answered
SSH_SESSION_UNKNOWN until a reboot or a manual close of each zombie.

Fix (design A, identity-tagged): the session registry keeps the node
connection each session was brokered on (captured in the same tick as
the open RPC, so the tag names the link that carried it), and a
`ready` on a NEWER connection marks every session of that node not
tagged to it lost - the ready IS the moment the prior link is proven
gone, for held outcomes included. Branch 1 keeps marking all sessions
directly as the faster FIN path. The tag is compared by IDENTITY, so
an open that rode the new socket in the attach-to-ready window (or any
later open, or a misbehaving agent's second ready) is never clobbered.
Pre-existing registry behavior, close arms, and the credential
invariant are untouched.

Red proof: the two new node-ws-handler orderings failed against the
old code (session stayed active after the SUPERSEDED close and the
replacement ready); the branch-1 pin stayed green across both.
Tests: branch-1 close, black-hole flap convergence, and the
newer-connection no-clobber case in both orders.
…child (review m-A + m-B)

m-A: the drain ran bare on the reconnect path - one writeFileSync/chmod
throw (ENOSPC/EROFS/EACCES are ordinary) inside `sup.close` escaped
`finish`, skipped `resolve(close)`, and the loop never dialed again
(alongside the remaining refs), against the file's own stated rule
that a broken sweep must not cost the node its connection. Every ref
now carries its own guard in `drainBrokeredSessions`, and the call in
`finish` wears the outer belt; the close always settles.

m-B: a ref enters the supervisor's live map only at hello - up to
~60 s after the drain's snapshot - so an open in flight across the
flap reappeared AFTER the drain as a fresh orphan (quota slot + the
destination's door, and post-m2 every later open there answers
session_in_use naming a session the plane unrolled). `dispatchCommand`
now hands the ssh_session_open executor a per-command liveness gate
built from the daemon's `currentWs` vs the delivering socket (identity,
not an attached-bool that a fast redial would fool); when the open
answers on a dead link the child goes through the supervisor's own
close and the command answers refused.

Tests (daemon.test.ts, shared fake-serve fakes): m-A red = the throw
escaped `finish` at daemon.ts:930 and no second `ready` landed; both
throws (per-ref and first-line) now redial and the surviving refs are
attempted. m-B red = the late-born child sat alive and the live map
held it; green = group-killed, map empty, no loss spam onto the new
link, and a later open to the same destination lands.
… (review NIT)

The m1 rewrite said a pre-open refusal "would misread every REOPEN",
which overstates it: an active-row-keyed gate would pass reopens after
lost or closed rows just fine. The precise argument is that any gate
able to catch a live-serve collision needs destination truth (who is
listening behind the door right now) which the plane cannot have
across sessions, so it would miss the collision rather than misread a
reopen - and the bind at the destination stays the enforcement point.
Comment only.
…iew round-4 MINOR1)

disconnectNode detaches the live entry synchronously, so the socket's close
lands in the superseded arm (no sessions) and the eviction's own act refuses
or replaces the redial (no ready sweep). The agent drained its brokered
children the moment the socket died; the plane now says so at the detach,
where only it can. The held branch and a refused (no-detach) eviction mark
nothing, unchanged. Comments name the R3 hold's pre-sweep return (NIT).
…NOR2)

The record write is bare sync fs, and the old order let a disk fault
(ENOSPC/EROFS/EACCES) throw before killGroup at EVERY close() call site -
m-B reclaim, emitBytes self-heal, the link-close drain (m-A's per-ref catch
keeps the loop alive, not the child) - orphaning a child that holds a quota
slot and the destination's door. The kill leads now; the stopping latch, not
the record, keeps the death quiet, and the boot reconcile re-derives closed
for a dead pid. Return contract unchanged. Test drives the real supervisor
with a store that throws on the closed write: RED showed the child survive
3 s, green shows it group-killed with a lost-on-disk settle and no loss
report.
The m-A per-ref catch comment justified itself with a disk fault in the
record write that the round-4 kill-first reorder made unreachable (close()
now has no reachable throw). The guard stays - the drain runs on the
reconnect path, so an unexpected throw must not strand the remaining
children or the link - but the comment now says belt-against-a-future
failure, not a fault that can no longer occur. Round-5 review NIT1.

Co-Authored-By: Claude <noreply@anthropic.com>
@theogravity

Copy link
Copy Markdown
Contributor Author

Implemented the SSH UX corrections in 3ffbd5e.

SSH is now a location choice in the shared New subshell form: choose SSH host, connect or reuse a connection, choose a remote folder, then launch an agent or terminal. The same flow works inside workspace creation and Add pane, preserving the workspace and its placement controls. Remote panes show the SSH host name in the sidebar and home grouping.

Removed the primary Connect over SSH sidebar section. A secondary SSH connections page remains reachable from the launch form and disconnected-pane recovery, for connection history, reconnecting and disconnecting. Recent hosts and active connections are available directly in the launch form. This makes starting remote work the entry point, rather than asking users to manage SSH sessions first.

The connecting-machine step explains whose SSH configuration and credentials are used. Members can use their own enrolled nodes; admins can also use the server account when server launching is enabled. The destination still runs the Subshell runtime without requiring enrollment. Server-origin connections enforce authorization and maintenance restrictions and drain owned SSH children on shutdown. Remote launch also honors the normal prompt stack, with no automatic replay after an uncertain delivery.

Updated SSH-SUPPORT.md, internal launch/sidebar documentation, and a release changeset.

Validation:

  • All 54 type-check tasks and 27 lint tasks pass; design, license and prose checks pass.
  • Server: 4,046 tests pass. Web: 2,482 tests pass. Node: 947 tests pass in a serial rerun. The full parallel run stalled in a node worker after its last assertion, so it was interrupted rather than reported as a completed full-suite pass.
  • Four real-SSH browser scenarios pass: standalone remote terminal and input, reusing a connection for a workspace pane without leaving the workspace, connection closure/history, and missing-runtime guidance.

This change covers the shared web launch experience (including the served desktop dashboard); it does not add a new privileged native desktop SSH bridge.

@theogravity

Copy link
Copy Markdown
Contributor Author

Implemented the SSH UX revision in 14acfc3.

The primary workflow is New subshell → SSH host → connecting computer → host → folder → agent/terminal, shared by standalone panes and workspaces. Personal Settings → Connections handles pairing, connection history, and revocation; it is secondary to launching work.

  • Subshell Client can supply its own SSH configuration and credentials without enrolling the computer as a node. Pair once using a short-lived code from Settings → Connections, then use the bundled app's SSH Connections screen to reconnect, disconnect, or forget the server.
  • Browser users can still connect through owned nodes and authorized server accounts. The form explicitly identifies the connecting computer and destination, includes an SSH configuration example and refresh action, and explains missing runtime installation.
  • Saved host/folder locations reopen through their original connecting computer and refuse silently changed destination identities.
  • Remote panes reconnect in place, preserving pane IDs and workspace placement. Terminal attachments are recreated when recovery replaces the runtime node, with browser keyboard input verified after recovery.
  • Pairing credentials are scoped to the SSH transport, stored privately, and excluded from ordinary REST authentication. The remotely served desktop window gains no native SSH privileges. Revocation, application exit, and reset close the corresponding transports. Bounded dispatch and backpressure prevent a slow new connection from blocking existing panes.

Validation: server 4,057 tests, web 2,491 tests plus the focused terminal-generation regression, node 953 tests, desktop UI 444 tests plus focused reset coverage, docs 169 tests, all five SSH browser tests against an isolated server and real SSH fixture, repository type/lint checks, and Rust formatting/Clippy/tests across all three crates. Native window interaction was not manually smoke-tested; the bundled UI build, native tests, and actual CLI broker transport were tested.

To exercise the desktop path, rebuild Subshell Client; refreshing the web UI alone does not add its native commands. Remaining documented limits: destination runtime installation is manual, SSH authentication must be noninteractive, and cross-host conversation/Git handoff is out of scope. This implements the agreed remote-work flow, not a claim of complete Codex feature parity.

@theogravity

Copy link
Copy Markdown
Contributor Author

Addressed the UX findings from the server-to-Docker SSH walkthrough:

  • Destination review no longer prescribes installation before probing. Failed host-trust and runtime probes reveal targeted guidance with copyable commands, explicit connecting-account context, and safe fingerprint-verification steps.
  • Empty SSH configuration guidance includes a copyable example with Port, container port/address advice, and the account/location to edit.
  • Runtime guidance covers platform selection, installation, tmux, and verification through the actual noninteractive SSH environment. Setup remains a deliberate terminal operation; the page does not modify SSH trust or install remote software automatically.
  • Launch buttons are hidden while choosing/connecting SSH. The standalone, add-pane, and new-workspace dialogs now keep their action footer outside the scrolling form.

Verified using an Ubuntu 24.04 Docker destination with OpenSSH, tmux, and a non-root account. Created an admin through the isolated server's setup wizard, exercised missing config, unknown host key, and missing runtime, then browsed/saved a remote project and typed commands into its terminal. Footer visibility was verified at 1440×1000 and 390×844. Test container/server were cleaned up.

Validation: 2,497 web tests, all five SSH browser tests, and repository lint/type checks. Added permanent browser assertions for contextual setup and desktop/mobile footer visibility, plus unit coverage for trust guidance and shell-safe copied commands.

theogravity and others added 4 commits October 6, 2026 21:25
…are copy

The SSH connect step leads with the machine-and-destination journey and demotes
shortcuts to a Quick connect group. The "Connect from" machine list becomes a
SearchableSelect like the other launch pickers: the detected local machine folds
in as a "This computer" option, and selectable machines sort above greyed ones
so the scan is gone. The host-step copy names the connecting machine and its
direction ("X SSHes into one of these hosts, listed in its SSH config") instead
of pointing at a config the machine may not own.

Tests and the connect e2e drive the picker instead of the old card buttons.

Co-Authored-By: Claude <noreply@anthropic.com>
theogravity added a commit that referenced this pull request Oct 7, 2026
Rewrites the UI section around the real job (pick a destination; origin/key
source only as a progressive follow-up; the agent upgrade is a secondary act in
the pane). Records that reuse from #330 is code/ideas only and its connect UX is
a cautionary example, not a base.

Co-Authored-By: Claude <noreply@anthropic.com>
…esign)

Co-Authored-By: Claude <noreply@anthropic.com>
@theogravity

Copy link
Copy Markdown
Contributor Author

Superseded by the ssh-anywhere design (#333): same destination-over-ssh goal, rebuilt around plain SSH to any sshd host with a default-off per-node gate, and a destination-first UX replacing this line's flow. The discovery/resolve grammar, snapshot policy, and sshd fixture here are the port source for Plan 1 (feat/ssh-anywhere-1), so the branch stays as a reference rather than a merge candidate.

@theogravity theogravity closed this Oct 7, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant