Add SSH remote host support for tunneling daemon connections - #2396
Open
bendavid wants to merge 7 commits into
Open
Add SSH remote host support for tunneling daemon connections#2396bendavid wants to merge 7 commits into
bendavid wants to merge 7 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
bendavid
marked this pull request as draft
July 26, 2026 12:26
bendavid
marked this pull request as ready for review
July 26, 2026 18:13
Addresses review of the SSH tunnel feature. The largest change is replacing the system password dialog with one Paseo renders itself. Askpass over a socket - Add `askpass-channel`: a unix socket plus a generated shell wrapper and Node helper, so SSH's prompts are answered by Paseo's own UI instead of `zenity`/`kdialog`/`osascript`. The helper is written at runtime, so there is no packaged asset to resolve inside an asar archive. - Drop the GTK-specific workarounds that dialog needed: underscores read as mnemonic accelerators, Pango markup parsing, AppleScript escaping. - Distinguish a key passphrase from an account password, and render the prompt in a globally mounted modal so reconnects outside the add-host flow are covered too. Prompts queue rather than replace each other. - Cancelling now ends the attempt. SSH ignores an askpass program's exit status and just retries, so cancellation is signalled out of band: the desktop aborts an `AbortSignal`, the CLI's terminal prompt writes a marker to stderr. SSH's three attempts are left intact so a typo stays retryable. Remote ensure script - Prefer a `paseo` already on `PATH` over installing a second copy, which would let a user on the remote host start a conflicting daemon against the same PASEO_HOME. Paseo only installs or upgrades inside installDir. - Pin the managed install to the local CLI version, falling back to `latest` when that version is not published (source checkouts, betas). - Pipe the script to `exec /bin/sh` over stdin rather than passing it as the remote command: sshd hands a remote command to the user's own shell, which may be fish or tcsh. The keepalive is now the absence of an exit, which also removes the non-portable `sleep infinity`. - Preserve exit codes and shell-quote every interpolated value. Correctness - Restore a `return` dropped from `switchToConnection`, which let a superseded switch tear down the connection that superseded it. - Route every client disposal through `closeClientAndTransport` so abandoned probes and aborted switches cannot orphan an ssh process. - Stop latency-probing non-active SSH connections: each probe spawned ssh, authenticated, and ran the ensure script, then threw the tunnel away, re-prompting password-auth hosts every cycle. - Leave `port` unset when unspecified so `~/.ssh/config` wins, and stop rendering `@host:undefined` when user and port are both absent. - Scope the desktop Origin strip to ports the main process forwards, and install the rule once per session rather than per window. - Surface SSH's stderr and exit code on failure; both were being lost. - Move systemd scope detection behind `DaemonLaunchRuntime` instead of a test-only flag on the production options type. UI - Localize the SSH flow across all locales; gate it on `getIsElectron()` rather than `isWeb`; make the connect cancellable; mark Port optional; submit on Enter from any field. Docs - Add docs/ssh.md covering the single-connection tunnel, the ensure script contract, which remote `paseo` runs, and the askpass and Origin gotchas. Note the SSH trust boundary in SECURITY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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) 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.See docs/ssh.md for the design and the gotchas behind it.
How it works
One connection. The remote command is
exec /bin/shwhile-Lforwards the port, and the ensure script is piped to that shell over stdin. sshd hands a remote command to the user's own shell (from the password database), which may be fish or tcsh — so the command is reduced to three metacharacter-free words that every shell agrees on, and the script never has to survive their quoting rules. It also keeps the script out of the remote host's process list.On success the script deliberately doesn't exit:
shreading from a pipe blocks for the next line, and that block is the keepalive. Closing stdin is the teardown.Readiness is a
READY:marker, not prose. The port forward accepts connections before the remote daemon is listening, so waiting on the local port is a false positive.Which remote
paseoruns, in order: anything already listening on the port → apaseoonPATH(global npm, system package manager) → a Paseo-managed install underinstallDir. PreferringPATHis about correctness, not disk: a second copy would let someone on the remote host start a conflicting daemon against the samePASEO_HOME. Paseo only ever installs or upgrades insideinstallDir. The managed copy is pinned to the local CLI version, falling back tolatestwhen that version isn't published (source checkouts, betas).Password prompts are rendered by Paseo. SSH only takes a secret from a program's stdout, so there is still a small askpass program — but it just relays over a unix socket to the app, which shows a styled, localized prompt that says whether it wants a key passphrase or an account password. The helper is written at runtime, so nothing needs unpacking from an asar archive. This replaced
zenity/kdialog/osascript, along with their GTK baggage (underscores read as mnemonic accelerators, Pango markup parsing, AppleScript escaping).Cancelling ends the attempt. SSH ignores an askpass program's exit status and simply retries, so cancellation is signalled out of band — the desktop aborts an
AbortSignal; the CLI's terminal prompt writes a marker to stderr. SSH's three attempts are left intact so a typo stays retryable.Changes
packages/cli/src/ssh/):ssh-host-config(parsessh://URIs),ssh-process(SSH args, tunnel, terminal askpass),ssh-connection(ensure + tunnel),remote-daemon(ensure script and failure messages),askpass-channel(socket relay for GUI prompts)local-daemon.ts):systemd-run --user --scope+loginctl enable-lingerso a detached daemon survives the session that started it, behind aDaemonLaunchRuntimeseamSshHostConnectionSchema.portis deliberately not defaulted — "unset" has to stay distinguishable from "22" so aPortdirective in~/.ssh/configwinsmain.ts,preload.ts): SSH bridge IPC — open/close tunnel, progress events, password relayNotes for review
StrictHostKeyChecking=accept-new). A changed key still fails, but a first connection from the GUI accepts an unknown key without showing a fingerprint. Noted in SECURITY.md./bin/shand Node. The local GUI prompt path is POSIX-only too (Windows would need a named pipe).Test plan
paseo ls -a --host ssh://submit82.mit.edu— single password prompt, agents listed, clean exitshagainst stubbed PATHs — ready/node-missing/npm-missing/PATH-preferred/unpublished-version paths