Skip to content

feat(ssh): host attachment — a remote node inside a local project (extracted from #112) - #182

Merged
eneskirca merged 4 commits into
mainfrom
feat/ssh-host-attachment
Aug 13, 2026
Merged

feat(ssh): host attachment — a remote node inside a local project (extracted from #112)#182
eneskirca merged 4 commits into
mainfrom
feat/ssh-host-attachment

Conversation

@eneskirca

@eneskirca eneskirca commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Extracts the SSH host attachment portion of #112 (by @proteus-dev / Corvin) onto current main, hand-applied file by file rather than cherry-picked — that branch is based on af8ff3be and picking from it silently reverts everything merged since.

Zero Codex content. git diff origin/main | grep -ic codex0, context lines included. No installRemoteCodexRuntime, no relay/app-server deployment, no nodeterm-codex / codex-relay, and no ssh-project.ts changes at all — an SSH connect on this branch does exactly what an SSH connect on main does today, plus nothing.

What it does

Today a remote terminal only works inside an SSH project: resolveSshRemote looks the ControlMaster up under activeProjectId, so a node whose machine is not its project's finds nothing, waits SSH_REMOTE_WAIT_MS, and lands on the offline overlay.

That state is already reachable on main. The account-login retry (nodeterm:add-account-login) spawns a node carrying another host's ssh into whatever tab happens to be active, and says so in its own comment: "Retry can fire from any project, so the active one may be local or a different host." It resolves the wrong project's connection every time.

This gives such a node a connection of its own. A remote node whose endpoint is not its project's now runs over a host attachment: a ControlMaster scoped by project × endpoint (sshAttachmentId), opened alongside the project's by Canvas's active-project effect, reconnected on its own SshReconnector loop, and torn down with the canvas.

sshConnectionIdForProject(projectId, conn, projectServer) is the single place that decides project-id vs attachment. It has to be one function: spawn, the exit-255 drop report, reconnect, and drag-drop upload all key off that id, and if any of them disagrees a node resolves a master opened for someone else.

It matches on host, not on the full endpoint. A node's ssh is a snapshot persisted into the canvas, and an SSH project's project.json lives in <remoteCwd>/.nodeterm/ on the host — shared with everyone who opens that folder. So a node's user is whoever created it: matching user too meant alice's nodes sent bob's entire canvas off to open a second master dialing alice@box, which fails for bob or sits on an askpass prompt. The project's own binding is the authority on how this user reaches that machine. The deliberate cost: inside an SSH project you cannot pin a node to a second account on the same host. A different machine still gets its own attachment, which is the case this is for.

An SSH project whose nodes name its own host takes the project-id branch unchanged — for an ordinary SSH canvas this is a no-op. The behaviour that changes is the one that was broken: a remote node in a local project (or on a second machine) now gets a connection instead of timing out.

Settings → Remote (SSH) also grows the two affordances that make a machine usable before any node points at it:

  • Default folder per saved machine — a starting point for browsing, not an inherited cwd. The "New remote" folder browser opens there instead of ~ every time. listDir cannot report a missing directory (it echoes the path back with dirs: [] whether the folder is gone or merely empty), so existence is checked against the parent's listing before landing — which keeps a stale default from dead-ending the dialog and keeps a genuinely empty default working.
  • Test connection, which opens and closes a master under its own ssh-settings-test-<id> scope — never a project's, which would report success without testing anything and then drop that project's terminals on the way out. It always disconnects, including on failure: a half-open master would be silently reused by the next test.

Composition with main's remote discipline

CLAUDE.md's rule — a remote node is NEVER spawned locally — is the thing most at risk here, so:

  • The routing facts of an attachment are recorded before the dial and survive its failure — the reconnect coordinator and the respawn's scope → canvas mapping both run precisely when there is no live connection to read them off. This also beats an ordering trap: main emits status:'connected' from inside connectOnce, over the same IPC pipe, so it lands before the invoke reply that records the connection.
  • A node created at runtime dials for itself from resolveSshRemote; Canvas's project-load pass is a pre-warm. One connectHostAttachment with an in-flight map collapses the pre-warm and every node on a machine into a single connect. It always dials — a cached controlPath is not treated as "already connected", because nothing in the renderer clears that path on a drop, and only main can tell a live master from a dead one (its reuse branch costs one mux'd -O check, which doubles as the ControlPersist keepalive).
  • requireRemote is untouched and still the backstop. An attachment that fails to connect refuses the spawn exactly like a project that fails to connect: the near-side guard in TerminalNode prints [not connected — …nothing was started locally], and core refuses again. There is no new path into the local-tmux branch.
  • The branch's version predates none of this — it never had a spawn path at all (see below), so the wiring here is written against main's current resolveSshRemote / requireRemote / SshReconnector shape rather than ported.
  • SshReconnector gains nothing structural: an attachment scope reconnects through the same connect/respawn deps, reading its endpoint back out of useSshConn (an attachment has no project row to read it from) and mapping scope → owning canvas for the respawn.
  • The attachment deliberately does not arm remote git routing (api.git.setActiveRemote). The Source Control panel is about the project's own cwd; an attached node must not repoint it at another host.

On separability

The examiner's read was right, with one correction worth recording: on origin/integration/codex-112 the only production consumer of sshAttachmentId is switchCodexAccount, and sshConnectionIdForProject is not called anywhere — it ships as a helper plus a test. So the branch's helpers are Codex-free but also unwired.

Shipping them dead would have been a non-feature, so this PR wires the routing itself (resolveSshRemote, the drop report, uploads, the Canvas connect + reconnect + teardown). That is the only place this goes beyond the four files the extraction brief named, and none of it touches ssh-project.ts or main-process code.

Three surfaces

  • Desktop (Electron) — where the feature lives and where it is reachable.
  • Server Edition — the renderer bundle is shared, so the code ships; the SSH stack behaves as it does today (the server's own SSH support is unchanged, and nothing here is main-process). No new server surface.
  • Mobile (iOS)not covered. The iOS client has no canvas-side SSH scope concept and the attachment is a renderer-side routing fact; an attached node on a phone-visible canvas will behave as it does on main. A follow-up on the iOS side, not a blocker for this.

Files

File What
src/shared/ssh.ts SshServer.remoteCwd, sshAttachmentId, sshConnectionIdForProject
src/renderer/lib/sshAttachments.ts (new) hostAttachmentsFor — which attachments a canvas needs, grouped per machine — and connectHostAttachment, the one register-then-dial entry point
src/renderer/state/sshConn.ts attachments get their own map (registerAttachment / getAttachment / ownerProjectId / attachmentScopesOf / clearAttachment), written before the dial so they outlive its failure
src/renderer/nodes/TerminalNode.tsx sshConnectionScope; resolveSshRemote dials the attachment on demand; the drop report, the offline retry and drag-drop upload all use the scope
src/renderer/components/kanban/ModalTerminal.tsx same scope for a card's spawn and uploads
src/renderer/canvas/Canvas.tsx connect attachments on project load; scope-aware reconnect/respawn; tear attachments down with the canvas
src/renderer/components/settings/sections/SshSection.tsx default working directory + Test connection
src/renderer/components/SshProjectDialog.tsx "New remote" browse starts at the machine's default folder, with a real existence check

Tests

+79 assertions across five files (ssh.test.ts, sshAttachments.test.ts, sshConn.test.ts, SshSection.test.tsx, SshProjectDialog.test.tsx — the component ones following the existing CustomAgentsSection / GitHubIssuesSection jsdom pattern). The branch's 82-line shared-helper test is ported and extended.

The wiring is tested at connectHostAttachment, which is where the ordering actually lives:

  • records the endpoint BEFORE dialing, not after it succeeds — asserts from inside the fake connect, so it pins the ordering rather than the end state (a pre-populated store structurally cannot catch this).
  • keeps the endpoint on record when the FIRST connect fails
  • a failed dial does not poison the scope against the retry
  • collapses concurrent callers into ONE dial
  • still dials when a controlPath is already cached — the reconnect path depends on it
  • a dial that lands after the canvas was deleted is handed back, not recorded
  • serves a node naming the same HOST from the project, whatever user it was saved with — the shared-project.json case
  • a node with no host is never served from the project (unroutable fails safe)
  • an EMPTY default folder is still the right place to land / falls back to ~ when the configured default is gone

Mutation-checked, not just green. Reverting each fix in turn — registering after the dial, dropping the in-flight map, matching user as well as host, restoring the dead .catch() fallback, reinstating the controlPath short-circuit, dropping the read-after-clear guard, dropping the hostless guard — fails exactly the tests named above, and nothing else.

Untested, honestly: the Canvas call sites themselves (the project-load pre-warm loop, the reconnector's connect/respawn deps, the delete-path teardown). They are inline closures inside a 8k-line component with no seam short of rendering React Flow under Electron; the logic they carry is in the tested helpers, but their wiring is verified only by reading. Flagging it rather than writing a test that mocks the whole component and proves nothing.

  • npm run typecheck — clean
  • npx vitest run src/shared src/renderer — 199/200 files, 2759 passed
  • npx vitest run — 5303 passed, 4 failed

The 4 failures are environmental (a symlinked node_modules in the extraction clone): 3 × node-pty-patch, plus webgl-addon-pair, which was confirmed failing identically on a clean origin/main worktree against the same node_modules.

Not verified

No device run. Wanted before merge: a remote node in a local canvas connecting and surviving a sleep/wake drop; the same node against a host that is down at first load, then reachable (the recovery path the second commit is entirely about); a runtime-created remote node (the account-login retry) in a local tab; Test connection against a reachable and an unreachable host; and that an ordinary SSH project is untouched (one master, one reconnect loop, no attachment registered).

Review rounds

Round 1 (96acaeb) — three blockers, one high, one medium. All were one bug wearing several faces: the attachment's routing facts were written from the connect result, so a failed first connect left the scope permanently unreachable, and main's connected event (emitted from inside connectOnce, ahead of the invoke reply) beat the record into place. Facts now live in their own map written before the dial. Plus: runtime-created nodes dial for themselves; host-only endpoint matching for shared project.json; a real existence check in the folder browser; corrected copy.

Round 2 (9c7894b) — one regression introduced by round 1's fix, caught with a reproduction test. connectHostAttachment short-circuited on a cached controlPath, and the reconnector's connect dep routed through it — but nothing clears that path on a drop, so after one success the coordinator could never dial again and a sleep/wake respawn would land on a dead socket and fall back to direct connections per node. The short-circuit is removed rather than flagged: main already coalesces per scope and its -O check reuse is both cheap and the ControlPersist keepalive, so a redundant dial costs nothing and a skipped one is the failure. Two nits fixed in the same commit (read-after-clear on a deleted canvas; undefined === undefined in the host comparison).

The pure layers review confirmed clean (one reconnector not two, requireRemote intact, quoting, serialization, ModalTerminal lifetime, test-connection cleanup) are unchanged throughout.

eneskirca and others added 4 commits August 13, 2026 20:37
Today a remote terminal only works inside an SSH PROJECT: `resolveSshRemote`
looks the ControlMaster up under `activeProjectId`, so a node whose machine is
not the project's finds nothing, waits 20s, and lands on the offline overlay.
That is already reachable on main — the account-login retry spawns a node
carrying another host's `ssh` into whatever tab is active, and says so in its
own comment ("the active one may be local or a different host").

This gives such a node a connection of its own. A remote node whose endpoint
is not its project's runs over a HOST ATTACHMENT: a ControlMaster scoped by
project × endpoint (`sshAttachmentId`), opened alongside the project's by
Canvas's active-project effect, reconnected on its own loop, and torn down
with the canvas. `sshConnectionIdForProject` is the single place that decides
project-id vs attachment, because every consumer — spawn, drop report,
reconnect, upload — has to make the same choice or a node resolves a master
opened for someone else.

An SSH project whose nodes are on its own endpoint takes the project-id branch
unchanged: for every canvas that exists today this is a no-op.

`requireRemote` is untouched and still the backstop — an attachment that fails
to connect refuses the spawn exactly like a project that fails to connect. The
attachment deliberately does NOT arm remote git routing: the Source Control
panel is about the project's own cwd.

Settings → Remote (SSH) grows the two affordances that make a machine usable
before any node points at it: a default working directory (which the "New
remote" browser now starts in, instead of `~` every time) and Test connection,
which opens and closes a master under its own scope id — never a project's,
which would report success without testing anything and then drop that
project's terminals on the way out.

Co-authored-by: Corvin <corvin@streamlain.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…irst connect

Review of #182 found the pure layers clean and three defects in the new
wiring, all of them one bug wearing three faces: the attachment's routing
facts were written from the connect RESULT.

1. A first connect that FAILED recorded nothing, so the reconnect
   coordinator's connect dep — which reads the endpoint back out of the
   store — returned false on every backoff step, and the offline overlay's
   Reconnect was inert for the rest of the app run.

2. Main emits `status:'connected'` from INSIDE its connect, over the same IPC
   pipe, so it arrives before the invoke reply. On a scope's first success
   `ownerProjectId` therefore still answered the scope id, and respawn took
   the "switched away" branch instead of bumping the nonce.

3. Only Canvas's project-load effect ever dialed, so a node created at
   runtime — the remote account-login retry, this feature's own motivating
   path — resolved a scope no master existed under and (per 1) never
   recovered.

The facts now live in their own map, written by `registerAttachment` BEFORE
the dial and surviving its failure, and every caller goes through one
`connectHostAttachment`: Canvas's pre-warm, the reconnect dep, and — new —
`resolveSshRemote` itself, so a node added at runtime dials for itself. An
in-flight map collapses the pre-warm and every node on a machine into one
connect.

Also from the review:

- A node's `ssh` is a snapshot persisted into `project.json`, which for an SSH
  project lives ON THE HOST and is shared with teammates. Matching the full
  endpoint meant alice's saved `user` sent bob's entire canvas onto a second
  master dialing `alice@box`. `sshConnectionIdForProject` now matches on HOST:
  the project's binding is the authority on how THIS user reaches the machine.

- `listDir` cannot report a missing directory (it echoes the path back with
  `dirs: []` whether the folder is gone or merely empty), so the dialog's
  `.catch()` fallback was dead code and a stale default folder dropped the user
  into a nonexistent path. Existence is now checked against the parent's
  listing, which also keeps a genuinely EMPTY default working.

- `SshServer.remoteCwd`'s doc and the settings field both claimed it becomes an
  attached node's cwd. Nothing implemented that; the copy now says what it does.

Co-authored-by: Corvin <corvin@streamlain.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onnected"

Re-review found the blocker-2 fix had bought its idempotence with a
short-circuit on `getControlPath(scopeId)`, and the reconnect coordinator's
connect dep now ran through it.

Nothing in the renderer clears that path when a connection drops — the status
subscription only invalidates the auto-permission-mode answer — so after one
successful connect the short-circuit answered true forever. Sleep/wake of an
attached node: exit 255, the loop calls connect, it returns true without
dialing, no `-O check` and no re-establish, and the respawned pty runs against
a dead socket under `ControlMaster=auto`, which silently falls back to a DIRECT
connection per node. That is the ~72k-logins/day field pattern the master
watchdog exists to prevent, and the SSH-project branch three lines below did
the right thing unconditionally — the asymmetry was not intended.

Removed rather than flagged. Main is the only thing that can tell a live master
from a dead one, and it already does exactly this: connect coalesces per scope,
its reuse branch costs one mux'd `-O check` (which doubles as the keepalive
that resets the ControlPersist counter), and a failed check re-establishes with
a 'reconnecting' status. A redundant dial through main is cheap and
self-healing; a skipped one is the failure above. There is no reason the spawn
path must avoid re-entering main, so the conditional buys nothing.

Two nits from the same review:

- A dial resolving after its canvas was deleted re-created the scope's
  connection entry via `setConn`, leaving a live master no delete path would
  ever visit again. It now checks the attachment is still registered, and hands
  the master back through an injected `disconnect` if it is not.

- `projectServer?.host === conn.host` matched `undefined === undefined`, so a
  node whose binding had lost its host was served from a LOCAL project's id —
  a master never opened for it. `conn.host` is now checked explicitly; such a
  node fails as an attachment, which ends at `requireRemote` refusing the spawn.

Co-authored-by: Corvin <corvin@streamlain.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eneskirca
eneskirca merged commit 7b84d9b into main Aug 13, 2026
4 checks passed
@eneskirca
eneskirca deleted the feat/ssh-host-attachment branch August 13, 2026 18:21
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