Skip to content

(remote): launch a session on a host inside tmux (#218, #222) - #427

Merged
devsuitup merged 3 commits into
mainfrom
feat/218-remote-launch
Oct 3, 2026
Merged

devsuitup merged 3 commits into
mainfrom
feat/218-remote-launch

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Starts a Claude session on a remote host from the project's +, inside tmux, and attaches to it. Second item of #218 and the launch half of #222; enrolment stays open.

What changes

  • Launch tier. computeHostProfile makes launch available under the attach rule: the probe found tmux, or a live descriptor names a tmux pane, and the probe did not say tmux: false. Otherwise the reason starts with "needs tmux on the host". launchBlockReason(profile) is the gate the main process checks.
  • remote-launch.js. Builds one sh -c '<script>' ssh command: test -d on the directory, command -v tmux, command -v claude (exit codes 9/10/11, each with its own message), then tmux new-session -d -P -F ... -s switchboard-<uuid8> -c "$cwd" 'claude --session-id <uuid> [flags]'. The output carries the pane target and pid, which go straight to the existing attach adapter (socket discovery and pid-reuse guard unchanged). No wait for the next refresh; the host is refreshed afterwards.
  • IPC remote-launch-session. Checks the host is declared, the tier is available, the id is a uuid, then launches and attaches. The attached session is registered through registerRemoteAttachSession, now shared with the remote branch of open-terminal.
  • UI. The + of a remote project is enabled when the launch tier is available and opens a dialog: directory (known paths of that host in a datalist, or typed) and permission mode, Dangerous Skip included. launchRemoteSession adds a pending row, launches, shows the session; a refusal is written in the terminal tab.
  • Docs. docs/remote-hosts.md ("Launch a session"), .ai/contexts/session-cache.md, CHANGELOG (New).

Validation and quoting

No interpolated value is trusted: the cwd must match ^/[A-Za-z0-9._+@:,=/ -]*$, be at most 4096 bytes and have no .. segment (so no quote, $, backtick, backslash, newline or leading - can reach the shell); the uuid is matched by regex and the tmux name derives from it; the permission mode is checked against an allow-list. The cwd is still single-quoted (shellSingleQuote) and the whole script is wrapped in sh -c, so a fish or csh login shell never parses it. The same checks run in the dialog, in handleLaunchRequest and in the adapter.

Follow-ups from review

  • remote-launch-session lowercases the id before its activeSessions guard.
  • The launch tier is not withdrawn by a failed last refresh (it runs its own ssh, as Send does); a host never synced still refuses.
  • loadProjects re-injects a pending remote session under the project of its own host.
  • parseLaunchOutput reads the last non-empty line, so an rc file printing on stdout does not hide the target.
  • Wiring of remote-launch-session and registerRemoteAttachSession pinned as source text in test/main-remote-launch-wiring.test.js; added tests for the 4096/4097-byte cwd bound, strict dangerouslySkipPermissions === true, a real sh -c run of buildLaunchCommand with stub tmux, and the pending re-injection (test/app-pending-remote-reinject.test.js). Nine more mutations, all killed.

Tests

  • test/remote-launch.test.js (20): exact command string; hostile cwd (', $(), backtick, newline, ;, |, .., leading -, relative, NUL, oversize, non-string); uuid; permission flags; output parsing; stop command for the created pane target is kill-pane, never kill-session; adapter outcomes (no dir, no tmux, no claude, ssh failure, timeout, unreadable output, throw); flow with a fake transport (tier refusal, undeclared host, test -d failure reported with attach never called, attach called with the created target and size); two runs under a real sh with stubbed tmux and claude pinning the exact tmux argv and the missing-directory exit code.
  • test/remote-host-profile.test.js: launch available with tmux (probe or descriptor), unavailable without it with its reason, launchBlockReason.
  • test/dom-sidebar-remote-tier-gates.test.js: + enabled and opening the remote dialog, not the local popover; disabled without tmux.
  • test/dom-remote-launch.test.js (8): dialog lists that host's known paths, free path, mode and Dangerous Skip mapping, refusal of empty/relative/hostile paths, Cancel/Escape/Enter/backdrop; launchRemoteSession (extracted from public/app.js) adds the pending row, calls the IPC, and writes a refusal into the terminal.
  • Four existing assertions that expected attach to be the top tier on a tmux host now expect launch.

Red first: remote-launch.test.js could not load (module absent), five profile tests failed, all dialog and flow tests failed. One mutation per guard, all killed: cwd allow-list, .. guard, test -d, command -v tmux, command -v claude, uuid regex, permission allow-list, tier gate, declared-host check, attach-failure check, tmux name, launch-failure stop, launch tier without tmux, launch via descriptor, null-profile gate, button enabled, button click, dialog path check, known paths per host, failure branch of launchRemoteSession. The -c "$cwd" flag was also mutated against the real-sh test.

task check: 0 lint errors, 3123 tests passed, 0 failed.

Not verified

  • No real host: nothing ran against a real ssh, tmux or claude. tmux output of -P -F and pane_pid carrying TMUX in /proc/<pid>/environ follow the tmux manual and the existing attach code, not an observation.
  • claude on the PATH of a non-interactive ssh command is required; when it is added only by an interactive profile the launch reports it as not found.
  • The IPC handler and registerRemoteAttachSession in main.js have no direct test (main.js cannot be loaded in the suite); the refactor of the open-terminal remote branch is covered only by the existing source pins.
  • A failed attach after a successful launch leaves the tmux session running on the host (the error names it). A claude that exits at once closes the pane and the attach reports the probe error.
  • Linux hosts only, as for attach. Only the permission mode maps to a CLI flag.

Starts claude --session-id <uuid> in a new detached tmux session on a
declared host, after checking the directory, tmux and claude exist, then
attaches to the created pane through the existing attach adapter. The launch
tier is available with tmux, and the project's + opens a directory and
permission dialog on such a host.

Refs #218
Refs #222
@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing 351e09c (adversarial review in progress).

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review at 351e09c: 0 blocking. Injection holds. The cwd is allow-listed and single-quoted inside sh -c '…'. It went through both parsing layers with spaces, a leading - segment and a trailing space, and arrived intact in the stub tmux argv. Every other value is either generated or allow-listed. Host output is parsed strictly and re-validated before attach. --dangerously-skip-permissions is sent only on the explicit Dangerous Skip choice. The open-terminal extraction is verbatim, and local launch is untouched (full suite green). Stop issues kill-pane, never kill-session. Of 16 mutations replayed, 14 were killed. Being fixed: a wiring pin for the new IPC, the id case before the active-session guard, launch exempted from a failed refresh (like Send), the remoteAlias match in the pending re-injection, the two surviving mutations (4096/4097, strict === true), and parsing the last stdout line.

Lowercase the session id before the activeSessions guard, keep the launch
tier available after a failed last refresh (launch runs its own ssh), re-inject
a pending remote session under its own host's project, and read the launch
target from the last non-empty output line. Pin the main.js wiring as source
text and add the boundary and real-sh tests.

Refs #218
Refs #222

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 504acdd (delta from 351e09c), merged with main as cefb336: 0 blocking. Text pins now cover the new IPC wiring: launchBlockReason in the deps, the lowercased id before the guard, and registration under the descriptor id. Launch is exempt from a failed refresh, as Send is, while a host never synced still refuses. The pending re-injection matches on remoteAlias, covered by a test that runs the shipped loadProjects. Two cases are now pinned: 4096/4097 and strict === true. A real sh -c run checks the argv. The stdout parse reads the last line. All 9 mutations were killed, and the launch tests pass under Node 22 + c8.

@devsuitup
devsuitup merged commit 88deeee into main Oct 3, 2026
11 checks passed
@devsuitup
devsuitup deleted the feat/218-remote-launch branch October 3, 2026 09:11
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