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
+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]);