feat(hosts): connect to saved hosts through a daemon

This commit is contained in:
Mohamed Boudra
2026-10-19 08:37:15 +02:00
parent b8bcd204a9
commit a954ded4be
39 changed files with 3623 additions and 370 deletions
+3
View File
@@ -1,3 +1,4 @@
import { createHostCommand } from "./commands/host/index.js";
import { pairCommand } from "./commands/daemon/pair.js";
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
@@ -58,6 +59,8 @@ export function createCli(): Command {
.option("--no-color", "disable colored output");
addDaemonHostOption(program);
program.addCommand(createHostCommand());
// Primary agent commands (top-level)
addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand));
+116
View File
@@ -0,0 +1,116 @@
import { Command } from "commander";
import type { HostSummary, HostUpdate } from "@getpaseo/client";
import {
withOutput,
type CommandOptions,
type OutputSchema,
type CommandResult,
} from "../../output/index.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
const schema: OutputSchema<HostSummary> = {
idField: "registrationId",
columns: [
{ header: "ID", field: "registrationId" },
{ header: "LABEL", field: "label" },
{ header: "SERVER", field: "serverId" },
{ header: "ENABLED", field: "enabled" },
{ header: "STATUS", field: "status" },
{ header: "SOURCE", field: (row) => `${row.source.id}/${row.source.key}` },
],
};
function metadata(values: string[] = []): Record<string, string> {
return Object.fromEntries(
values.map((entry) => {
const split = entry.indexOf("=");
if (split < 1) throw { code: "invalid_metadata", message: "Metadata must use key=value" };
return [entry.slice(0, split), entry.slice(split + 1)];
}),
);
}
interface Options extends CommandOptions {
label?: string;
meta?: string[];
removeMeta?: string[];
connection?: string;
replaceIdentity?: boolean;
}
async function run(
action: string,
id: string | undefined,
options: Options,
): Promise<CommandResult<HostSummary>> {
const client = await connectToDaemon({ target: options.daemonTarget });
try {
if (action === "add")
return {
type: "single",
data: await client.addHost({
connection: id!,
label: options.label,
metadata: metadata(options.meta),
}),
schema,
};
if (action === "ls") return { type: "list", data: (await client.listHosts()).hosts, schema };
const host = (await client.listHosts()).hosts.find((entry) => entry.registrationId === id);
if (!host) throw { code: "host_unknown", message: `Unknown registration ${id}` };
if (action === "inspect")
return {
type: "single",
data: host,
schema: { ...schema, renderHuman: () => JSON.stringify(host, null, 2) },
};
if (action === "rm") {
await client.removeHost(id!);
return { type: "single", data: host, schema };
}
const patch: HostUpdate = {};
if (action === "enable" || action === "disable") patch.enabled = action === "enable";
if (options.label !== undefined) patch.label = options.label || null;
if (options.connection !== undefined) patch.connection = options.connection;
if (options.replaceIdentity) patch.replaceIdentity = true;
if (options.meta?.length || options.removeMeta?.length) {
patch.metadata = { ...host.metadata, ...metadata(options.meta) };
for (const key of options.removeMeta ?? []) delete patch.metadata[key];
}
return { type: "single", data: await client.updateHost(id!, patch), schema };
} finally {
await client.close();
}
}
export function createHostCommand(): Command {
const host = new Command("host").description("Manage hosts connected to the selected daemon");
addJsonAndDaemonHostOptions(
host.command("ls").description("List saved hosts, including disabled and failed entries"),
).action(withOutput((options: Options, _command: Command) => run("ls", undefined, options)));
addJsonAndDaemonHostOptions(
host
.command("add")
.argument("<connection>")
.option("--label <name>")
.option("--meta <key=value>", "Set metadata", collectMultiple, []),
).action(
withOutput((connection: string, options: Options, _command: Command) =>
run("add", connection, options),
),
);
addJsonAndDaemonHostOptions(
host
.command("update")
.argument("<registration-id>")
.option("--label <name>")
.option("--connection <connection>")
.option("--replace-identity", "Accept a new destination identity")
.option("--meta <key=value>", "Replace supplied metadata keys", collectMultiple, [])
.option("--remove-meta <key>", "Remove a metadata key", collectMultiple, []),
).action(
withOutput((id: string, options: Options, _command: Command) => run("update", id, options)),
);
for (const action of ["inspect", "enable", "disable", "rm"])
addJsonAndDaemonHostOptions(host.command(action).argument("<registration-id>")).action(
withOutput((id: string, options: Options, _command: Command) => run(action, id, options)),
);
return host;
}
+2 -90
View File
@@ -1,90 +1,2 @@
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),
});
});
});
}
export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node";
export type { SshTunnel } from "@getpaseo/client/node";
+10 -51
View File
@@ -1,42 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } 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" }));
import { parseHostTarget } from "@getpaseo/client/node";
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.",
@@ -45,21 +11,14 @@ describe("CLI SSH transport", () => {
"ssh exited with code 255",
);
});
it("routes an SSH host through a local tunnel", async () => {
await connectToDaemon({
target: { kind: "endpoint", 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",
it("uses the shared SSH target grammar and excludes reusable credentials from discovery", () => {
expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777")).toEqual({
kind: "ssh",
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
description: "ssh://deploy@build-box:2222 (daemon port 7777)",
});
expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow(
"Invalid host connection",
);
});
});
+23 -175
View File
@@ -1,3 +1,4 @@
import { createHostClient, parseHostTarget } from "@getpaseo/client/node";
import {
waitForDaemonReady,
resolvePaseoHome,
@@ -8,21 +9,12 @@ import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
export type { DaemonTarget } from "./daemon-target.js";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
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 { WebSocket } from "ws";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
export interface ConnectOptions {
target: DaemonTarget;
@@ -170,11 +162,8 @@ export function resolveDaemonTarget(host: string): TransportTarget {
}
export function resolveDaemonPassword(host: string): string | undefined {
const trimmed = host.trim();
if (trimmed.startsWith("tcp://")) {
const fromUri = parseConnectionUri(trimmed).password;
if (fromUri) return fromUri;
}
const fromUri = parseHostTarget(host).password;
if (fromUri) return fromUri;
const fromEnv = process.env.PASEO_PASSWORD;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
@@ -189,117 +178,9 @@ export function resolveDaemonCredential(
return token ? { kind: "localCredential", token } : null;
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (
url: string,
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
): WebSocketLike => {
return new WebSocket(url, options?.protocols, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as WebSocketLike;
};
}
/**
* Create and connect a daemon client
* Returns the connected client or throws if connection fails
*/
async function tryConnectHost(
host: string,
credential: ReturnType<typeof resolveDaemonCredential>,
home: string,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
): Promise<{ client: DaemonClient } | { error: unknown }> {
const target = resolveDaemonTarget(host);
const client = new DaemonClient({
url: target.url,
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
...(credential?.kind === "password" ? { password: credential.password } : {}),
...(credential?.kind === "localCredential"
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
: {}),
connectTimeoutMs: timeout,
webSocketFactory: (
url: string,
config?: { headers?: Record<string, string>; protocols?: string[] },
) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
protocols: config?.protocols,
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
});
try {
await client.connect();
return { client };
} catch (error) {
await client.close().catch(() => {});
return { error };
}
}
async function connectViaRelayOffer(
offer: ConnectionOffer,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
): Promise<DaemonClient> {
const url = buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
});
const client = new DaemonClient({
url,
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
connectTimeoutMs: timeout,
webSocketFactory: (
target: string,
config?: { headers?: Record<string, string>; protocols?: string[] },
) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }),
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
reconnect: { enabled: false },
});
try {
await client.connect();
return client;
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
const lastError = client.lastError ? ` (${client.lastError})` : "";
throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error });
}
}
function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null {
if (!host) return null;
try {
return parseConnectionOfferFromUrl(host);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error });
}
}
async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> {
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const deadline = Date.now() + timeout;
const explicitHost =
const host =
options.target.kind === "endpoint"
? options.target.host
: (
@@ -309,58 +190,25 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
})
).listen;
const home = resolveClientPaseoHome(options.target);
const clientId = await getOrCreateCliClientId(home);
const nodeWebSocketFactory = createNodeWebSocketFactory();
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 ? { kind: "password", password } : null,
home,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) {
const close = result.client.close.bind(result.client);
result.client.close = async () => {
try {
await close();
} finally {
tunnel.close();
}
};
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 credential = resolveDaemonCredential(host, home);
const client = createHostClient(host, {
clientId: await getOrCreateCliClientId(home),
clientType: "cli",
appVersion: resolveCliVersion(),
...(credential?.kind === "password" ? { password: credential.password } : {}),
...(credential?.kind === "localCredential"
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
: {}),
connectTimeoutMs: timeout,
reconnect: { enabled: false },
});
try {
await client.connect();
return client;
} catch (error) {
await client.close();
throw error;
}
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(
offer,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
}
const result = await tryConnectHost(
explicitHost,
resolveDaemonCredential(explicitHost, home),
home,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
throw result.error;
}
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {