From 667664847ddfcf3ffb64f510d239a3054cdc987c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 28 Aug 2026 16:32:12 +0200 Subject: [PATCH] Connect to remote daemons over SSH (#3989) Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations. Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits. Closes #3949 Co-authored-by: James Strain Co-authored-by: Reid Levesque --- README.md | 4 +- docs/architecture.md | 2 +- docs/development.md | 7 +- .../src/components/add-host-method-modal.tsx | 43 +- .../components/add-remote-ssh-host-modal.tsx | 172 ++++++ .../app/src/components/welcome-screen.tsx | 33 +- .../daemon/desktop-daemon-transport.test.ts | 87 ++- .../daemon/desktop-daemon-transport.ts | 142 +++-- .../app/src/desktop/daemon/desktop-daemon.ts | 26 +- .../daemon/local-daemon-transport-rpc.ts | 4 +- .../daemon/test-local-daemon-transport-rpc.ts | 32 +- packages/app/src/i18n/resources/ar.ts | 22 + packages/app/src/i18n/resources/en.ts | 22 + packages/app/src/i18n/resources/es.ts | 22 + packages/app/src/i18n/resources/fr.ts | 22 + packages/app/src/i18n/resources/ja.ts | 22 + packages/app/src/i18n/resources/ko.ts | 22 + packages/app/src/i18n/resources/pt-BR.ts | 22 + packages/app/src/i18n/resources/ru.ts | 22 + packages/app/src/i18n/resources/zh-CN.ts | 22 + packages/app/src/runtime/host-runtime.ts | 53 +- packages/app/src/screens/settings-screen.tsx | 16 + .../app/src/screens/settings/host-page.tsx | 10 +- .../app/src/types/host-connection.test.ts | 46 ++ packages/app/src/types/host-connection.ts | 75 +++ .../src/utils/test-daemon-connection.test.ts | 37 +- .../app/src/utils/test-daemon-connection.ts | 67 +- packages/cli/src/commands/agent/ls.ts | 11 +- packages/cli/src/ssh/ssh-tunnel.ts | 90 +++ packages/cli/src/utils/client.ssh.test.ts | 63 ++ packages/cli/src/utils/client.ts | 24 +- packages/cli/src/utils/command-options.ts | 2 +- packages/desktop/src/daemon/daemon-manager.ts | 5 +- .../src/daemon/local-transport.test.ts | 204 +++++++ .../desktop/src/daemon/local-transport.ts | 573 ++++++++++++++---- packages/protocol/src/ssh-transport.test.ts | 66 ++ packages/protocol/src/ssh-transport.ts | 82 +++ public-docs/connectivity.md | 38 +- 38 files changed, 1961 insertions(+), 251 deletions(-) create mode 100644 packages/app/src/components/add-remote-ssh-host-modal.tsx create mode 100644 packages/cli/src/ssh/ssh-tunnel.ts create mode 100644 packages/cli/src/utils/client.ssh.test.ts create mode 100644 packages/desktop/src/daemon/local-transport.test.ts create mode 100644 packages/protocol/src/ssh-transport.test.ts create mode 100644 packages/protocol/src/ssh-transport.ts diff --git a/README.md b/README.md index d3b9022778..3478e15e36 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ paseo ls # list running agents paseo attach abc123 # stream live output paseo send abc123 "also add tests" # follow-up task -# run on a remote daemon -paseo --host workstation.local:6767 run "run the full test suite" +# run on a remote daemon; --cwd is a path on that host +paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite" ``` See the [full CLI reference](https://paseo.sh/docs/cli) for more. diff --git a/docs/architecture.md b/docs/architecture.md index 854e52fd45..f11c2ab2ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,7 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra Cross-platform React Native app that connects to one or more daemons. - Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. -- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state +- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon. - `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`. - `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. diff --git a/docs/development.md b/docs/development.md index 22b491220a..02ec592010 100644 --- a/docs/development.md +++ b/docs/development.md @@ -556,12 +556,15 @@ npm run cli -- daemon status # Check daemon status npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project ``` -Use `--host ` to point the CLI at a different daemon: +Use `--host` to point the CLI at a different daemon: ```bash -npm run cli -- --host localhost:7777 ls -a +npm run cli -- ls -a --host localhost:7777 +npm run cli -- ls -a --host ssh://user@host ``` +In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh). + Desktop integrations can focus an existing agent without creating one or sending a message. Use `paseo://h//agent/`, or run `paseo agent open `. The CLI reads the local daemon's server ID by diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 4a9bba5d68..b97c529bef 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -1,11 +1,19 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { isFdroidBuild } from "@/constants/build-profile"; import { isNative } from "@/constants/platform"; +import { isElectronRuntime } from "@/desktop/host"; +import type { Theme } from "@/styles/theme"; + +const ThemedQrCode = withUnistyles(QrCode); +const ThemedLink2 = withUnistyles(Link2); +const ThemedClipboardPaste = withUnistyles(ClipboardPaste); +const ThemedTerminal = withUnistyles(Terminal); +const foregroundIconMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const styles = StyleSheet.create((theme) => ({ option: { @@ -37,6 +45,7 @@ export interface AddHostMethodModalProps { visible: boolean; onClose: () => void; onDirectConnection: () => void; + onRemoteSsh: () => void; onScanQr: () => void; onPasteLink: () => void; } @@ -45,10 +54,10 @@ export function AddHostMethodModal({ visible, onClose, onDirectConnection, + onRemoteSsh, onScanQr, onPasteLink, }: AddHostMethodModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const header = useMemo(() => ({ title: t("pairing.connectionMethods.title") }), [t]); @@ -60,6 +69,10 @@ export function AddHostMethodModal({ onScanQr(); }, [onScanQr]); + const handleRemoteSsh = useCallback(() => { + onRemoteSsh(); + }, [onRemoteSsh]); + const handlePaste = useCallback(() => { onPasteLink(); }, [onPasteLink]); @@ -78,7 +91,7 @@ export function AddHostMethodModal({ accessibilityLabel={t("pairing.connectionMethods.direct.title")} testID="add-host-method-direct" > - + {t("pairing.connectionMethods.direct.title")} @@ -87,6 +100,24 @@ export function AddHostMethodModal({ + {isElectronRuntime() ? ( + + + + {t("pairing.connectionMethods.remoteSsh.title")} + + {t("pairing.connectionMethods.remoteSsh.description")} + + + + ) : null} + {isNative && !isFdroidBuild ? ( - + {t("pairing.connectionMethods.scanQr.title")} @@ -111,7 +142,7 @@ export function AddHostMethodModal({ accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")} testID="add-host-method-pair-link" > - + {t("pairing.connectionMethods.pasteLink.title")} diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/components/add-remote-ssh-host-modal.tsx new file mode 100644 index 0000000000..2e3e369f39 --- /dev/null +++ b/packages/app/src/components/add-remote-ssh-host-modal.tsx @@ -0,0 +1,172 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Terminal } from "lucide-react-native"; +import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; +import type { HostProfile } from "@/types/host-connection"; +import { useHostMutations, useHosts } from "@/runtime/host-runtime"; +import { Button } from "@/components/ui/button"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import type { EditingTextInputHandle } from "@/components/ui/text-input"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; +import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; + +const FLEX_ONE_STYLE = { flex: 1 } as const; +const ThemedTerminal = withUnistyles(Terminal); + +const styles = StyleSheet.create((theme) => ({ + helper: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[3], + marginTop: theme.spacing[2], + }, +})); + +export interface AddRemoteSshHostModalProps { + visible: boolean; + onClose: () => void; + onCancel?: () => void; + onSaved?: (result: { + profile: HostProfile; + serverId: string; + hostname: string | null; + isNewHost: boolean; + }) => void; +} + +export function AddRemoteSshHostModal({ + visible, + onClose, + onCancel, + onSaved, +}: AddRemoteSshHostModalProps) { + const { t } = useTranslation(); + const hosts = useHosts(); + const isCompact = useIsCompactFormFactor(); + const { probeAndUpsertRemoteSshConnection } = useHostMutations(); + const targetRef = useRef(""); + const inputRef = useRef(null); + const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const header = useMemo(() => ({ title: t("pairing.remoteSsh.title") }), [t]); + + const clear = useCallback(() => { + targetRef.current = ""; + inputRef.current?.replaceText(""); + setErrorMessage(""); + }, []); + + const handleClose = useCallback(() => { + if (isSaving) return; + clear(); + onClose(); + }, [clear, isSaving, onClose]); + + const handleCancel = useCallback(() => { + if (isSaving) return; + clear(); + (onCancel ?? onClose)(); + }, [clear, isSaving, onCancel, onClose]); + + const handleSave = useCallback(async () => { + if (isSaving) return; + const rawTarget = targetRef.current.trim(); + if (!rawTarget) { + setErrorMessage(t("pairing.remoteSsh.errors.targetRequired")); + return; + } + + let target: ReturnType; + try { + target = parseSshTransportUri(rawTarget); + } catch { + setErrorMessage(t("pairing.remoteSsh.errors.invalidTarget")); + return; + } + + let result: Awaited>; + try { + setIsSaving(true); + setErrorMessage(""); + result = await probeAndUpsertRemoteSshConnection(target); + } catch (error) { + const message = + error instanceof DaemonConnectionTestError + ? t("pairing.remoteSsh.errors.failedToConnect", { detail: error.message }) + : t("common.errors.unableToSave"); + setErrorMessage(message); + return; + } finally { + setIsSaving(false); + } + + clear(); + onClose(); + onSaved?.({ + ...result, + isNewHost: !hosts.some((profile) => profile.serverId === result.serverId), + }); + }, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]); + const handleTargetChange = useCallback((value: string) => { + targetRef.current = value; + }, []); + const handleSubmit = useCallback(() => void handleSave(), [handleSave]); + + return ( + + {t("pairing.remoteSsh.helper")} + + + + + + + + + ); +} diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 5fdfa7854a..1b3cdbe237 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -3,11 +3,19 @@ import { useTranslation } from "react-i18next"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; +import { + QrCode, + Link2, + ClipboardPaste, + ExternalLink, + Settings, + Terminal, +} from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; +import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; import { resolveAppVersion } from "@/utils/app-version"; @@ -17,9 +25,10 @@ import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; import { isFdroidBuild } from "@/constants/build-profile"; import { isWeb, isNative } from "@/constants/platform"; +import { isElectronRuntime } from "@/desktop/host"; interface WelcomeAction { - key: "scan-qr" | "direct-connection" | "paste-pairing-link"; + key: "scan-qr" | "direct-connection" | "remote-ssh" | "paste-pairing-link"; label: string; testID: string; primary: boolean; @@ -165,6 +174,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); + const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); const hosts = useHosts(); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); @@ -188,6 +198,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []); const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []); + const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []); + const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []); const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []); const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []); const handleScanQr = useCallback(() => { @@ -249,6 +261,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { }, ]; + if (isElectronRuntime()) { + actions.splice(1, 0, { + key: "remote-ssh", + label: t("pairing.connectionMethods.remoteSsh.title"), + testID: "welcome-remote-ssh", + primary: false, + icon: Terminal, + onPress: handleOpenRemoteSsh, + }); + } + const scrollContentContainerStyle = useMemo( () => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }], [theme.spacing, insets.bottom], @@ -300,6 +323,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { onSaved={handleHostSaved} /> + + { - it("emits open after the session resolves even if the rust open event raced earlier", async () => { + it("uses the main-process event as readiness when it races registration", async () => { const rpc = createFakeLocalDaemonTransportRpc(); - const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); + const cleanup = vi.fn(); + const transportFactory = createDesktopDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); const transport = transportFactory!({ url: LOCAL_URL }); @@ -15,32 +19,93 @@ describe("desktop-daemon-transport", () => { const onOpen = vi.fn(); transport.onOpen(onOpen); - rpc.emitEvent({ sessionId: "local-session-1", kind: "open" }); - expect(onOpen).not.toHaveBeenCalled(); + rpc.resolveListen(cleanup); + await Promise.resolve(); - rpc.resolveOpen("local-session-1"); + const sessionId = rpc.openCalls[0]?.sessionId ?? ""; + expect(sessionId).not.toBe(""); + rpc.emitEvent({ sessionId, kind: "open" }); + expect(onOpen).toHaveBeenCalledTimes(1); + + rpc.resolveRegistration(); await Promise.resolve(); expect(onOpen).toHaveBeenCalledTimes(1); }); - it("cleans up late async setup after the transport is closed", async () => { + it("does not start a session when listener setup finishes after close", async () => { const rpc = createFakeLocalDaemonTransportRpc(); const cleanup = vi.fn(); - const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); + const transportFactory = createDesktopDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); const transport = transportFactory!({ url: LOCAL_URL }); transport.close(); - rpc.resolveOpen("local-session-2"); rpc.resolveListen(cleanup); await Promise.resolve(); await Promise.resolve(); - expect(rpc.closedSessions).toEqual(["local-session-2"]); + expect(rpc.openCalls).toHaveLength(0); + expect(rpc.closedSessions).toHaveLength(1); expect(cleanup).toHaveBeenCalledTimes(1); }); + + it("cancels a registered session while readiness is pending", async () => { + const rpc = createFakeLocalDaemonTransportRpc(); + const transportFactory = createDesktopDaemonTransportFactory(rpc); + expect(transportFactory).not.toBeNull(); + + const transport = transportFactory!({ url: LOCAL_URL }); + rpc.resolveListen(vi.fn()); + await Promise.resolve(); + + const sessionId = rpc.openCalls[0]?.sessionId ?? ""; + expect(sessionId).not.toBe(""); + + transport.close(); + + expect(rpc.closedSessions).toEqual([sessionId]); + }); + + it("passes Remote SSH parameters to the desktop transport bridge", async () => { + const rpc = createFakeLocalDaemonTransportRpc(); + const transportFactory = createDesktopDaemonTransportFactory(rpc); + expect(transportFactory).not.toBeNull(); + + const url = buildDesktopDaemonTransportUrl({ + transportType: "ssh", + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 7777, + }); + transportFactory!({ url }); + rpc.resolveListen(vi.fn()); + await Promise.resolve(); + + expect(rpc.openCalls).toHaveLength(1); + expect(rpc.openCalls[0]?.target).toEqual({ + transportType: "ssh", + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 7777, + }); + }); + + it.each([0, 65536])("rejects an out-of-range Remote SSH port (%s)", (sshPort) => { + const transportFactory = createDesktopDaemonTransportFactory( + createFakeLocalDaemonTransportRpc(), + ); + expect(transportFactory).not.toBeNull(); + + const url = buildDesktopDaemonTransportUrl({ + transportType: "ssh", + host: "deploy@example.com", + sshPort, + }); + + expect(() => transportFactory!({ url })).toThrow("Invalid SSH transport target"); + }); }); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index 32f5ed6cf9..5ab5eabddb 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -2,13 +2,15 @@ import type { DaemonTransport, DaemonTransportFactory, } from "@getpaseo/client/internal/daemon-client"; -import type { LocalTransportTarget } from "./desktop-daemon"; +import { validatePort, validateSshHost } from "@getpaseo/protocol/ssh-transport"; +import type { DesktopDaemonTransportTarget } from "./desktop-daemon"; import { defaultLocalDaemonTransportRpc, + type LocalDaemonTransportEvent, type LocalDaemonTransportRpc, } from "./local-daemon-transport-rpc"; -const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; +const DESKTOP_TRANSPORT_SCHEME = "paseo+desktop:"; function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string { const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; @@ -28,21 +30,32 @@ function decodeBase64ToBytes(base64: string): Uint8Array { return bytes; } -export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string { - const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`); - url.searchParams.set("path", target.transportPath); +export function buildDesktopDaemonTransportUrl(target: DesktopDaemonTransportTarget): string { + const url = new URL(`${DESKTOP_TRANSPORT_SCHEME}//${target.transportType}`); + if (target.transportType === "ssh") { + url.searchParams.set("host", target.host); + if (target.sshPort !== undefined) { + url.searchParams.set("port", String(target.sshPort)); + } + if (target.daemonPort !== undefined) { + url.searchParams.set("daemonPort", String(target.daemonPort)); + } + } else { + url.searchParams.set("path", target.transportPath); + } return url.toString(); } -function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { +function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarget { const parsed = new URL(url); - if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) { - throw new Error(`Unsupported local transport URL: ${url}`); + if (parsed.protocol !== DESKTOP_TRANSPORT_SCHEME) { + throw new Error(`Unsupported desktop transport URL: ${url}`); } const transportType = parsed.hostname; + if (transportType === "ssh") return parseSshDesktopTransportUrl(parsed, url); const transportPath = parsed.searchParams.get("path")?.trim() ?? ""; if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) { - throw new Error(`Invalid local transport target: ${url}`); + throw new Error(`Invalid desktop transport target: ${url}`); } return { transportType, @@ -50,12 +63,33 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { }; } -export function createDesktopLocalDaemonTransportFactory( +function parseSshDesktopTransportUrl(parsed: URL, rawUrl: string): DesktopDaemonTransportTarget { + try { + const host = validateSshHost(parsed.searchParams.get("host") ?? ""); + const sshPort = parseOptionalUrlPort(parsed, "port", "SSH port"); + const daemonPort = parseOptionalUrlPort(parsed, "daemonPort", "Daemon port"); + return { + transportType: "ssh", + host, + ...(sshPort !== undefined ? { sshPort } : {}), + ...(daemonPort !== undefined ? { daemonPort } : {}), + }; + } catch (error) { + throw new Error(`Invalid SSH transport target: ${rawUrl}`, { cause: error }); + } +} + +function parseOptionalUrlPort(parsed: URL, key: string, label: string): number | undefined { + const value = parsed.searchParams.get(key); + return value === null ? undefined : validatePort(value, label); +} + +export function createDesktopDaemonTransportFactory( rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc, ): DaemonTransportFactory | null { return ({ url }) => { - const target = parseLocalDaemonTransportUrl(url); - let sessionId: string | null = null; + const target = parseDesktopDaemonTransportUrl(url); + const sessionId = `local-session-${globalThis.crypto.randomUUID()}`; let unlisten: (() => void) | null = null; let disposed = false; let didEmitOpen = false; @@ -80,6 +114,9 @@ export function createDesktopLocalDaemonTransportFactory( } }; const emitError = (event?: unknown) => { + if (disposed) { + return; + } for (const handler of errorHandlers) { handler(event); } @@ -90,61 +127,49 @@ export function createDesktopLocalDaemonTransportFactory( } }; - void rpc - .listenToEvents((payload) => { - if (disposed || !sessionId || payload.sessionId !== sessionId) { + const handleEvent = (payload: LocalDaemonTransportEvent) => { + if (disposed || payload.sessionId !== sessionId) { + return; + } + if (payload.kind === "open") { + emitOpen(); + return; + } + if (payload.kind === "message") { + if (payload.text) { + emitMessage(payload.text, false); return; } - if (payload.kind === "open") { - emitOpen(); - return; + if (payload.binaryBase64) { + emitMessage(decodeBase64ToBytes(payload.binaryBase64), true); } - if (payload.kind === "message") { - if (payload.text) { - emitMessage(payload.text, false); - return; - } - if (payload.binaryBase64) { - emitMessage(decodeBase64ToBytes(payload.binaryBase64), true); - } - return; - } - if (payload.kind === "close") { - emitClose(payload); - return; - } - emitError(payload.error ?? "Local daemon transport error"); - }) - .then((cleanup) => { + return; + } + if (payload.kind === "close") { + emitClose(payload); + return; + } + emitError(payload.error ?? "Local daemon transport error"); + }; + + void (async () => { + try { + const cleanup = await rpc.listenToEvents(handleEvent); if (disposed) { cleanup(); return; } unlisten = cleanup; - return; - }) - .catch((error) => { - emitError(error); - }); - void rpc - .openSession(target) - .then((id) => { - if (disposed) { - void rpc.closeSession(id).catch((error) => emitError(error)); - return; - } - sessionId = id; - emitOpen(); - return; - }) - .catch((error) => { + await rpc.openSession({ sessionId, target }); + } catch (error) { emitError(error); - }); + } + })(); const transport: DaemonTransport = { send: (data) => { - if (!sessionId) { + if (!didEmitOpen) { return; } if (typeof data === "string") { @@ -157,12 +182,11 @@ export function createDesktopLocalDaemonTransportFactory( void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error)); }, close: () => { - disposed = true; - const currentSessionId = sessionId; - sessionId = null; - if (currentSessionId) { - void rpc.closeSession(currentSessionId).catch((error) => emitError(error)); + if (disposed) { + return; } + disposed = true; + void rpc.closeSession(sessionId).catch((error) => emitError(error)); unlisten?.(); unlisten = null; }, diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 04cad6cc17..83fd0864cf 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -40,6 +40,22 @@ export interface LocalTransportTarget { transportPath: string; } +export interface RemoteSshTransportTarget { + [key: string]: unknown; + transportType: "ssh"; + host: string; + sshPort?: number; + daemonPort?: number; +} + +export type DesktopDaemonTransportTarget = LocalTransportTarget | RemoteSshTransportTarget; + +export interface OpenLocalTransportSessionInput { + [key: string]: unknown; + sessionId: string; + target: DesktopDaemonTransportTarget; +} + interface LocalTransportEventPayload { sessionId: string; kind: "open" | "message" | "close" | "error"; @@ -179,12 +195,10 @@ export async function listenToLocalTransportEvents( return typeof unlisten === "function" ? unlisten : () => {}; } -export async function openLocalTransportSession(target: LocalTransportTarget): Promise { - const raw = await invokeDesktopCommand("open_local_daemon_transport", target); - if (typeof raw !== "string" || raw.trim().length === 0) { - throw new Error("Unexpected local transport session response."); - } - return raw; +export async function openLocalTransportSession( + input: OpenLocalTransportSessionInput, +): Promise { + await invokeDesktopCommand("open_local_daemon_transport", input); } export async function sendLocalTransportMessage(input: { diff --git a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts index 54961107e9..5f0ee62bde 100644 --- a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts +++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts @@ -3,7 +3,7 @@ import { listenToLocalTransportEvents, openLocalTransportSession, sendLocalTransportMessage, - type LocalTransportTarget, + type OpenLocalTransportSessionInput, } from "./desktop-daemon"; export interface LocalDaemonTransportEvent { @@ -17,7 +17,7 @@ export interface LocalDaemonTransportEvent { } export interface LocalDaemonTransportRpc { - openSession(target: LocalTransportTarget): Promise; + openSession(input: OpenLocalTransportSessionInput): Promise; listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; closeSession(sessionId: string): Promise; diff --git a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts index 8f2214b038..71df0b9b13 100644 --- a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts +++ b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts @@ -1,4 +1,4 @@ -import type { LocalTransportTarget } from "./desktop-daemon"; +import type { OpenLocalTransportSessionInput } from "./desktop-daemon"; import type { LocalDaemonTransportEvent, LocalDaemonTransportRpc, @@ -11,10 +11,10 @@ export interface RecordedSend { } export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { - readonly openCalls: LocalTransportTarget[]; + readonly openCalls: OpenLocalTransportSessionInput[]; readonly recordedSends: RecordedSend[]; readonly closedSessions: string[]; - resolveOpen(sessionId: string): void; + resolveRegistration(): void; rejectOpen(error: Error): void; resolveListen(cleanup: () => void): void; rejectListen(error: Error): void; @@ -22,12 +22,12 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { } export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc { - const openCalls: LocalTransportTarget[] = []; + const openCalls: OpenLocalTransportSessionInput[] = []; const recordedSends: RecordedSend[] = []; const closedSessions: string[] = []; let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null; - let resolveOpenSession: ((sessionId: string) => void) | null = null; - let rejectOpenSession: ((error: Error) => void) | null = null; + let resolveRegistrationPromise: (() => void) | null = null; + let rejectRegistrationPromise: ((error: Error) => void) | null = null; let resolveListenPromise: ((cleanup: () => void) => void) | null = null; let rejectListenPromise: ((error: Error) => void) | null = null; @@ -35,11 +35,11 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc openCalls, recordedSends, closedSessions, - openSession(target) { - openCalls.push(target); - return new Promise((resolve, reject) => { - resolveOpenSession = resolve; - rejectOpenSession = reject; + openSession(input) { + openCalls.push(input); + return new Promise((resolve, reject) => { + resolveRegistrationPromise = resolve; + rejectRegistrationPromise = reject; }); }, listenToEvents(handler) { @@ -55,17 +55,17 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc async closeSession(sessionId) { closedSessions.push(sessionId); }, - resolveOpen(sessionId) { - if (!resolveOpenSession) { + resolveRegistration() { + if (!resolveRegistrationPromise) { throw new Error("openSession was not called"); } - resolveOpenSession(sessionId); + resolveRegistrationPromise(); }, rejectOpen(error) { - if (!rejectOpenSession) { + if (!rejectRegistrationPromise) { throw new Error("openSession was not called"); } - rejectOpenSession(error); + rejectRegistrationPromise(error); }, resolveListen(cleanup) { if (!resolveListenPromise) { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index aee38b2254..cc1eb185d3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1558,6 +1558,10 @@ export const ar: TranslationResources = { title: "اتصال مباشر", description: "الشبكة المحلية أو VPN.", }, + remoteSsh: { + title: "SSH عن بُعد", + description: "الاتصال عبر عميل SSH لسطح المكتب.", + }, scanQr: { title: "مسح رمز QR", description: "اتصال التتابع المشفر.", @@ -1610,6 +1614,23 @@ export const ar: TranslationResources = { details: "التفاصيل:{{detail}}", }, }, + remoteSsh: { + title: "SSH عن بُعد", + helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.", + fields: { + target: "مضيف SSH", + }, + actions: { + cancel: "إلغاء", + connect: "اتصال", + connecting: "جارٍ الاتصال...", + }, + errors: { + targetRequired: "مضيف SSH مطلوب", + invalidTarget: "أدخل مضيف ssh:// صالحًا", + failedToConnect: "تعذر الاتصال عبر SSH. {{detail}}", + }, + }, link: { title: "الصق رابط الاقتران", helper: "الصق رابط الاقتران من الخادم الخاص بك.", @@ -2244,6 +2265,7 @@ export const ar: TranslationResources = { badges: { relay: "تتابع", local: "محلي", + remoteSsh: "SSH عن بُعد", }, connections: { title: "اتصالات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5c9385ebbf..10f80ddc47 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1568,6 +1568,10 @@ export const en = { title: "Direct connection", description: "Local network or VPN.", }, + remoteSsh: { + title: "Remote SSH", + description: "Connect through the desktop SSH client.", + }, scanQr: { title: "Scan QR code", description: "Encrypted relay connection.", @@ -1619,6 +1623,23 @@ export const en = { details: "Details: {{detail}}", }, }, + remoteSsh: { + title: "Remote SSH", + helper: "Connect to a Paseo daemon running on the remote host.", + fields: { + target: "SSH host", + }, + actions: { + cancel: "Cancel", + connect: "Connect", + connecting: "Connecting...", + }, + errors: { + targetRequired: "SSH host is required", + invalidTarget: "Enter a valid ssh:// host", + failedToConnect: "Unable to connect over SSH. {{detail}}", + }, + }, link: { title: "Paste pairing link", helper: "Paste the pairing link from your server.", @@ -2342,6 +2363,7 @@ export const en = { badges: { relay: "Relay", local: "Local", + remoteSsh: "Remote SSH", }, connections: { title: "Connections", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ac8d52889a..e65c8c63ac 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1602,6 +1602,10 @@ export const es: TranslationResources = { title: "Conexión directa", description: "Red local o VPN.", }, + remoteSsh: { + title: "SSH remoto", + description: "Conéctate mediante el cliente SSH de escritorio.", + }, scanQr: { title: "Escanea el códigoQR", description: "Conexión de retransmisión cifrada.", @@ -1654,6 +1658,23 @@ export const es: TranslationResources = { details: "Detalles:{{detail}}", }, }, + remoteSsh: { + title: "SSH remoto", + helper: "Conéctate a un daemon de Paseo en el host remoto.", + fields: { + target: "Host SSH", + }, + actions: { + cancel: "Cancelar", + connect: "Conectar", + connecting: "Conectando...", + }, + errors: { + targetRequired: "El host SSH es obligatorio", + invalidTarget: "Introduce un host ssh:// válido", + failedToConnect: "No se pudo conectar por SSH. {{detail}}", + }, + }, link: { title: "Pegar enlace de emparejamiento", helper: "Pegue el enlace de emparejamiento de su servidor.", @@ -2295,6 +2316,7 @@ export const es: TranslationResources = { badges: { relay: "Relé", local: "Local", + remoteSsh: "SSH remoto", }, connections: { title: "Conexiones", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 057816a746..b18d54a70d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1606,6 +1606,10 @@ export const fr: TranslationResources = { title: "Connexion directe", description: "Réseau local ou VPN.", }, + remoteSsh: { + title: "SSH distant", + description: "Connectez-vous avec le client SSH de bureau.", + }, scanQr: { title: "Scanner le codeQR", description: "Connexion relais cryptée.", @@ -1658,6 +1662,23 @@ export const fr: TranslationResources = { details: "Détails:{{detail}}", }, }, + remoteSsh: { + title: "SSH distant", + helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.", + fields: { + target: "Hôte SSH", + }, + actions: { + cancel: "Annuler", + connect: "Connecter", + connecting: "Connexion...", + }, + errors: { + targetRequired: "L’hôte SSH est requis", + invalidTarget: "Saisissez un hôte ssh:// valide", + failedToConnect: "Connexion SSH impossible. {{detail}}", + }, + }, link: { title: "Coller le lien d'association", helper: "Collez le lien d'appairage depuis votre serveur.", @@ -2300,6 +2321,7 @@ export const fr: TranslationResources = { badges: { relay: "Relais", local: "Locale", + remoteSsh: "SSH distant", }, connections: { title: "Relations", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4317a67fdd..ea254d43c4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1573,6 +1573,10 @@ export const ja: TranslationResources = { title: "直接接続", description: "ローカルネットワークまたはVPN。", }, + remoteSsh: { + title: "リモート SSH", + description: "デスクトップの SSH クライアント経由で接続します。", + }, scanQr: { title: "QRコードをスキャン", description: "暗号化されたリレー接続。", @@ -1624,6 +1628,23 @@ export const ja: TranslationResources = { details: "詳細: {{detail}}", }, }, + remoteSsh: { + title: "リモート SSH", + helper: "リモートホストで動作する Paseo デーモンに接続します。", + fields: { + target: "SSH ホスト", + }, + actions: { + cancel: "キャンセル", + connect: "接続", + connecting: "接続中...", + }, + errors: { + targetRequired: "SSH ホストは必須です", + invalidTarget: "有効な ssh:// ホストを入力してください", + failedToConnect: "SSH で接続できません。{{detail}}", + }, + }, link: { title: "ペアリングリンクを貼り付け", helper: "サーバーからのペアリングリンクを貼り付けてください。", @@ -2263,6 +2284,7 @@ export const ja: TranslationResources = { badges: { relay: "リレー", local: "ローカル", + remoteSsh: "リモート SSH", }, connections: { title: "接続", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 20c1a7b2b1..1a4351aad4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1568,6 +1568,10 @@ export const ko: TranslationResources = { title: "직접 연결", description: "로컬 네트워크 또는 VPN.", }, + remoteSsh: { + title: "원격 SSH", + description: "데스크톱 SSH 클라이언트를 통해 연결합니다.", + }, scanQr: { title: "QR 코드 스캔", description: "암호화된 릴레이 연결.", @@ -1618,6 +1622,23 @@ export const ko: TranslationResources = { details: "세부 정보: {{detail}}", }, }, + remoteSsh: { + title: "원격 SSH", + helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.", + fields: { + target: "SSH 호스트", + }, + actions: { + cancel: "취소", + connect: "연결", + connecting: "연결 중...", + }, + errors: { + targetRequired: "SSH 호스트가 필요합니다", + invalidTarget: "유효한 ssh:// 호스트를 입력하세요", + failedToConnect: "SSH로 연결할 수 없습니다. {{detail}}", + }, + }, link: { title: "페어링 링크 붙여넣기", helper: "서버에서 받은 페어링 링크를 붙여넣으세요.", @@ -2254,6 +2275,7 @@ export const ko: TranslationResources = { badges: { relay: "릴레이", local: "로컬", + remoteSsh: "원격 SSH", }, connections: { title: "연결", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 17ba25eb5c..15346caa74 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1588,6 +1588,10 @@ export const ptBR: TranslationResources = { title: "Conexão direta", description: "Rede local ou VPN.", }, + remoteSsh: { + title: "SSH remoto", + description: "Conecte-se pelo cliente SSH do desktop.", + }, scanQr: { title: "Escanear QR code", description: "Conexão relay criptografada.", @@ -1640,6 +1644,23 @@ export const ptBR: TranslationResources = { details: "Detalhes: {{detail}}", }, }, + remoteSsh: { + title: "SSH remoto", + helper: "Conecte-se a um daemon Paseo no host remoto.", + fields: { + target: "Host SSH", + }, + actions: { + cancel: "Cancelar", + connect: "Conectar", + connecting: "Conectando...", + }, + errors: { + targetRequired: "O host SSH é obrigatório", + invalidTarget: "Insira um host ssh:// válido", + failedToConnect: "Não foi possível conectar por SSH. {{detail}}", + }, + }, link: { title: "Colar link de pareamento", helper: "Cole o link de pareamento do seu servidor.", @@ -2279,6 +2300,7 @@ export const ptBR: TranslationResources = { badges: { relay: "Relay", local: "Local", + remoteSsh: "SSH remoto", }, connections: { title: "Conexões", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d0cb4f6849..a6eb0cedb8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1585,6 +1585,10 @@ export const ru: TranslationResources = { title: "Прямое подключение", description: "Локальная сеть или VPN.", }, + remoteSsh: { + title: "Удалённый SSH", + description: "Подключение через SSH-клиент настольного приложения.", + }, scanQr: { title: "Сканировать QR-код", description: "Зашифрованное подключение через ретранслятор.", @@ -1637,6 +1641,23 @@ export const ru: TranslationResources = { details: "Подробности: {{detail}}", }, }, + remoteSsh: { + title: "Удалённый SSH", + helper: "Подключитесь к демону Paseo на удалённом хосте.", + fields: { + target: "Хост SSH", + }, + actions: { + cancel: "Отмена", + connect: "Подключить", + connecting: "Подключение...", + }, + errors: { + targetRequired: "Укажите хост SSH", + invalidTarget: "Укажите корректный хост ssh://", + failedToConnect: "Не удалось подключиться по SSH. {{detail}}", + }, + }, link: { title: "Вставить ссылку для сопряжения", helper: "Вставьте ссылку на сопряжение с вашего сервера.", @@ -2281,6 +2302,7 @@ export const ru: TranslationResources = { badges: { relay: "Ретранслятор", local: "Локальный", + remoteSsh: "Удалённый SSH", }, connections: { title: "Подключения", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d7e4d37970..2c19dd98d0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1541,6 +1541,10 @@ export const zhCN: TranslationResources = { title: "直接连接", description: "本地网络或 VPN。", }, + remoteSsh: { + title: "远程 SSH", + description: "通过桌面 SSH 客户端连接。", + }, scanQr: { title: "扫描二维码", description: "加密 relay 连接。", @@ -1591,6 +1595,23 @@ export const zhCN: TranslationResources = { details: "详情:{{detail}}", }, }, + remoteSsh: { + title: "远程 SSH", + helper: "连接到远程主机上运行的 Paseo 守护进程。", + fields: { + target: "SSH 主机", + }, + actions: { + cancel: "取消", + connect: "连接", + connecting: "正在连接...", + }, + errors: { + targetRequired: "SSH 主机为必填项", + invalidTarget: "请输入有效的 ssh:// 主机", + failedToConnect: "无法通过 SSH 连接。{{detail}}", + }, + }, link: { title: "粘贴配对链接", helper: "粘贴来自 server 的配对链接。", @@ -2218,6 +2239,7 @@ export const zhCN: TranslationResources = { badges: { relay: "Relay", local: "本地", + remoteSsh: "远程 SSH", }, connections: { title: "连接", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 8fef25f5fa..fc434365dd 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -9,6 +9,7 @@ import { } from "@getpaseo/client/internal/daemon-client"; import { connectionFromListen, + createRemoteSshHostConnection, normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, @@ -38,8 +39,8 @@ import { type ConnectionProbeState, } from "@/utils/connection-selection"; import { - buildLocalDaemonTransportUrl, - createDesktopLocalDaemonTransportFactory, + buildDesktopDaemonTransportUrl, + createDesktopDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; @@ -81,6 +82,7 @@ export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } | { type: "directPipe"; endpoint: string; display: "pipe" } + | { type: "remoteSsh"; endpoint: string; display: string } | { type: "relay"; endpoint: string; display: "relay" }; export type HostRuntimeAgentDirectoryStatus = @@ -211,6 +213,13 @@ function toActiveConnection(connection: HostConnection): ActiveConnection { display: "pipe", }; } + if (connection.type === "remoteSsh") { + return { + type: "remoteSsh", + endpoint: connection.host, + display: connection.host, + }; + } if (connection.type === "directTcp") { return { type: "directTcp", @@ -493,7 +502,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { - const localTransportFactory = createDesktopLocalDaemonTransportFactory(); + const desktopTransportFactory = createDesktopDaemonTransportFactory(); const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() }; const base = { suppressSendErrors: true, @@ -507,13 +516,28 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, - ...(localTransportFactory ? { transportFactory: localTransportFactory } : {}), - url: buildLocalDaemonTransportUrl({ + ...(desktopTransportFactory ? { transportFactory: desktopTransportFactory } : {}), + url: buildDesktopDaemonTransportUrl({ transportType: connection.type === "directSocket" ? "socket" : "pipe", transportPath: connection.path, }), }); } + if (connection.type === "remoteSsh") { + if (!desktopTransportFactory) { + throw new Error("Remote SSH is only available in the desktop app."); + } + return new DaemonClient({ + ...base, + transportFactory: desktopTransportFactory, + url: buildDesktopDaemonTransportUrl({ + transportType: "ssh", + host: connection.host, + ...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}), + ...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}), + }), + }); + } if (connection.type === "directTcp") { return new DaemonClient({ ...base, @@ -1754,6 +1778,18 @@ export class HostRuntimeStore { }); } + async probeAndUpsertRemoteSshConnection(input: { + host: string; + sshPort?: number; + daemonPort?: number; + label?: string; + }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + return this.probeAndUpsertConnection({ + label: input.label, + connection: createRemoteSshHostConnection(input), + }); + } + async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; @@ -2488,6 +2524,12 @@ export interface HostMutations { password?: string; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; + probeAndUpsertRemoteSshConnection: (input: { + host: string; + sshPort?: number; + daemonPort?: number; + label?: string; + }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; upsertRelayConnection: (input: { serverId: string; relayEndpoint: string; @@ -2513,6 +2555,7 @@ export function useHostMutations(): HostMutations { () => ({ upsertDirectConnection: (input) => store.upsertDirectConnection(input), probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), + probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input), upsertRelayConnection: (input) => store.upsertRelayConnection(input), upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9dfd9e7746..5e5e9be474 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -74,6 +74,7 @@ import { BackHeader } from "@/components/headers/back-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; +import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { EditorSection } from "@/screens/settings/editor-section"; @@ -1187,6 +1188,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); + const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [playbackTestResult, setPlaybackTestResult] = useState(null); @@ -1293,11 +1295,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(false); + setIsRemoteSshVisible(false); setIsPasteLinkVisible(false); }, []); const goBackToAddConnectionMethods = useCallback(() => { setIsDirectHostVisible(false); + setIsRemoteSshVisible(false); setIsPasteLinkVisible(false); setIsAddHostMethodVisible(true); }, []); @@ -1319,6 +1323,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti setIsDirectHostVisible(true); }, []); + const handleSelectRemoteSsh = useCallback(() => { + setIsAddHostMethodVisible(false); + setIsRemoteSshVisible(true); + }, []); + const handleSelectPasteLink = useCallback(() => { setIsAddHostMethodVisible(false); setIsPasteLinkVisible(true); @@ -1532,6 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti visible={isAddHostMethodVisible} onClose={closeAddConnectionFlow} onDirectConnection={handleSelectDirectConnection} + onRemoteSsh={handleSelectRemoteSsh} onPasteLink={handleSelectPasteLink} onScanQr={handleScanQr} /> @@ -1541,6 +1551,12 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti onCancel={goBackToAddConnectionMethods} onSaved={handleHostAdded} /> + , + text: t("settings.host.badges.remoteSsh"), + }; + } return { icon: , text: activeConnection.display, @@ -186,7 +195,6 @@ function HostStatusBadges({ serverId }: { serverId: string }) { } const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); - const statusDotStyle = useMemo( () => [styles.statusDot, { backgroundColor: statusDotColor }], [statusDotColor], diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 88194dc3b7..b7324807b6 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultHostAppearance } from "@/hosts/appearance"; import { + createRemoteSshHostConnection, normalizeStoredHostProfile, orderHostsLocalFirst, resolveActiveHostServerId, @@ -141,6 +142,51 @@ describe("normalizeStoredHostProfile", () => { expect(profile?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" }); }); + + it("normalizes stored Remote SSH connection parameters", () => { + const profile = normalizeStoredHostProfile({ + serverId: "srv_ssh", + connections: [ + { + type: "remoteSsh", + host: " deploy@example.com ", + sshPort: 2222, + daemonPort: 7777, + }, + ], + }); + + expect(profile?.connections[0]).toEqual({ + id: "ssh:deploy%40example.com:2222:7777", + type: "remoteSsh", + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 7777, + }); + }); +}); + +describe("createRemoteSshHostConnection", () => { + it("keeps optional SSH settings absent", () => { + expect(createRemoteSshHostConnection({ host: "build-box" })).toEqual({ + id: "ssh:build-box::", + type: "remoteSsh", + host: "build-box", + }); + }); + + it("rejects invalid SSH destinations and ports", () => { + expect(() => createRemoteSshHostConnection({ host: "" })).toThrow("SSH host is required"); + expect(() => createRemoteSshHostConnection({ host: "bad host" })).toThrow( + "SSH host is invalid", + ); + expect(() => createRemoteSshHostConnection({ host: "build-box", sshPort: 70000 })).toThrow( + "SSH port must be between 1 and 65535", + ); + expect(() => createRemoteSshHostConnection({ host: "build-box", daemonPort: 0 })).toThrow( + "Daemon port must be between 1 and 65535", + ); + }); }); describe("upsertHostConnectionInProfiles", () => { diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 2105312e88..4b6cd6d5a9 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -6,6 +6,11 @@ import { DirectTcpHostConnectionSchema, type DirectTcpHostConnection, } from "@getpaseo/protocol/host-connection-schema"; +import { + DEFAULT_SSH_DAEMON_PORT, + validatePort, + validateSshHost, +} from "@getpaseo/protocol/ssh-transport"; import { type HostAppearance, defaultHostAppearance, @@ -27,6 +32,14 @@ export interface DirectPipeHostConnection { path: string; } +export interface RemoteSshHostConnection { + id: string; + type: "remoteSsh"; + host: string; + sshPort?: number; + daemonPort?: number; +} + export interface RelayHostConnection { id: string; type: "relay"; @@ -39,6 +52,7 @@ export type HostConnection = | DirectTcpHostConnection | DirectSocketHostConnection | DirectPipeHostConnection + | RemoteSshHostConnection | RelayHostConnection; export type HostLifecycle = Record; @@ -123,6 +137,9 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool if (left.type === "directPipe" && right.type === "directPipe") { return left.path === right.path; } + if (left.type === "remoteSsh" && right.type === "remoteSsh") { + return remoteSshConnectionEquals(left, right); + } if (left.type === "relay" && right.type === "relay") { return ( left.relayEndpoint === right.relayEndpoint && @@ -134,6 +151,17 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool return false; } +function remoteSshConnectionEquals( + left: RemoteSshHostConnection, + right: RemoteSshHostConnection, +): boolean { + return ( + left.host === right.host && + left.sshPort === right.sshPort && + left.daemonPort === right.daemonPort + ); +} + function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -294,6 +322,35 @@ export function connectionFromListen(listen: string): HostConnection | null { } } +export function createRemoteSshHostConnection(input: { + host: string; + sshPort?: number; + daemonPort?: number; +}): RemoteSshHostConnection { + const host = validateSshHost(input.host); + const sshPort = input.sshPort === undefined ? undefined : validatePort(input.sshPort, "SSH port"); + + const daemonPort = + input.daemonPort === undefined || input.daemonPort === DEFAULT_SSH_DAEMON_PORT + ? undefined + : validatePort(input.daemonPort, "Daemon port"); + + const id = [ + "ssh", + encodeURIComponent(host), + sshPort === undefined ? "" : String(sshPort), + daemonPort === undefined ? "" : String(daemonPort), + ].join(":"); + + return { + id, + type: "remoteSsh", + host, + ...(sshPort !== undefined ? { sshPort } : {}), + ...(daemonPort !== undefined ? { daemonPort } : {}), + }; +} + const StoredHostConnectionSchema = z.discriminatedUnion("type", [ z.strictObject({ id: z.string().optional(), @@ -312,6 +369,13 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [ type: z.literal("directPipe"), path: z.string(), }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("remoteSsh"), + host: z.string(), + sshPort: z.number().optional(), + daemonPort: z.number().optional(), + }), z.strictObject({ id: z.string().optional(), type: z.literal("relay"), @@ -356,6 +420,17 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec const path = connection.path.trim(); return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; } + if (connection.type === "remoteSsh") { + try { + return createRemoteSshHostConnection({ + host: connection.host, + ...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}), + ...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}), + }); + } catch { + return null; + } + } if (connection.type === "relay") { try { const relayEndpoint = normalizeHostPort(connection.relayEndpoint); diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 5ccee7e750..cf6fca82e6 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -43,9 +43,13 @@ class FakeDaemonProbe { return "cid_shared_probe_test"; }, resolveAppVersion: () => null, - createLocalTransportFactory: () => null, - buildLocalTransportUrl: ({ transportType, transportPath }) => - `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`, + createDesktopTransportFactory: () => null, + buildDesktopTransportUrl: (target) => { + if (target.transportType === "ssh") { + return `paseo+desktop://ssh?host=${encodeURIComponent(target.host)}`; + } + return `paseo+desktop://${target.transportType}?path=${encodeURIComponent(target.transportPath)}`; + }, createClient: (config) => { const client = new FakeDaemonClient(this, config); this.createdClients.push(client); @@ -137,7 +141,32 @@ describe("test-daemon-connection connectToDaemon", () => { ); await result.client.close(); - expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"); + expect(probe.createdConfigs()[0]?.url).toBe("paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock"); + }); + + it("uses the desktop transport for Remote SSH connections", async () => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const transportFactory = vi.fn(); + const result = await connectToDaemon( + { + id: "ssh:deploy%40example.com:2222:%2Fkeys%2Fpaseo", + type: "remoteSsh", + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 7777, + }, + undefined, + { + ...probe.deps, + createDesktopTransportFactory: () => transportFactory, + }, + ); + await result.client.close(); + + expect(probe.createdConfigs()[0]).toMatchObject({ + url: "paseo+desktop://ssh?host=deploy%40example.com", + transportFactory, + }); }); it("passes direct TCP connection passwords into the client config", async () => { diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 381cb1afb5..a631bc7739 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -9,9 +9,10 @@ import { shouldUseTlsForDefaultHostedRelay, } from "./daemon-endpoints"; import { - buildLocalDaemonTransportUrl, - createDesktopLocalDaemonTransportFactory, + buildDesktopDaemonTransportUrl, + createDesktopDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; +import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daemon"; export interface DaemonProbeClient { readonly lastError: string | null; @@ -20,27 +21,45 @@ export interface DaemonProbeClient { getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null; } -interface LocalTransportUrlInput { - transportType: "socket" | "pipe"; - transportPath: string; -} - export interface DaemonConnectionDependencies { getClientId(): Promise; resolveAppVersion(): string | null; - createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; - buildLocalTransportUrl(input: LocalTransportUrlInput): string; + createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null; + buildDesktopTransportUrl(input: DesktopDaemonTransportTarget): string; createClient(config: DaemonClientConfig): TClient; } const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { getClientId: getOrCreateClientId, resolveAppVersion, - createLocalTransportFactory: createDesktopLocalDaemonTransportFactory, - buildLocalTransportUrl: buildLocalDaemonTransportUrl, + createDesktopTransportFactory: createDesktopDaemonTransportFactory, + buildDesktopTransportUrl: buildDesktopDaemonTransportUrl, createClient: (config) => new DaemonClient(config), }; +function buildRemoteSshClientConfig(input: { + connection: Extract; + base: Omit; + desktopTransportFactory: DaemonClientConfig["transportFactory"] | null; + buildDesktopTransportUrl: (target: DesktopDaemonTransportTarget) => string; +}): DaemonClientConfig { + if (!input.desktopTransportFactory) { + throw new Error("Remote SSH is only available in the desktop app."); + } + return { + ...input.base, + transportFactory: input.desktopTransportFactory, + url: input.buildDesktopTransportUrl({ + transportType: "ssh", + host: input.connection.host, + ...(input.connection.sshPort !== undefined ? { sshPort: input.connection.sshPort } : {}), + ...(input.connection.daemonPort !== undefined + ? { daemonPort: input.connection.daemonPort } + : {}), + }), + }; +} + function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -103,11 +122,14 @@ export async function buildClientConfig( }, deps: Pick< DaemonConnectionDependencies, - "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl" + | "getClientId" + | "resolveAppVersion" + | "createDesktopTransportFactory" + | "buildDesktopTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { const clientId = await deps.getClientId(); - const localTransportFactory = deps.createLocalTransportFactory(); + const desktopTransportFactory = deps.createDesktopTransportFactory(); const base = { clientId, clientType: "mobile" as const, @@ -117,21 +139,30 @@ export async function buildClientConfig( ...(options?.capabilities ? { capabilities: options.capabilities } : {}), ...(options?.trace ? { trace: options.trace } : {}), ...((connection.type === "directSocket" || connection.type === "directPipe") && - localTransportFactory - ? { transportFactory: localTransportFactory } + desktopTransportFactory + ? { transportFactory: desktopTransportFactory } : {}), }; if (connection.type === "directSocket" || connection.type === "directPipe") { return { ...base, - url: deps.buildLocalTransportUrl({ + url: deps.buildDesktopTransportUrl({ transportType: connection.type === "directSocket" ? "socket" : "pipe", transportPath: connection.path, }), }; } + if (connection.type === "remoteSsh") { + return buildRemoteSshClientConfig({ + connection, + base, + desktopTransportFactory, + buildDesktopTransportUrl: deps.buildDesktopTransportUrl, + }); + } + if (connection.type === "directTcp") { return { ...base, @@ -233,7 +264,9 @@ interface ProbeOptions { function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number { if (options?.timeoutMs) return options.timeoutMs; - return connection.type === "relay" ? 10_000 : 6_000; + if (connection.type === "relay") return 10_000; + if (connection.type === "remoteSsh") return 15_000; + return 6_000; } export function connectToDaemon( diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 1a7bddd312..37978d9011 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -106,13 +106,16 @@ export type AgentLsResult = ListResult; function daemonConnectionFailure(host: string, cause: unknown): CommandError { const reason = cause instanceof Error ? cause.message : String(cause); + const isSsh = host.trim().startsWith("ssh://"); return { code: "DAEMON_NOT_RUNNING", message: `Cannot reach the daemon at ${host}: ${reason}`, - details: [ - "Start a local daemon with: paseo daemon start", - "To use another daemon, pass --host or set PASEO_HOST.", - ].join("\n"), + details: isSsh + ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." + : [ + "Start a local daemon with: paseo daemon start", + "To use another daemon, pass --host or set PASEO_HOST.", + ].join("\n"), }; } diff --git a/packages/cli/src/ssh/ssh-tunnel.ts b/packages/cli/src/ssh/ssh-tunnel.ts new file mode 100644 index 0000000000..33900d0baa --- /dev/null +++ b/packages/cli/src/ssh/ssh-tunnel.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createServer, type Server, type Socket } from "node:net"; +import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport"; + +const SSH_STDERR_LIMIT = 8192; + +export interface SshTunnel { + endpoint: string; + close(): void; + failureDetail(): string | null; +} + +function formatSshFailure( + stderr: string, + code: number | null, + signal: NodeJS.Signals | null, +): string { + const detail = stderr.trim(); + if (detail) return detail; + if (signal) return `ssh exited with signal ${signal}`; + return `ssh exited with code ${code ?? "unknown"}`; +} + +export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { + return failure ?? (stderr.trim() || null); +} + +export function createSshTunnel(target: SshTransportTarget): Promise { + let server: Server | null = null; + let socket: Socket | null = null; + let child: ChildProcessWithoutNullStreams | null = null; + let stderr = ""; + let failure: string | null = null; + + function close(): void { + server?.close(); + server = null; + socket?.destroy(); + socket = null; + if (child && !child.killed) child.kill(); + child = null; + } + + return new Promise((resolve, reject) => { + server = createServer((acceptedSocket) => { + socket = acceptedSocket; + server?.close(); + server = null; + + child = spawn("ssh", buildSshTunnelArgs(target), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); + }); + child.on("error", (error) => { + failure = error.message; + acceptedSocket.destroy(error); + }); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); + acceptedSocket.destroy(failure ? new Error(failure) : undefined); + }); + acceptedSocket.on("error", () => undefined); + acceptedSocket.on("close", () => { + if (child && !child.killed) child.kill(); + }); + acceptedSocket.pipe(child.stdin); + child.stdout.pipe(acceptedSocket); + }); + server.once("error", (error) => { + close(); + reject(error); + }); + server.listen(0, "127.0.0.1", () => { + const address = server?.address(); + if (!address || typeof address === "string") { + close(); + reject(new Error("Failed to allocate the SSH tunnel port")); + return; + } + resolve({ + endpoint: `127.0.0.1:${address.port}`, + close, + failureDetail: () => resolveSshFailureDetail(failure, stderr), + }); + }); + }); +} diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts new file mode 100644 index 0000000000..4311db71b5 --- /dev/null +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js"; +import { connectToDaemon } from "./client.js"; + +const mocks = vi.hoisted(() => ({ + configs: [] as Array>, + createSshTunnel: vi.fn(), +})); + +vi.mock("@getpaseo/client/internal/daemon-client", () => ({ + DaemonClient: class { + lastError = null; + + constructor(config: Record) { + mocks.configs.push(config); + } + + async connect() {} + async close() {} + }, +})); + +vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({ + ...(await importOriginal()), + createSshTunnel: mocks.createSshTunnel, +})); +vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" })); + +describe("CLI SSH transport", () => { + beforeEach(() => { + mocks.configs.length = 0; + mocks.createSshTunnel.mockReset(); + mocks.createSshTunnel.mockResolvedValue({ + endpoint: "127.0.0.1:4567", + close: vi.fn(), + failureDetail: () => null, + }); + }); + + it("surfaces SSH stderr before the child exit event settles", () => { + expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe( + "Host key verification failed.", + ); + expect(resolveSshFailureDetail("ssh exited with code 255", "earlier stderr")).toBe( + "ssh exited with code 255", + ); + }); + + it("routes an SSH host through a local tunnel", async () => { + await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" }); + + expect(mocks.createSshTunnel).toHaveBeenCalledWith({ + host: "deploy@build-box", + sshPort: 2222, + daemonPort: 7777, + }); + expect(mocks.configs[0]).toMatchObject({ + url: "ws://127.0.0.1:4567/ws", + clientId: "cli-test-id", + clientType: "cli", + }); + }); +}); diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index e2cd972238..b5140ddda9 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -11,11 +11,13 @@ import { parseConnectionOfferFromUrl, type ConnectionOffer, } from "@getpaseo/protocol/connection-offer"; +import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; import path from "node:path"; import { WebSocket } from "ws"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; +import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { host?: string; @@ -59,7 +61,9 @@ export function buildDaemonConnectionCommandError(options: { return { code: "DAEMON_NOT_RUNNING", message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", + details: host.trim().startsWith("ssh://") + ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." + : "Start the daemon with: paseo daemon start", }; } @@ -357,6 +361,24 @@ export async function connectToDaemon(options?: ConnectOptions): Promise readManagedFileBase64(args ?? {}), delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}), garbage_collect_attachment_files: (args) => garbageCollectManagedAttachmentFiles(args ?? {}), - open_local_daemon_transport: async (args) => { - const target = args as { transportType: "socket" | "pipe"; transportPath: string }; - return await openLocalTransportSession(target); - }, + open_local_daemon_transport: async (args) => await openLocalTransportSession(args), send_local_daemon_transport_message: async (args) => { await sendLocalTransportMessage( args as { sessionId: string; text?: string; binaryBase64?: string }, diff --git a/packages/desktop/src/daemon/local-transport.test.ts b/packages/desktop/src/daemon/local-transport.test.ts new file mode 100644 index 0000000000..202268e433 --- /dev/null +++ b/packages/desktop/src/daemon/local-transport.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildSshArgs, + createLocalTransportManager, + LOCAL_TRANSPORT_SETUP_TIMEOUT_MS, + parseTransportTarget, + resolveSshFailureDetail, + type TransportEndpoint, + type TransportEventPayload, + type TransportWebSocket, +} from "./local-transport"; + +const SESSION_INPUT = { + sessionId: "local-session-test", + target: { transportType: "ssh", host: "build-box" }, +} as const; + +function createConnectingSocket(): TransportWebSocket & { + close: ReturnType; + terminate: ReturnType; +} { + return { + readyState: 0, + once: vi.fn(), + on: vi.fn(), + send: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), + } as unknown as TransportWebSocket & { + close: ReturnType; + terminate: ReturnType; + }; +} + +function createEndpoint(): TransportEndpoint & { close: ReturnType } { + return { + url: "ws://127.0.0.1:12345/ws", + close: vi.fn(), + failureDetail: () => null, + }; +} + +interface ScheduledTimeout { + callback: () => void; + delayMs: number; + cancelled: boolean; +} + +function createManagerHarness( + resolveEndpoint: () => Promise, + sockets: TransportWebSocket[], +) { + const events: TransportEventPayload[] = []; + const scheduledTimeouts: ScheduledTimeout[] = []; + const createWebSocket = vi.fn(() => { + const socket = sockets.shift(); + if (!socket) { + throw new Error("No fake WebSocket is available"); + } + return socket; + }); + const manager = createLocalTransportManager({ + resolveEndpoint, + createWebSocket, + scheduleTimeout(callback, delayMs) { + const scheduled = { callback, delayMs, cancelled: false }; + scheduledTimeouts.push(scheduled); + return () => { + scheduled.cancelled = true; + }; + }, + emitEvent: (event) => events.push(event), + }); + return { createWebSocket, events, manager, scheduledTimeouts }; +} + +describe("Remote SSH desktop transport", () => { + it("builds a batch-mode SSH stdio tunnel with optional connection settings", () => { + expect( + buildSshArgs({ + transportType: "ssh", + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 7777, + }), + ).toEqual([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ClearAllForwardings=yes", + "-o", + "ExitOnForwardFailure=yes", + "-p", + "2222", + "-W", + "127.0.0.1:7777", + "deploy@example.com", + ]); + }); + + it("rejects unsafe SSH targets at the IPC boundary", () => { + expect(() => + parseTransportTarget({ transportType: "ssh", host: "-oProxyCommand=bad" }), + ).toThrow("SSH host is invalid"); + expect(() => + parseTransportTarget({ transportType: "ssh", host: "build-box", sshPort: 0 }), + ).toThrow("SSH port must be between 1 and 65535"); + expect(() => + parseTransportTarget({ transportType: "ssh", host: "build-box", daemonPort: 65536 }), + ).toThrow("Daemon port must be between 1 and 65535"); + }); + + it("surfaces SSH stderr before the child exit event settles", () => { + expect(resolveSshFailureDetail(null, "Permission denied.\n")).toBe("Permission denied."); + expect(resolveSshFailureDetail("ssh exited with code 255", "earlier stderr")).toBe( + "ssh exited with code 255", + ); + }); +}); + +describe("local transport session lifecycle", () => { + it("releases an opening transport when its setup deadline expires", async () => { + const firstEndpoint = createEndpoint(); + const secondEndpoint = createEndpoint(); + const firstSocket = createConnectingSocket(); + const secondSocket = createConnectingSocket(); + const endpoints = [firstEndpoint, secondEndpoint]; + const { events, manager, scheduledTimeouts } = createManagerHarness( + async () => endpoints.shift() ?? createEndpoint(), + [firstSocket, secondSocket], + ); + + manager.open(SESSION_INPUT); + await Promise.resolve(); + + expect(scheduledTimeouts[0]?.delayMs).toBe(LOCAL_TRANSPORT_SETUP_TIMEOUT_MS); + scheduledTimeouts[0]?.callback(); + + expect(firstSocket.terminate).toHaveBeenCalledTimes(1); + expect(firstEndpoint.close).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + { + sessionId: SESSION_INPUT.sessionId, + kind: "error", + error: "Connection to Remote SSH host build-box timed out during setup.", + }, + ]); + + manager.close(SESSION_INPUT.sessionId); + expect(firstSocket.terminate).toHaveBeenCalledTimes(1); + expect(firstEndpoint.close).toHaveBeenCalledTimes(1); + + expect(() => manager.open(SESSION_INPUT)).not.toThrow(); + await Promise.resolve(); + manager.close(SESSION_INPUT.sessionId); + expect(secondSocket.terminate).toHaveBeenCalledTimes(1); + expect(secondEndpoint.close).toHaveBeenCalledTimes(1); + }); + + it("releases a connecting transport when the caller closes it", async () => { + const endpoint = createEndpoint(); + const socket = createConnectingSocket(); + const { events, manager, scheduledTimeouts } = createManagerHarness( + async () => endpoint, + [socket], + ); + + manager.open(SESSION_INPUT); + await Promise.resolve(); + manager.close(SESSION_INPUT.sessionId); + manager.close(SESSION_INPUT.sessionId); + + expect(socket.terminate).toHaveBeenCalledTimes(1); + expect(endpoint.close).toHaveBeenCalledTimes(1); + expect(scheduledTimeouts[0]?.cancelled).toBe(true); + expect(events).toEqual([]); + }); + + it("closes an endpoint that resolves after the caller cancels setup", async () => { + const endpoint = createEndpoint(); + let resolveEndpoint: ((endpoint: TransportEndpoint) => void) | null = null; + const endpointPromise = new Promise((resolve) => { + resolveEndpoint = resolve; + }); + const socket = createConnectingSocket(); + const { createWebSocket, events, manager } = createManagerHarness( + () => endpointPromise, + [socket], + ); + + manager.open(SESSION_INPUT); + manager.close(SESSION_INPUT.sessionId); + resolveEndpoint?.(endpoint); + await Promise.resolve(); + await Promise.resolve(); + + expect(endpoint.close).toHaveBeenCalledTimes(1); + expect(createWebSocket).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); +}); diff --git a/packages/desktop/src/daemon/local-transport.ts b/packages/desktop/src/daemon/local-transport.ts index c3d5227f43..9c47ecb843 100644 --- a/packages/desktop/src/daemon/local-transport.ts +++ b/packages/desktop/src/daemon/local-transport.ts @@ -1,12 +1,29 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createServer, type Server, type Socket } from "node:net"; +import { + buildSshTunnelArgs, + DEFAULT_SSH_DAEMON_PORT, + validatePort, + validateSshHost, +} from "@getpaseo/protocol/ssh-transport"; import { BrowserWindow } from "electron"; import { WebSocket, type RawData } from "ws"; -interface TransportTarget { +export interface LocalTransportTarget { transportType: "socket" | "pipe"; transportPath: string; } -interface TransportEventPayload { +export interface SshTransportTarget { + transportType: "ssh"; + host: string; + sshPort?: number; + daemonPort?: number; +} + +export type TransportTarget = LocalTransportTarget | SshTransportTarget; + +export interface TransportEventPayload { sessionId: string; kind: "open" | "message" | "close" | "error"; text?: string | null; @@ -18,14 +35,52 @@ interface TransportEventPayload { interface Session { id: string; - ws: WebSocket; - state: "opening" | "open" | "closing" | "closed"; + target: TransportTarget; + ws: TransportWebSocket | null; + state: "opening" | "open" | "closed"; + closeTarget: (() => void) | null; + cancelSetupDeadline: () => void; +} + +interface OpenTransportSessionInput { + sessionId: string; + target: TransportTarget; +} + +export interface TransportEndpoint { + url: string; + close: () => void; + failureDetail: () => string | null; +} + +export interface TransportWebSocket { + readonly readyState: number; + once(event: "open", listener: () => void): void; + on(event: "message", listener: (data: RawData, isBinary: boolean) => void): void; + on(event: "close", listener: (code: number, reason?: Buffer | string) => void): void; + on(event: "error", listener: (error: Error) => void): void; + send(data: string | Buffer, callback: (error?: Error) => void): void; + close(): void; + terminate(): void; +} + +export interface LocalTransportManagerDependencies { + resolveEndpoint(target: TransportTarget): Promise; + createWebSocket(url: string): TransportWebSocket; + scheduleTimeout(callback: () => void, delayMs: number): () => void; + emitEvent(payload: TransportEventPayload): void; +} + +export interface LocalTransportManager { + open(rawInput: unknown): void; + send(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; + close(sessionId: string): void; + closeAll(): void; } const WS_ENDPOINT_PATH = "/ws"; - -let nextSessionId = 0; -const sessions = new Map(); +const SSH_STDERR_LIMIT = 8192; +export const LOCAL_TRANSPORT_SETUP_TIMEOUT_MS = 30_000; function emitTransportEvent(payload: TransportEventPayload): void { for (const win of BrowserWindow.getAllWindows()) { @@ -43,15 +98,183 @@ function emitTransportEvent(payload: TransportEventPayload): void { * The part before `:` is the IPC path, the part after is the HTTP request * path used during the WebSocket upgrade handshake. */ -function buildLocalWebSocketUrl(target: TransportTarget): string { +function buildLocalWebSocketUrl(target: LocalTransportTarget): string { const ipcPath = target.transportPath; return `ws+unix://${ipcPath}:${WS_ENDPOINT_PATH}`; } function describeTransportTarget(target: TransportTarget): string { + if (target.transportType === "ssh") { + return `Remote SSH host ${target.host}`; + } return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket"; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseTransportTarget(value: unknown): TransportTarget { + if (!isRecord(value)) { + throw new Error("Desktop transport target must be an object."); + } + + if (value.transportType === "socket" || value.transportType === "pipe") { + const transportPath = typeof value.transportPath === "string" ? value.transportPath.trim() : ""; + if (!transportPath) { + throw new Error("Local transport path is required."); + } + return { transportType: value.transportType, transportPath }; + } + + if (value.transportType === "ssh") return parseSshTransportTarget(value); + throw new Error("Unsupported desktop transport type."); +} + +function parseSshTransportTarget(value: Record): SshTransportTarget { + const host = validateSshHost(typeof value.host === "string" ? value.host : ""); + const sshPort = + value.sshPort === undefined ? undefined : validatePortValue(value.sshPort, "SSH port"); + const daemonPort = + value.daemonPort === undefined ? undefined : validatePortValue(value.daemonPort, "Daemon port"); + return { + transportType: "ssh", + host, + ...(sshPort !== undefined ? { sshPort } : {}), + ...(daemonPort !== undefined ? { daemonPort } : {}), + }; +} + +function validatePortValue(value: unknown, label: string): number { + if (typeof value !== "number") throw new Error(`${label} must be between 1 and 65535.`); + try { + return validatePort(value, label); + } catch { + throw new Error(`${label} must be between 1 and 65535.`); + } +} + +function parseOpenTransportSessionInput(value: unknown): OpenTransportSessionInput { + if (!isRecord(value)) { + throw new Error("Desktop transport open input must be an object."); + } + + const sessionId = typeof value.sessionId === "string" ? value.sessionId.trim() : ""; + if (!/^[A-Za-z0-9_-]{1,128}$/u.test(sessionId)) { + throw new Error("Desktop transport session ID is invalid."); + } + + return { + sessionId, + target: parseTransportTarget(value.target), + }; +} + +export function buildSshArgs(target: SshTransportTarget): string[] { + return buildSshTunnelArgs({ + host: target.host, + ...(target.sshPort !== undefined ? { sshPort: target.sshPort } : {}), + daemonPort: target.daemonPort ?? DEFAULT_SSH_DAEMON_PORT, + }); +} + +function formatSshFailure( + stderr: string, + code: number | null, + signal: NodeJS.Signals | null, +): string { + const detail = stderr.trim(); + if (detail) return detail; + if (signal) return `ssh exited with signal ${signal}`; + return `ssh exited with code ${code ?? "unknown"}`; +} + +export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { + return failure ?? (stderr.trim() || null); +} + +function createSshProxy(target: SshTransportTarget): Promise { + let server: Server | null = null; + let socket: Socket | null = null; + let child: ChildProcessWithoutNullStreams | null = null; + let stderr = ""; + let failure: string | null = null; + + function close(): void { + server?.close(); + server = null; + socket?.destroy(); + socket = null; + if (child && !child.killed) { + child.kill(); + } + child = null; + } + + return new Promise((resolve, reject) => { + server = createServer((acceptedSocket) => { + socket = acceptedSocket; + server?.close(); + server = null; + + child = spawn("ssh", buildSshArgs(target), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); + }); + child.on("error", (error) => { + failure = error.message; + acceptedSocket.destroy(error); + }); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) { + failure = formatSshFailure(stderr, code, signal); + } + acceptedSocket.destroy(failure ? new Error(failure) : undefined); + }); + + acceptedSocket.on("error", () => undefined); + acceptedSocket.on("close", () => { + if (child && !child.killed) { + child.kill(); + } + }); + acceptedSocket.pipe(child.stdin); + child.stdout.pipe(acceptedSocket); + }); + server.once("error", (error) => { + close(); + reject(error); + }); + server.listen(0, "127.0.0.1", () => { + const address = server?.address(); + if (!address || typeof address === "string") { + close(); + reject(new Error("Failed to allocate the Remote SSH proxy port.")); + return; + } + resolve({ + url: `ws://127.0.0.1:${address.port}${WS_ENDPOINT_PATH}`, + close, + failureDetail: () => resolveSshFailureDetail(failure, stderr), + }); + }); + }); +} + +async function resolveTransportEndpoint(target: TransportTarget): Promise { + if (target.transportType === "ssh") { + return createSshProxy(target); + } + return { + url: buildLocalWebSocketUrl(target), + close: () => undefined, + failureDetail: () => null, + }; +} + function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer { if (typeof input.text === "string") { return input.text; @@ -64,94 +287,272 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string }) throw new Error("Local transport send requires text or binary payload."); } -export function openLocalTransportSession(target: TransportTarget): Promise { - const sessionId = `local-session-${++nextSessionId}`; - const url = buildLocalWebSocketUrl(target); +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} - return new Promise((resolve, reject) => { - const ws = new WebSocket(url); - const session: Session = { - id: sessionId, - ws, - state: "opening", - }; - sessions.set(sessionId, session); +export function createLocalTransportManager( + deps: LocalTransportManagerDependencies, +): LocalTransportManager { + const sessions = new Map(); - let openSettled = false; + function isCurrent(session: Session): boolean { + return sessions.get(session.id) === session && session.state !== "closed"; + } - const finalizeOpenFailure = (message: string): void => { - if (openSettled) { + function emitEvent(payload: TransportEventPayload): void { + try { + deps.emitEvent(payload); + } catch { + // A renderer may disappear while the main process is broadcasting an event. + } + } + + function disposeSession(session: Session): void { + if (session.state === "closed") { + return; + } + + session.state = "closed"; + try { + session.cancelSetupDeadline(); + } catch { + // Continue releasing the socket and endpoint if a runtime adapter fails. + } + session.cancelSetupDeadline = () => undefined; + if (sessions.get(session.id) === session) { + sessions.delete(session.id); + } + + const ws = session.ws; + session.ws = null; + if (ws) { + try { + if (ws.readyState === WebSocket.CONNECTING) { + ws.terminate(); + } else if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } catch { + // Closing is best-effort; the endpoint still owns the underlying transport. + } + } + + const closeTarget = session.closeTarget; + session.closeTarget = null; + try { + closeTarget?.(); + } catch { + // Closing is best-effort and must not prevent the registry from being released. + } + } + + function failOpeningSession(session: Session, message: string): void { + if (!isCurrent(session) || session.state !== "opening") { + return; + } + disposeSession(session); + emitEvent({ sessionId: session.id, kind: "error", error: message }); + } + + async function connectSession(session: Session): Promise { + let endpoint: TransportEndpoint; + try { + endpoint = await deps.resolveEndpoint(session.target); + } catch (error) { + failOpeningSession( + session, + `Failed to connect to ${describeTransportTarget(session.target)}: ${getErrorMessage(error)}`, + ); + return; + } + + if (!isCurrent(session) || session.state !== "opening") { + try { + endpoint.close(); + } catch { + // The cancelled session no longer owns any other resources to release. + } + return; + } + + let targetClosed = false; + session.closeTarget = () => { + if (targetClosed) { return; } - - openSettled = true; - session.state = "closed"; - sessions.delete(sessionId); - reject(new Error(message)); + targetClosed = true; + endpoint.close(); }; + let ws: TransportWebSocket; + try { + ws = deps.createWebSocket(endpoint.url); + } catch (error) { + failOpeningSession( + session, + `Failed to connect to ${describeTransportTarget(session.target)}: ${getErrorMessage(error)}`, + ); + return; + } + session.ws = ws; + ws.once("open", () => { - openSettled = true; + if (!isCurrent(session) || session.state !== "opening") { + return; + } + session.cancelSetupDeadline(); + session.cancelSetupDeadline = () => undefined; session.state = "open"; - resolve(sessionId); - emitTransportEvent({ sessionId, kind: "open" }); + emitEvent({ sessionId: session.id, kind: "open" }); }); ws.on("message", (data: RawData, isBinary: boolean) => { + if (!isCurrent(session) || session.state !== "open") { + return; + } if (isBinary || data instanceof Buffer) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); - emitTransportEvent({ - sessionId, + emitEvent({ + sessionId: session.id, kind: "message", binaryBase64: buf.toString("base64"), }); return; } - emitTransportEvent({ - sessionId, + emitEvent({ + sessionId: session.id, kind: "message", text: data.toString(), }); }); ws.on("close", (code: number, reason?: Buffer | string) => { - const shouldEmitClose = session.state === "open" || session.state === "closing"; - session.state = "closed"; - sessions.delete(sessionId); - - if (!openSettled) { - finalizeOpenFailure( - `${describeTransportTarget(target)} closed before the session became ready.`, + if (!isCurrent(session)) { + return; + } + if (session.state === "opening") { + const failureDetail = endpoint.failureDetail(); + const detail = failureDetail ? `: ${failureDetail}` : ""; + failOpeningSession( + session, + `${describeTransportTarget(session.target)} closed before the session became ready${detail}.`, ); return; } - if (shouldEmitClose) { - emitTransportEvent({ - sessionId, - kind: "close", - code, - reason: reason ? String(reason) : "", - }); - } - }); - - ws.on("error", (err: Error) => { - if (!openSettled) { - finalizeOpenFailure( - `Failed to connect to ${describeTransportTarget(target)}: ${err.message}`, - ); - return; - } - - emitTransportEvent({ - sessionId, - kind: "error", - error: err.message, + disposeSession(session); + emitEvent({ + sessionId: session.id, + kind: "close", + code, + reason: reason ? String(reason) : "", }); }); - }); + + ws.on("error", (error: Error) => { + if (!isCurrent(session)) { + return; + } + const failureDetail = endpoint.failureDetail(); + const detail = failureDetail ? `${error.message}: ${failureDetail}` : error.message; + if (session.state === "opening") { + failOpeningSession( + session, + `Failed to connect to ${describeTransportTarget(session.target)}: ${detail}`, + ); + return; + } + + emitEvent({ sessionId: session.id, kind: "error", error: detail }); + }); + } + + function open(rawInput: unknown): void { + const { sessionId, target } = parseOpenTransportSessionInput(rawInput); + if (sessions.has(sessionId)) { + throw new Error(`Local transport session already exists: ${sessionId}`); + } + + const session: Session = { + id: sessionId, + target, + ws: null, + state: "opening", + closeTarget: null, + cancelSetupDeadline: () => undefined, + }; + sessions.set(sessionId, session); + session.cancelSetupDeadline = deps.scheduleTimeout(() => { + failOpeningSession( + session, + `Connection to ${describeTransportTarget(target)} timed out during setup.`, + ); + }, LOCAL_TRANSPORT_SETUP_TIMEOUT_MS); + void connectSession(session); + } + + async function send(input: { + sessionId: string; + text?: string; + binaryBase64?: string; + }): Promise { + const session = sessions.get(input.sessionId); + if (!session) { + throw new Error(`Local transport session not found: ${input.sessionId}`); + } + + const ws = session.ws; + if (session.state !== "open" || !ws || ws.readyState !== WebSocket.OPEN) { + throw new Error( + session.state === "opening" + ? "Local transport session is not open yet." + : "Local transport session is closed.", + ); + } + + const payload = decodeTransportMessage(input); + await new Promise((resolve, reject) => { + ws.send(payload, (error) => { + if (error) { + reject(new Error(`Local transport write failed: ${error.message}`)); + return; + } + resolve(); + }); + }); + } + + function close(sessionId: string): void { + const session = sessions.get(sessionId); + if (session) { + disposeSession(session); + } + } + + function closeAll(): void { + for (const session of sessions.values()) { + disposeSession(session); + } + } + + return { open, send, close, closeAll }; +} + +const localTransportManager = createLocalTransportManager({ + resolveEndpoint: resolveTransportEndpoint, + createWebSocket: (url) => new WebSocket(url), + scheduleTimeout: (callback, delayMs) => { + const timeout = setTimeout(callback, delayMs); + timeout.unref(); + return () => clearTimeout(timeout); + }, + emitEvent: emitTransportEvent, +}); + +export function openLocalTransportSession(rawInput: unknown): void { + localTransportManager.open(rawInput); } export async function sendLocalTransportMessage(input: { @@ -159,51 +560,13 @@ export async function sendLocalTransportMessage(input: { text?: string; binaryBase64?: string; }): Promise { - const session = sessions.get(input.sessionId); - if (!session) { - throw new Error(`Local transport session not found: ${input.sessionId}`); - } - - if (session.state !== "open" || session.ws.readyState !== WebSocket.OPEN) { - throw new Error( - session.state === "opening" - ? "Local transport session is not open yet." - : "Local transport session is closed.", - ); - } - - const payload = decodeTransportMessage(input); - await new Promise((resolve, reject) => { - session.ws.send(payload, (error) => { - if (error) { - reject(new Error(`Local transport write failed: ${error.message}`)); - return; - } - resolve(); - }); - }); + await localTransportManager.send(input); } export function closeLocalTransportSession(sessionId: string): void { - const session = sessions.get(sessionId); - if (!session) return; - - try { - if (session.ws.readyState === WebSocket.CONNECTING) { - session.state = "closed"; - session.ws.terminate(); - } else { - session.state = "closing"; - session.ws.close(); - } - } catch { - // ignore close errors - } - sessions.delete(sessionId); + localTransportManager.close(sessionId); } export function closeAllTransportSessions(): void { - for (const [id] of sessions) { - closeLocalTransportSession(id); - } + localTransportManager.closeAll(); } diff --git a/packages/protocol/src/ssh-transport.test.ts b/packages/protocol/src/ssh-transport.test.ts new file mode 100644 index 0000000000..978e5a12d7 --- /dev/null +++ b/packages/protocol/src/ssh-transport.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { buildSshTunnelArgs, parseSshTransportUri, validateSshHost } from "./ssh-transport.js"; + +describe("SSH transport", () => { + it("parses SSH targets with Paseo's default daemon port", () => { + expect(parseSshTransportUri("ssh://deploy@example.com:2222")).toEqual({ + host: "deploy@example.com", + sshPort: 2222, + daemonPort: 6767, + }); + }); + + it("accepts an explicit remote daemon port", () => { + expect(parseSshTransportUri("ssh://build-box?daemonPort=7777")).toEqual({ + host: "build-box", + daemonPort: 7777, + }); + }); + + it("passes IPv6 hosts to OpenSSH without URI brackets", () => { + expect(parseSshTransportUri("ssh://deploy@[2001:db8::1]:2222")).toEqual({ + host: "deploy@2001:db8::1", + sshPort: 2222, + daemonPort: 6767, + }); + }); + + it.each([ + "http://build-box", + "ssh://build-box/path", + "ssh://build-box?unknown=true", + "ssh://build-box?daemonPort=0", + "ssh://user:secret@build-box", + ])("rejects invalid target %s", (target) => { + expect(() => parseSshTransportUri(target)).toThrow(); + }); + + it("builds a non-interactive stdio tunnel and preserves SSH config", () => { + expect( + buildSshTunnelArgs({ + host: "deploy@build-box", + sshPort: 2222, + daemonPort: 7777, + }), + ).toEqual([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ClearAllForwardings=yes", + "-o", + "ExitOnForwardFailure=yes", + "-p", + "2222", + "-W", + "127.0.0.1:7777", + "deploy@build-box", + ]); + }); + + it.each(["", "-oProxyCommand=bad", "bad host"])("rejects unsafe SSH host %j", (host) => { + expect(() => validateSshHost(host)).toThrow(); + }); +}); diff --git a/packages/protocol/src/ssh-transport.ts b/packages/protocol/src/ssh-transport.ts new file mode 100644 index 0000000000..9878b10bcf --- /dev/null +++ b/packages/protocol/src/ssh-transport.ts @@ -0,0 +1,82 @@ +export const DEFAULT_SSH_DAEMON_PORT = 6767; + +export interface SshTransportTarget { + host: string; + sshPort?: number; + daemonPort: number; +} + +export function validatePort(value: string | number, label: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${label} must be between 1 and 65535`); + } + return port; +} + +export function validateSshHost(host: string): string { + const normalized = host.trim(); + if (!normalized) throw new Error("SSH host is required"); + if (/\s/u.test(normalized) || normalized.startsWith("-")) { + throw new Error("SSH host is invalid"); + } + return normalized; +} + +export function parseSshTransportUri(value: string): SshTransportTarget { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new Error("Invalid SSH host URI", { cause: error }); + } + + if (url.protocol !== "ssh:" || url.password || (url.pathname !== "" && url.pathname !== "/")) { + throw new Error("Invalid SSH host URI"); + } + if (url.hash) throw new Error("SSH host URI does not support fragments"); + + for (const key of url.searchParams.keys()) { + if (key !== "daemonPort") throw new Error(`Unsupported SSH host option: ${key}`); + } + const daemonPorts = url.searchParams.getAll("daemonPort"); + if (daemonPorts.length > 1) throw new Error("daemonPort may only be specified once"); + + const urlHostname = url.hostname; + const hostname = validateSshHost( + urlHostname.startsWith("[") && urlHostname.endsWith("]") + ? urlHostname.slice(1, -1) + : urlHostname, + ); + const username = decodeURIComponent(url.username); + const host = validateSshHost(username ? `${username}@${hostname}` : hostname); + return { + host, + ...(url.port ? { sshPort: validatePort(url.port, "SSH port") } : {}), + daemonPort: + daemonPorts[0] === undefined + ? DEFAULT_SSH_DAEMON_PORT + : validatePort(daemonPorts[0], "Daemon port"), + }; +} + +export function buildSshTunnelArgs(target: SshTransportTarget): string[] { + const host = validateSshHost(target.host); + const daemonPort = validatePort(target.daemonPort, "Daemon port"); + const args = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ClearAllForwardings=yes", + "-o", + "ExitOnForwardFailure=yes", + ]; + if (target.sshPort !== undefined) { + args.push("-p", String(validatePort(target.sshPort, "SSH port"))); + } + args.push("-W", `127.0.0.1:${daemonPort}`, host); + return args; +} diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 977c6f8b8d..a80d483f5a 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -1,6 +1,6 @@ --- title: Connectivity -description: Connect a Paseo client to your daemon through the relay or Tailscale. +description: Connect a Paseo client to your daemon through SSH, the relay, or Tailscale. nav: Connectivity order: 4 category: Getting started @@ -8,13 +8,45 @@ category: Getting started # Connectivity -Your Paseo app connects to the daemon running on your computer or server. You can connect through the Paseo relay or directly with Tailscale. +Your Paseo app connects to the daemon running on your computer or server. Paseo Desktop and the CLI can tunnel through SSH. Mobile clients can connect through the Paseo relay or directly with Tailscale. This is client-to-daemon transport. If you are looking for the service that starts agents from GitHub, Slack, and Discord events, that is [Hub](/docs/hub). +- [SSH](#ssh) - [Paseo relay](#paseo-relay) - [Tailscale](#tailscale) +## SSH + +SSH transport connects to an existing daemon through your local OpenSSH client. It does not install, start, or configure Paseo on the remote host. + +Before connecting: + +1. Start the Paseo daemon on the remote host. +2. Confirm `ssh user@host` works with a key or SSH agent. Paseo uses non-interactive SSH and follows your OpenSSH config. + +The CLI accepts an SSH URI as its host: + +```bash +paseo ls -a --host ssh://user@host +``` + +The daemon is expected at `127.0.0.1:6767` on the remote host. The port in the SSH URL is the SSH server port: + +```bash +paseo ls -a --host ssh://user@host:2222 +``` + +Set a different remote daemon port with `daemonPort`: + +```bash +paseo ls -a --host 'ssh://user@host?daemonPort=7777' +``` + +`--host` belongs after the command. `paseo daemon status` checks only the local daemon; use `paseo ls --host ...` to verify a remote connection. `paseo run --host ...` also requires `--cwd` with a path that exists on the remote host. + +In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. + ## Paseo relay The relay works without Tailscale, port forwarding, or network configuration. Traffic is end-to-end encrypted. @@ -89,6 +121,8 @@ If the host was already paired through the relay, Paseo adds the direct connecti ## Troubleshooting +- **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords. +- **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon. - **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP. - **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port. - **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it.