Skip to content

Add SSH remote host support for tunneling daemon connections#2396

Open
bendavid wants to merge 6 commits into
getpaseo:mainfrom
bendavid:remotessh
Open

Add SSH remote host support for tunneling daemon connections#2396
bendavid wants to merge 6 commits into
getpaseo:mainfrom
bendavid:remotessh

Conversation

@bendavid

Copy link
Copy Markdown
Contributor

Summary

Connect to a remote Paseo daemon over SSH from the CLI (--host ssh://user@host:port) and the desktop app. The CLI ensures a daemon is running on the remote host (installing Paseo if needed), opens an SSH local port-forward, and tunnels the WebSocket connection through it — all over a single SSH connection.

Changes

SSH remote host support

  • CLI (packages/cli/src/ssh/): ssh-host-config (parse ssh:// URIs, normalize host configs), ssh-process (SSH exec, tunnel, askpass scripts), ssh-connection (single-connection ensure + tunnel), remote-daemon (ensure script: check/install/launch/wait in one SSH call)
  • CLI (local-daemon.ts): systemd-run --user --scope + loginctl enable-linger for cgroup escape so the daemon survives SSH disconnect
  • CLI (client.ts): --host ssh:// support with terminal askpass for interactive password prompts
  • Protocol (host-connection-schema.ts): SshHostConnectionSchema with host, port, user, remotePort, remoteHome, installDir
  • App (add-ssh-host-modal.tsx): add SSH host from the desktop UI with real-time progress display
  • App (host-runtime.ts, test-daemon-connection.ts): probeAndUpsertSshConnection, connectToDaemonViaSsh
  • Desktop (main.ts, preload.ts): SSH bridge IPC — single openTunnel handler that runs the ensure script inline and emits progress events

Single SSH connection

Instead of two separate SSH connections (ensure + tunnel) with ControlMaster multiplexing, a single SSH connection runs the ensure script inline and keeps the port forward alive with exec sleep infinity after the script exits. The ensure script is wrapped in a function (return instead of exit) so the shell survives. Readiness is detected via PROGRESS: messages from the ensure script rather than waitForLocalPort (which gives false positives when the forward is up but the daemon is not listening yet).

Fix 1006 transport error

The Electron renderer's Origin header (http://localhost:8083 from Metro) did not match the SSH tunnel port (e.g. 127.0.0.1:35173). The remote daemon's same-origin check rejected the port mismatch, causing WebSocket close code 1006. Fixed by stripping the Origin header for loopback WebSocket upgrades via session.webRequest.onBeforeSendHeaders — the SSH tunnel itself is the trust boundary.

SSH progress in desktop UI

The desktop main process emits paseo:event:ssh-progress events with progress messages from the ensure script. The app subscribes and displays them in the Add SSH Host modal while connecting.

Test plan

  • paseo ls -a --host ssh://submit82.mit.edu — single password prompt, agents listed, clean exit
  • Fresh daemon start on remote host (daemon stopped first) — ensure script installs/launches, tunnel connects
  • Desktop app SSH connection — progress messages displayed, WebSocket connects through tunnel
  • CLI typecheck, lint, 31 tests pass

bendavid added 5 commits July 24, 2026 16:57
Connect to a remote Paseo daemon over SSH from the CLI (--host
ssh://user@host:port) and the desktop app. The CLI ensures a daemon
is running on the remote host (installing Paseo if needed), opens an
SSH local port-forward, and tunnels the WebSocket connection through it.

CLI:
- ssh-host-config: parse ssh:// URIs, normalize host configs
- ssh-process: SSH exec, tunnel (SshTunnel), askpass scripts
- ssh-connection: connectViaSsh, ControlMaster connection sharing
- remote-daemon: ensure script (check/install/launch/wait in one SSH call)
- local-daemon: systemd-run --user --scope + loginctl enable-linger for
  cgroup escape so the daemon survives SSH disconnect
- client.ts: --host ssh:// support, PTY for interactive password prompts

Protocol:
- SshHostConnectionSchema with host, port, user, remotePort, remoteHome,
  installDir fields

App:
- AddSshHostModal: add SSH host from the desktop UI
- host-runtime: probeAndUpsertSshConnection, SSH connection handling
- test-daemon-connection: connectToDaemonViaSsh (ensure + tunnel + probe)
- host-connection types: SSH connection parsing and equality

Desktop:
- preload.ts: SSH bridge IPC (ensureRemoteDaemon, openTunnel, closeTunnel)
The Electron renderer's Origin header (http://localhost:8083 from Metro)
didn't match the SSH tunnel port (e.g. 127.0.0.1:35173). The remote
daemon's same-origin check rejected the port mismatch, causing the
WebSocket to close with code 1006.

Desktop: use session.webRequest.onBeforeSendHeaders to strip the Origin
header for WebSocket upgrades to ws://127.0.0.1:* and ws://localhost:*.
The SSH tunnel itself is the trust boundary, so no origin check is needed
for loopback connections.
Instead of two separate SSH connections (ensure + tunnel) with
ControlMaster multiplexing, open a single SSH connection that runs the
ensure script inline and keeps the port forward alive with 'exec sleep
infinity' after the script exits.

- SshTunnel.open accepts an ensureScript option: runs it on the remote
  host through the same SSH connection, then keeps alive with sleep
- Readiness is detected via PROGRESS: messages from the ensure script
  (not waitForLocalPort, which gives false positives when the forward
  is up but the daemon isn't listening yet)
- Removed ControlMaster, sshControlPath, and the separate
  ensureRemoteDaemon IPC handler from the desktop
- The desktop's openTunnel handler now takes the ensure script and
  runs everything in one connection
- CLI uses a terminal askpass (read -rs /dev/tty) for password prompts
  since the SSH stderr is piped for progress parsing
- Ensure script wrapped in a function (return instead of exit) so the
  shell survives after the script completes
The desktop main process emits paseo:event:ssh-progress events with
progress messages from the ensure script. The app subscribes to these
events in connectToDaemonViaSsh and forwards them through the
onProgress callback chain to the AddSshHostModal, which displays them
below the form while connecting.

- Desktop: emit ssh-progress events from openTunnel IPC handler
- App: onProgress in ProbeOptions, connectToDaemon deps, and
  probeAndUpsertSshConnection
- AddSshHostModal: progressMessage state and display
- host-runtime.ts: add installDir to probeAndUpsertSshConnection input
  type (was used but not declared, causing typecheck failure)
- local-daemon.ts: remove module-level cache from isSystemdRunUserAvailable,
  make it a pure function; move loginctl enable-linger into
  startLocalDaemonDetached; add forceNoSystemdRun option to bypass
  systemd-run detection in tests
- local-daemon.supervision.test.ts: pass forceNoSystemdRun: true instead
  of manipulating XDG_RUNTIME_DIR env
@bendavid
bendavid force-pushed the remotessh branch 2 times, most recently from de6f20e to d5bd61e Compare July 24, 2026 17:48
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