Add SSH remote host support for tunneling daemon connections#2396
Open
bendavid wants to merge 6 commits into
Open
Add SSH remote host support for tunneling daemon connections#2396bendavid wants to merge 6 commits into
bendavid wants to merge 6 commits into
Conversation
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
force-pushed
the
remotessh
branch
2 times, most recently
from
July 24, 2026 17:48
de6f20e to
d5bd61e
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.
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
packages/cli/src/ssh/):ssh-host-config(parsessh://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)local-daemon.ts):systemd-run --user --scope+loginctl enable-lingerfor cgroup escape so the daemon survives SSH disconnectclient.ts):--host ssh://support with terminal askpass for interactive password promptshost-connection-schema.ts):SshHostConnectionSchemawith host, port, user, remotePort, remoteHome, installDiradd-ssh-host-modal.tsx): add SSH host from the desktop UI with real-time progress displayhost-runtime.ts,test-daemon-connection.ts):probeAndUpsertSshConnection,connectToDaemonViaSshmain.ts,preload.ts): SSH bridge IPC — singleopenTunnelhandler that runs the ensure script inline and emits progress eventsSingle 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 infinityafter the script exits. The ensure script is wrapped in a function (returninstead ofexit) so the shell survives. Readiness is detected viaPROGRESS:messages from the ensure script rather thanwaitForLocalPort(which gives false positives when the forward is up but the daemon is not listening yet).Fix 1006 transport error
The Electron renderer's
Originheader (http://localhost:8083from 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 theOriginheader for loopback WebSocket upgrades viasession.webRequest.onBeforeSendHeaders— the SSH tunnel itself is the trust boundary.SSH progress in desktop UI
The desktop main process emits
paseo:event:ssh-progressevents 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