mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Connect saved hosts and delegate agents across daemons (#7370)
* feat(hosts): connect to saved hosts through a daemon * fix(hosts): validate safe targets and verify encrypted relay routes * test(hosts): include relay coverage in existing CI contracts * fix(cli): preserve validated addresses in daemon command hints * fix(hosts): share daemon links across isolated routed sessions Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion. * fix(hosts): bound encrypted overload and initial acquisition Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission. * fix(hosts): keep compact row actions clear of host labels Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls. * refactor(hosts): remove management settings UI from phase one Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access. * test(hosts): control physical write backpressure across platforms Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks. * refactor(hosts): use the ordinary client contract over channel transports Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes. * test(e2e): keep transient developer toasts from stalling checks Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions. * Create, prompt, and monitor agents across connected hosts (#7507) * feat(hosts): delegate agents across connected hosts Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications. * test(app): normalize subagent fixtures through snapshots * docs(protocol): clarify same-host parent labels * fix(hosts): resolve subagents through destination sessions A child address identifies its destination regardless of the connection used. Reuse the existing host candidate selection for discovered routes and direct connections, and keep navigation and row actions independent of the parent transport. * fix(hosts): keep routed destinations distinct during discovery A routed candidate names only the origin daemon. Match it within the named destination profile so two destinations discovered through the same origin retain separate identities and connections, including existing saved routes. * feat(hosts): prompt and query agents on selected hosts Extend the existing MCP host operation boundary with prompt, status, activity and list operations. Local calls retain their handlers and notification owner; selected destinations use ordinary client RPCs and the existing idle finish wait. * test(e2e): preserve toast coverage with CSS hiding * docs(hosts): translate connected host references
This commit is contained in:
@@ -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://build-box:2222 (daemon port 7777)",
|
||||
});
|
||||
expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow(
|
||||
"Invalid host connection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -50,15 +42,15 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
|
||||
if (error && typeof error === "object" && "message" in error) message = String(error.message);
|
||||
if (options.target.kind === "endpoint")
|
||||
message = message.replaceAll(options.target.host, describeDaemonTarget(options.target));
|
||||
if (message.startsWith("Cannot connect to daemon at "))
|
||||
return error as { code: string; message: string; details: string };
|
||||
let code = "DAEMON_UNREACHABLE";
|
||||
if (typeof error === "object" && error !== null && "code" in error) code = String(error.code);
|
||||
else if (message === "Password required") code = "AUTH_REQUIRED";
|
||||
else if (message === "Incorrect password") code = "AUTH_FAILED";
|
||||
return {
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
message: message.startsWith("Cannot connect to daemon at ")
|
||||
? message
|
||||
: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { parseHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { test, expect } from "vitest";
|
||||
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
||||
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
|
||||
import {
|
||||
resolveDaemonCredential,
|
||||
resolveClientPaseoHome,
|
||||
buildDaemonConnectionCommandError,
|
||||
} from "./client.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -44,6 +49,31 @@ test("endpoint descriptions redact pairing material and credentials", () => {
|
||||
).not.toContain("private");
|
||||
});
|
||||
|
||||
test("a pairing offer without a URL prefix still redacts its key", () => {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: "srv_example",
|
||||
daemonPublicKeyB64: "example-secret",
|
||||
relay: { endpoint: "localhost:6767", useTls: false },
|
||||
};
|
||||
const host = `#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`;
|
||||
expect(describeDaemonTarget({ kind: "endpoint", host })).toBe(
|
||||
"relay://localhost:6767/srv_example",
|
||||
);
|
||||
});
|
||||
|
||||
test("malformed endpoint diagnostics never expose the supplied credential", () => {
|
||||
const target = { kind: "endpoint" as const, host: "alice:example-secret@localhost:6767" };
|
||||
expect(describeDaemonTarget(target)).not.toContain("example-secret");
|
||||
for (const message of [
|
||||
`Invalid connection ${target.host}`,
|
||||
`Cannot connect to daemon at ${target.host}`,
|
||||
]) {
|
||||
const diagnostic = buildDaemonConnectionCommandError({ target, error: new Error(message) });
|
||||
expect(JSON.stringify(diagnostic)).not.toContain("example-secret");
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-"));
|
||||
const previousPassword = process.env.PASEO_PASSWORD;
|
||||
@@ -67,3 +97,34 @@ test("CLI selects an explicit password before a matching local credential and ne
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["/tmp/paseo.sock", "\\\\.\\pipe\\paseo", "localhost:6767", "[::1]:6767", "6767"])(
|
||||
"validated bare target %s keeps its spelling in command hints",
|
||||
(host) => {
|
||||
expect(describeDaemonTarget({ kind: "endpoint", host })).toBe(host);
|
||||
},
|
||||
);
|
||||
|
||||
test("validated URI command hints preserve their transport address without credentials", () => {
|
||||
const ssh = describeDaemonTarget({
|
||||
kind: "endpoint",
|
||||
host: "ssh://deploy@build-box:2222?daemonPort=7777&password=example-secret",
|
||||
});
|
||||
expect(ssh).not.toContain("example-secret");
|
||||
expect(parseHostTarget(ssh)).toMatchObject({
|
||||
kind: "ssh",
|
||||
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
|
||||
});
|
||||
const ws = describeDaemonTarget({
|
||||
kind: "endpoint",
|
||||
host: "wss://example.test/custom-ws?authorization=example-secret",
|
||||
});
|
||||
expect(ws).not.toContain("example-secret");
|
||||
expect(new URL(ws).pathname).toBe("/custom-ws");
|
||||
expect(
|
||||
describeDaemonTarget({
|
||||
kind: "endpoint",
|
||||
host: "tcp://localhost:6767?daemonPort=example-secret&ssl=example-secret",
|
||||
}),
|
||||
).not.toContain("example-secret");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { resolvePaseoHome } from "@getpaseo/server/daemon-control";
|
||||
|
||||
export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string };
|
||||
@@ -39,13 +40,26 @@ export function selectDaemonTarget(
|
||||
export function describeDaemonTarget(target: DaemonTarget): string {
|
||||
if (target.kind === "instance") return `home ${target.home}`;
|
||||
try {
|
||||
const url = new URL(target.host);
|
||||
if (url.password) url.password = "REDACTED";
|
||||
for (const key of url.searchParams.keys())
|
||||
if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED");
|
||||
if (url.hash) url.hash = "REDACTED";
|
||||
return url.toString();
|
||||
const input = target.host.trim();
|
||||
const parsed = parseHostTarget(input);
|
||||
// Discovery descriptions are display text; command hints must preserve the address syntax.
|
||||
if (parsed.kind === "ssh" || (!parsed.offer && !parsed.socketPath && input.includes("://"))) {
|
||||
const url = new URL(input);
|
||||
if (decodeURIComponent(url.username).includes(":")) url.username = "REDACTED";
|
||||
for (const key of new Set(url.searchParams.keys())) {
|
||||
const routingOption =
|
||||
(parsed.kind === "ssh" && key === "daemonPort") ||
|
||||
(key === "ssl" &&
|
||||
url.searchParams.getAll(key).every((value) => value === "true" || value === "false"));
|
||||
if (!routingOption) url.searchParams.set(key, "REDACTED");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
// Pairing offers must always use safe display text, including prefix-free fragments.
|
||||
return parsed.kind === "socket" && !parsed.offer && !input.includes("://")
|
||||
? input
|
||||
: parsed.description;
|
||||
} catch {
|
||||
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
|
||||
return "<invalid endpoint>";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user