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 <jstrain@nvidia.com>
Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-08-28 16:32:12 +02:00
committed by GitHub
co-authored by James Strain Reid Levesque
parent 7ea4288117
commit 667664847d
38 changed files with 1961 additions and 251 deletions
+7 -4
View File
@@ -106,13 +106,16 @@ export type AgentLsResult = ListResult<AgentListItem>;
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 <host:port> 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 <host:port> or set PASEO_HOST.",
].join("\n"),
};
}
+90
View File
@@ -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<SshTunnel> {
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),
});
});
});
}
+63
View File
@@ -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<Record<string, unknown>>,
createSshTunnel: vi.fn(),
}));
vi.mock("@getpaseo/client/internal/daemon-client", () => ({
DaemonClient: class {
lastError = null;
constructor(config: Record<string, unknown>) {
mocks.configs.push(config);
}
async connect() {}
async close() {}
},
}));
vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ssh/ssh-tunnel.js")>()),
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",
});
});
});
+23 -1
View File
@@ -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<DaemonC
const nodeWebSocketFactory = createNodeWebSocketFactory();
const explicitHost = options?.host ?? process.env.PASEO_HOST;
if (explicitHost?.trim().startsWith("ssh://")) {
const target = parseSshTransportUri(explicitHost.trim());
const tunnel = await createSshTunnel(target);
const password = resolveDaemonPassword(explicitHost);
const result = await tryConnectHost(
tunnel.endpoint,
password,
clientId,
timeout,
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
const failure = tunnel.failureDetail();
tunnel.close();
if (failure) throw new Error(`SSH connection failed: ${failure}`, { cause: result.error });
throw result.error;
}
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
"Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);