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
+4 -1
View File
@@ -873,9 +873,12 @@ test("keeps relay upgrade credentials out of the socket request", async () => {
},
});
clients.push(client);
void client.connect();
const connecting = client.connect();
void connecting.catch(() => {});
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
await client.close();
await expect(connecting).rejects.toThrow("Daemon client closed");
});
test("refuses relay password auth without an encrypted hello", async () => {
+149 -14
View File
@@ -1,4 +1,5 @@
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts";
import { legacyUsageIcon } from "./legacy-usage-icons.js";
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
@@ -391,6 +392,10 @@ export interface DaemonClientConfig {
/** Deliver compact bodies/hash references to a caller-owned snapshot cache.
* The default keeps public SDK snapshot entries expanded. */
providerSnapshots?: "wire";
routedClient?: boolean;
routedServerId?: string;
routedFailure?: () => Error | null;
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
@@ -1008,8 +1013,16 @@ class DaemonRpcError extends Error {
readonly requestId: string;
readonly requestType?: string;
readonly code?: string;
readonly serverId?: string;
readonly userMessage: string;
constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) {
constructor(params: {
requestId: string;
error: string;
requestType?: string;
code?: string;
serverId?: string;
}) {
const parts = [params.error];
if (params.requestType) parts.push(`requestType=${params.requestType}`);
if (params.code) parts.push(`code=${params.code}`);
@@ -1018,6 +1031,8 @@ class DaemonRpcError extends Error {
this.requestId = params.requestId;
this.requestType = params.requestType;
this.code = params.code;
this.serverId = params.serverId;
this.userMessage = params.error;
}
}
@@ -1502,6 +1517,7 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1518,6 +1534,7 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1568,9 +1585,7 @@ export class DaemonClient {
return;
}
this.shouldReconnect = false;
this.connectPromise = null;
this.connectResolve = null;
this.connectReject = null;
this.rejectConnect(new Error("Daemon client closed"));
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
@@ -1598,6 +1613,7 @@ export class DaemonClient {
}
ensureConnected(options?: { verify?: boolean }): void {
this.requireRoutedConnection();
if (this.connectionState.status === "disposed") {
return;
}
@@ -1613,7 +1629,9 @@ export class DaemonClient {
this.attemptConnect();
return;
}
void this.connect();
void this.connect().catch((error: unknown) =>
this.logger.debug({ err: error }, "Connection attempt ended"),
);
}
private verifyConnection(): void {
@@ -1631,13 +1649,98 @@ export class DaemonClient {
event: "CONNECTION_VERIFICATION_FAILED",
reasonCode: "liveness_timeout",
});
this.ensureConnected();
if (!this.config.routedClient) this.ensureConnected();
})
.finally(() => {
if (this.connectionVerification === transport) this.connectionVerification = null;
});
}
requireConnectedHosts(): void {
if (!this.isConnected)
throw Object.assign(new Error("The managing daemon is offline"), {
code: "host_offline",
serverId: this.config.routedServerId,
});
if (this.lastServerInfoMessage?.features?.connectedHosts !== true)
throw Object.assign(new Error("Update the managing daemon to use connected hosts"), {
code: "unsupported_capability",
});
}
async listHosts(): Promise<HostDirectory> {
this.requireConnectedHosts();
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.list.request" },
responseType: "hosts.list.response",
})
).directory;
}
observeHosts(): OwnedSubscription<HostDirectory> {
this.requireConnectedHosts();
return this.owned.observe(async (accept) => {
const result = await this.sendCorrelatedSessionRequest({
message: { type: "hosts.list.request", observe: true },
responseType: "hosts.list.response",
});
if (!result.subscriptionId) throw new Error("Host omitted subscription identity");
const snapshot = { ...result.directory, subscriptionId: result.subscriptionId };
accept(snapshot);
return snapshot;
});
}
async addHost(input: HostAdd) {
this.requireConnectedHosts();
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.add.request", input },
responseType: "hosts.add.response",
})
).host;
}
async updateHost(registrationId: string, patch: HostUpdate) {
this.requireConnectedHosts();
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.update.request", registrationId, patch },
responseType: "hosts.update.response",
})
).host;
}
async removeHost(registrationId: string): Promise<void> {
this.requireConnectedHosts();
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.remove.request", registrationId },
responseType: "hosts.remove.response",
});
}
async openHostRoute(serverId: string, scopeId: string): Promise<string> {
this.requireConnectedHosts();
try {
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.route.open.request", serverId, scopeId },
responseType: "hosts.route.open.response",
})
).routeId;
} catch (error) {
throw Object.assign(error instanceof Error ? error : new Error("Host route failed"), {
serverId,
});
}
}
sendHostFrame(routeId: string, data: string, binary: boolean): void {
this.sendSessionMessageStrict({ type: "hosts.route.frame.request", routeId, data, binary });
}
cancelHostRoute(scopeId: string): void {
if (this.isConnected)
this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId });
}
closeHostRoute(routeId: string): void {
if (this.isConnected)
this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId });
}
getConnectionState(): ConnectionState {
return this.connectionState;
}
@@ -1780,7 +1883,7 @@ export class DaemonClient {
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
if (!this.transport) {
throw new DaemonConnectionError("Transport not connected");
throw this.unavailableError();
}
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
assertTransportFrameFits(frame);
@@ -1867,7 +1970,7 @@ export class DaemonClient {
}
// If connecting, queue the message to be sent once connected
if (status === "connecting") {
if (status === "connecting" && !this.config.routedClient) {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
// Remove from queue
@@ -1888,7 +1991,7 @@ export class DaemonClient {
}
// Not connected and not connecting - fail immediately
return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
return Promise.reject(this.unavailableError());
}
/**
@@ -1942,9 +2045,13 @@ export class DaemonClient {
kind: "error",
error: new DaemonRpcError({
requestId: msg.payload.requestId,
serverId: this.config.routedServerId,
error: msg.payload.error,
requestType: msg.payload.requestType,
code: msg.payload.code,
code:
this.config.routedClient && msg.payload.code === "access_denied"
? "permission_denied"
: msg.payload.code,
}),
};
}
@@ -2053,9 +2160,23 @@ export class DaemonClient {
});
}
private requireRoutedConnection(): void {
if (this.config.routedClient && !this.isConnected) throw this.unavailableError();
}
private unavailableError(): Error {
if (this.config.routedFailure?.()) return this.config.routedFailure()!;
if (this.config.routedClient)
return Object.assign(new Error(`Host ${this.config.routedServerId} is offline`), {
code: "host_offline",
serverId: this.config.routedServerId,
});
return new DaemonConnectionError("Transport not connected");
}
private sendSessionMessageStrict(message: SessionInboundMessage): void {
if (!this.transport || this.connectionState.status !== "connected") {
throw new DaemonConnectionError("Transport not connected");
throw this.unavailableError();
}
const payload = SessionInboundMessageSchema.parse(message);
try {
@@ -2851,7 +2972,10 @@ export class DaemonClient {
// ============================================================================
private readonly creations = new CreationClient({
supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
supports: (feature) => {
this.requireRoutedConnection();
return this.lastServerInfoMessage?.features?.[feature] === true;
},
requestId: () => this.createRequestId(),
request: (kind, input) =>
kind === "workspace"
@@ -6287,6 +6411,9 @@ export class DaemonClient {
const auth = this.helloAuth;
this.sendJsonMessage("hello", "hello", {
type: "hello",
...(this.config.sessionPermissions
? { sessionPermissions: this.config.sessionPermissions }
: {}),
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
@@ -6632,6 +6759,7 @@ export class DaemonClient {
}
private scheduleReconnect(input?: {
failure?: Error;
reason?: string;
event?: string;
reasonCode?: string;
@@ -6652,7 +6780,14 @@ export class DaemonClient {
// Clear all pending waiters and queued sends since the connection was lost
// and responses from the previous connection will never arrive.
this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
this.clearWaiters(
this.config.routedClient
? Object.assign(
new Error("Connection lost before result; inspect the destination before retrying"),
{ code: "outcome_unknown", serverId: this.config.routedServerId },
)
: new DaemonConnectionError(reason ?? "Connection lost"),
);
this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
this.terminalStreams.clearSlots();
@@ -6669,7 +6804,7 @@ export class DaemonClient {
this.rejectConnect(
this.authFailureReasonValue
? new DaemonAuthenticationError(this.authFailureReasonValue)
: new Error(reason ?? "Transport disconnected before connect"),
: (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")),
);
return;
}
+204
View File
@@ -0,0 +1,204 @@
import { safeRandomId } from "../daemon-client-transport-utils.js";
import { DaemonClient } from "../daemon-client.js";
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
import type { OwnedSubscription } from "../connection/index.js";
import type { PaseoApi, createPaseoApi } from "../index.js";
import type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
export interface PaseoHostActions {
list(): Promise<HostDirectory>;
observe(): OwnedSubscription<HostDirectory>;
add(input: HostAdd): Promise<HostSummary>;
update(registrationId: string, patch: HostUpdate): Promise<HostSummary>;
remove(registrationId: string): Promise<void>;
client(serverId: string): Promise<PaseoApi>;
}
function encode(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
let result = "";
for (let start = 0; start < bytes.length; start += 8192)
result += String.fromCharCode(...bytes.subarray(start, start + 8192));
return globalThis.btoa(result);
}
function decode(data: string): Uint8Array {
return Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0));
}
function routeTransport(
origin: DaemonClient,
serverId: string,
scopeId: string,
initialRouteId: string,
state: { failure: Error | null },
): DaemonTransportFactory {
let first: string | null = initialRouteId;
return () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
const errors = new Set<(error: unknown) => void>();
let routeId: string | null = null;
let closed = false;
const cleanups: (() => void)[] = [];
const close = (error?: unknown) => {
if (closed) return;
closed = true;
if (error instanceof Error) state.failure = Object.assign(error, { serverId });
for (const cleanup of cleanups) cleanup();
if (routeId) origin.closeHostRoute(routeId);
if (error) for (const handler of errors) handler(error);
for (const handler of closes) handler(error);
};
cleanups.push(
origin.subscribeRawMessages((message) => {
if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) {
for (const handler of messages)
handler(
message.payload.binary ? decode(message.payload.data) : message.payload.data,
message.payload.binary,
);
}
if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId)
close(
Object.assign(new Error(message.payload.message), {
code: message.payload.code,
serverId,
}),
);
}),
);
cleanups.push(
origin.subscribeConnectionStatus((connectionState) => {
if (connectionState.status !== "connected") close();
}),
);
const opening = first ? Promise.resolve(first) : origin.openHostRoute(serverId, scopeId);
first = null;
void opening
.then((id) => {
if (closed) {
origin.closeHostRoute(id);
return;
}
routeId = id;
state.failure = null;
for (const handler of opens) handler();
return undefined;
})
.catch(close);
return {
send(data) {
if (closed || !routeId)
throw Object.assign(new Error(`Host ${serverId} is offline`), {
code: "host_offline",
serverId,
});
origin.sendHostFrame(
routeId,
typeof data === "string" ? data : encode(data),
typeof data !== "string",
);
},
close: () => close(),
onOpen(handler) {
opens.add(handler);
return () => opens.delete(handler);
},
onClose(handler) {
closes.add(handler);
return () => closes.delete(handler);
},
onError(handler) {
errors.add(handler);
return () => errors.delete(handler);
},
onMessage(handler) {
messages.add(handler);
return () => messages.delete(handler);
},
};
};
}
export function createHostActions(
origin: DaemonClient,
owner: {
own<T extends { release(): Promise<void> }>(create: () => T): T;
signal: AbortSignal;
api: typeof createPaseoApi;
},
): PaseoHostActions {
const assertActive = () => {
if (owner.signal.aborted) throw new Error("Paseo API is disposed");
};
return {
list: () => {
assertActive();
return origin.listHosts();
},
observe: () => owner.own(() => origin.observeHosts()),
add: (input) => {
assertActive();
return origin.addHost(input);
},
update: (id, patch) => {
assertActive();
return origin.updateHost(id, patch);
},
remove: (id) => {
assertActive();
return origin.removeHost(id);
},
async client(serverId) {
assertActive();
origin.requireConnectedHosts();
if (origin.getLastServerInfoMessage()?.serverId === serverId) {
const api = owner.api(origin, { signal: owner.signal });
const scope = owner.own(() => ({ release: () => api.dispose() }));
return { ...api, dispose: () => scope.release() };
}
const scopeId = safeRandomId();
const cancel = () => origin.cancelHostRoute(scopeId);
owner.signal.addEventListener("abort", cancel, { once: true });
let routeId: string;
try {
routeId = await origin.openHostRoute(serverId, scopeId);
} finally {
owner.signal.removeEventListener("abort", cancel);
}
if (owner.signal.aborted) {
origin.closeHostRoute(routeId);
assertActive();
}
const state: { failure: Error | null } = { failure: null };
const client = new DaemonClient({
url: "ws://routed-host/ws",
clientId: scopeId,
transportFactory: routeTransport(origin, serverId, scopeId, routeId, state),
routedClient: true,
routedServerId: serverId,
routedFailure: () => state.failure,
});
const api = owner.api(client, { signal: owner.signal });
const scope = owner.own(() => ({
release: async () => {
try {
await api.dispose();
} finally {
await client.close();
}
},
}));
try {
await client.connect();
assertActive();
return { ...api, dispose: () => scope.release() };
} catch (error) {
const failure = state.failure ?? error;
await scope.release();
throw failure;
}
},
};
}
+1
View File
@@ -267,6 +267,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
"agents",
"config",
"dispose",
"hosts",
"observeEvents",
"projects",
"providers",
+6
View File
@@ -1,3 +1,6 @@
import { createHostActions, type PaseoHostActions } from "./hosts/index.js";
export type { PaseoHostActions } from "./hosts/index.js";
export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
import type { OwnedSubscription } from "./connection/index.js";
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
import type { DaemonClientConfig } from "./daemon-client.js";
@@ -484,6 +487,7 @@ export interface PaseoConfigActions {
}
export interface PaseoApi {
readonly hosts: PaseoHostActions;
dispose(): Promise<void>;
observeEvents: DaemonClient["observeEvents"];
readonly terminals: PaseoTerminalActions;
@@ -695,6 +699,7 @@ export function createPaseoApi(
return {
dispose,
hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }),
observeEvents,
terminals,
projects: {
@@ -1034,6 +1039,7 @@ function listProviderUsage(
daemonClient: DaemonClient,
options?: PaseoProviderUsageOptions,
): Promise<PaseoProviderUsageResult> {
daemonClient.ensureConnected();
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98.
if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) {
return Promise.reject(new Error("Update the host to list provider usage."));
+22
View File
@@ -0,0 +1,22 @@
/** Counts bytes until the socket's send callback, shared by all links owned by a daemon. */
export class HostTransportBudget {
private used = 0;
constructor(readonly limit = 64 * 1024 * 1024) {}
get bufferedBytes(): number {
return this.used;
}
reserve(bytes: number): () => void {
if (bytes > this.limit - this.used)
throw Object.assign(new Error("Connected host buffer limit reached"), {
code: "resource_limit",
});
this.used += bytes;
let released = false;
return () => {
if (!released) {
released = true;
this.used -= bytes;
}
};
}
}
@@ -0,0 +1,53 @@
import { once } from "node:events";
import { createServer } from "node:http";
import { afterEach, expect, test } from "vitest";
import { WebSocketServer, WebSocket } from "ws";
import { createHostTransportFactory, HostTransportBudget } from "./index.js";
const cleanup: (() => Promise<unknown>)[] = [];
afterEach(async () => {
for (const close of cleanup.splice(0).toReversed()) await close();
});
async function endpoint() {
const server = createServer();
const wss = new WebSocketServer({ server });
server.listen(0, "127.0.0.1");
await once(server, "listening");
cleanup.push(async () => {
for (const socket of wss.clients) socket.terminate();
await new Promise<void>((resolve) => wss.close(() => server.close(() => resolve())));
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing port");
return { wss, url: `ws://127.0.0.1:${address.port}/ws` };
}
test("connector retains per-attempt headers and WebSocket subprotocols", async () => {
const server = await endpoint();
const connected = once(server.wss, "connection");
const transport = createHostTransportFactory(server.url)({
url: server.url,
headers: { Authorization: "Bearer example" },
protocols: ["paseo-test"],
});
cleanup.push(async () => transport.close());
const [socket, request] = (await connected) as [WebSocket, import("node:http").IncomingMessage];
expect(request.headers.authorization).toBe("Bearer example");
expect(socket.protocol).toBe("paseo-test");
});
test("a slow consumer cannot exceed the shared byte budget and close releases reserved bytes", async () => {
const server = await endpoint();
const budget = new HostTransportBudget(4 * 1024 * 1024);
const connected = once(server.wss, "connection");
const transport = createHostTransportFactory(server.url, budget)({ url: server.url });
cleanup.push(async () => transport.close());
const ready = new Promise<void>((resolve) => transport.onOpen(resolve));
const [socket] = (await connected) as [WebSocket];
socket.pause();
await ready;
const frame = new Uint8Array(1024 * 1024);
for (let index = 0; index < 4; index++) transport.send(frame);
expect(budget.bufferedBytes).toBe(4 * 1024 * 1024);
expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached");
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
transport.close();
await expect.poll(() => budget.bufferedBytes).toBe(0);
});
+190
View File
@@ -0,0 +1,190 @@
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js";
import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js";
import type {
DaemonTransport,
DaemonTransportFactory,
WebSocketLike,
} from "../daemon-client-transport-types.js";
import { DaemonClient, type DaemonClientConfig } from "../daemon-client.js";
export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
): DaemonTransportFactory {
const target = parseHostTarget(connection);
return (attemptOptions) => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const errors = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
let closed = false;
let closeEvent: unknown;
let transport: DaemonTransport | undefined;
let teardown = () => {};
let sshTunnel: SshTunnel | null = null;
const close = (code?: number, reason?: string) => {
if (closed) return;
closed = true;
try {
transport?.close(code, reason);
} finally {
try {
teardown();
} finally {
for (const handler of closes) handler(closeEvent);
}
}
};
void (async () => {
let socketTarget: Extract<HostTarget, { kind: "socket" }>;
if (target.kind === "ssh") {
const tunnel = await createSshTunnel(target.ssh);
sshTunnel = tunnel;
teardown = () => tunnel.close();
socketTarget = parseHostTarget(tunnel.endpoint) as Extract<HostTarget, { kind: "socket" }>;
} else socketTarget = target;
if (closed) {
teardown();
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, options) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
options?.protocols,
{
headers: options?.headers,
...(socketPath ? { socketPath } : {}),
maxPayload: 24 * 1024 * 1024,
},
);
const pending = new Set<() => void>();
const send = socket.send.bind(socket);
socket.send = (data, ...args: unknown[]) => {
const bytes =
typeof data === "string"
? Buffer.byteLength(data)
: Buffer.byteLength(data as Uint8Array);
if (socket.bufferedAmount + bytes > 24 * 1024 * 1024) {
socket.terminate();
throw Object.assign(new Error("Host socket buffer limit reached"), {
code: "resource_limit",
});
}
let release: () => void;
try {
release = budget.reserve(bytes);
} catch (error) {
socket.terminate();
throw error;
}
pending.add(release);
const callback = args.find(
(value): value is (error?: Error) => void => typeof value === "function",
);
const sendOptions = args.find((value) => value !== null && typeof value === "object") as
| { binary?: boolean }
| undefined;
try {
send(data, sendOptions ?? {}, (error) => {
release();
pending.delete(release);
callback?.(error);
});
} catch (error) {
release();
pending.delete(release);
throw error;
}
};
socket.once("close", () => {
for (const release of pending) release();
pending.clear();
});
return socket as unknown as WebSocketLike;
});
if (socketTarget.offer)
factory = createRelayE2eeTransportFactory({
baseFactory: factory,
daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64,
logger: { warn() {} },
});
transport = factory({ ...attemptOptions, url: socketTarget.url });
transport.onOpen(() => {
if (!closed) for (const handler of opens) handler();
});
transport.onClose((event) => {
closeEvent = event;
close();
});
transport.onError((error) => {
const detail = sshTunnel?.failureDetail() ?? "";
const failure =
/permission denied|authentication failed|host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(
detail,
)
? Object.assign(
new Error(
"SSH authentication or host verification failed. Check the SSH configuration on this daemon.",
),
{ code: "authentication_failed" },
)
: error;
for (const handler of errors) handler(failure);
close();
});
transport.onMessage((data, binary) => {
if (!closed) for (const handler of messages) handler(data, binary);
});
})().catch(() => {
for (const handler of errors) handler(new Error("Host transport failed"));
close();
});
return {
send(data) {
if (closed || !transport) throw new Error("Host transport is unavailable");
transport.send(data);
},
close,
onOpen(handler) {
opens.add(handler);
return () => opens.delete(handler);
},
onClose(handler) {
closes.add(handler);
return () => closes.delete(handler);
},
onError(handler) {
errors.add(handler);
return () => errors.delete(handler);
},
onMessage(handler) {
messages.add(handler);
return () => messages.delete(handler);
},
};
};
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
): DaemonClient {
const target = parseHostTarget(connection);
return new DaemonClient({
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection),
});
}
+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),
});
});
});
}