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

This commit is contained in:
Mohamed Boudra
2026-10-21 10:33:11 +02:00
parent 5d70bf0645
commit 3b50636e14
39 changed files with 3623 additions and 370 deletions
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "vitest";
import { parseHostTarget } from "./host-target.js";
test.each([
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
["4321", "tcp://127.0.0.1:4321"],
["[::1]:4321", "tcp://[::1]:4321"],
["unix:///tmp/paseo.sock", "unix:///tmp/paseo.sock"],
["pipe://\\\\.\\pipe\\paseo", "pipe://\\\\.\\pipe\\paseo"],
["wss://example.com/custom?token=secret", "wss://example.com"],
["tcp://example.com:6767?password=secret", "tcp://example.com:6767"],
])(
"the shared connector accepts %s and exposes only safe display text",
(connection, description) => {
expect(parseHostTarget(connection).description).toBe(description);
},
);
test("SSH daemon credentials are separate from SSH authentication and excluded from display text", () => {
expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777&password=secret")).toEqual({
kind: "ssh",
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
password: "secret",
description: "ssh://deploy@build-box:2222 (daemon port 7777)",
});
});
test.each([
"",
"ssh://-bad",
"ssh://user:secret@host",
"ssh://host?unknown=secret",
"unix://",
"tcp://host:0",
"C:\\Users\\socket",
])("invalid target syntax is rejected without echoing %s", (connection) => {
expect(() => parseHostTarget(connection)).toThrow("Invalid host connection");
});
+123
View File
@@ -0,0 +1,123 @@
import { parseConnectionOfferFromUrl, type ConnectionOffer } from "./connection-offer.js";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "./daemon-endpoints.js";
import { parseSshTransportUri, type SshTransportTarget } from "./ssh-transport.js";
export type HostTarget =
| { kind: "ssh"; ssh: SshTransportTarget; password?: string; description: string }
| {
kind: "socket";
url: string;
socketPath?: string;
password?: string;
offer?: ConnectionOffer;
description: string;
};
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
export function parseHostTarget(input: string): HostTarget {
try {
return parse(input.trim());
} catch {
throw Object.assign(
new Error(
"Invalid host connection. Use host:port, ws(s)://, tcp://, unix://, pipe://, ssh:// or a pairing URL.",
),
{ code: "invalid_connection" },
);
}
}
function parse(input: string): HostTarget {
if (!input) throw new Error("empty");
if (input.startsWith("ssh://")) {
return sshTarget(input);
}
if (input.startsWith("relay://") || input.includes("#connect=")) {
const relay = parseRelayConnectionUri(input);
return relayTarget(relay.offer, relay.password);
}
const offer = parseConnectionOfferFromUrl(input);
if (offer) return relayTarget(offer);
if (
input.startsWith("unix://") ||
input.startsWith("pipe://") ||
input.startsWith("/") ||
input.startsWith("~") ||
input.startsWith("\\\\.\\pipe\\")
) {
const socketPath = input.replace(/^(unix|pipe):\/\//, "");
if (!socketPath || socketPath === "~") throw new Error("empty socket");
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
return {
kind: "socket",
socketPath,
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
};
}
if (input.startsWith("tcp://")) return tcpTarget(input);
if (/^wss?:\/\//.test(input)) return websocketTarget(input);
const endpoint = normalizeHostPort(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return {
kind: "socket",
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
description: `tcp://${endpoint}`,
};
}
function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
return {
kind: "socket",
url: buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
}),
offer,
password,
description: `relay://${offer.relay.endpoint}/${offer.serverId}`,
};
}
function tcpTarget(input: string): HostTarget {
const parts = parseConnectionUri(input);
const endpoint = normalizeHostPort(
`${parts.isIpv6 ? `[${parts.host}]` : parts.host}:${parts.port}`,
);
return {
kind: "socket",
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
password: parts.password,
description: `tcp://${endpoint}`,
};
}
function websocketTarget(input: string): HostTarget {
const url = new URL(input);
if (!url.hostname || url.username || url.password || url.hash) throw new Error("invalid url");
const password = url.searchParams.get("password") ?? undefined;
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
if (url.pathname === "/") url.pathname = "/ws";
url.searchParams.delete("password");
return { kind: "socket", url: url.toString(), password, description: url.origin };
}
function sshTarget(input: string): HostTarget {
const uri = new URL(input);
if (uri.searchParams.getAll("password").length > 1) throw new Error("Duplicate daemon password");
const password = uri.searchParams.get("password") || undefined;
uri.searchParams.delete("password");
const ssh = parseSshTransportUri(uri.toString());
return {
kind: "ssh",
ssh,
...(password ? { password } : {}),
description: `ssh://${ssh.host}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`,
};
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
export const HostMetadataSchema = z.record(z.string().max(256), z.string().max(4096));
export const HostAddSchema = z.object({
connection: z.string().min(1).max(16384),
label: z.string().max(256).optional(),
metadata: HostMetadataSchema.optional(),
sourceKey: z.string().min(1).max(256).optional(),
});
export const HostUpdateSchema = z.object({
label: z.string().max(256).nullable().optional(),
metadata: HostMetadataSchema.optional(),
enabled: z.boolean().optional(),
connection: z.string().min(1).max(16384).optional(),
replaceIdentity: z.boolean().optional(),
});
export const HostSummarySchema = z.object({
registrationId: z.string(),
serverId: z.string().nullable(),
label: z.string().nullable(),
source: z.object({ id: z.string(), key: z.string() }),
metadata: HostMetadataSchema,
enabled: z.boolean(),
status: z.enum(["connecting", "online", "offline", "error", "disabled"]),
targetDescription: z.string(),
lastOnlineAt: z.string().nullable(),
nextRetryAt: z.string().nullable(),
error: z.object({ code: z.string(), message: z.string() }).nullable(),
});
export const HostDirectorySchema = z.object({
self: z.object({ serverId: z.string(), label: z.string() }),
hosts: z.array(HostSummarySchema),
});
export type HostAdd = z.infer<typeof HostAddSchema>;
export type HostUpdate = z.infer<typeof HostUpdateSchema>;
export type HostSummary = z.infer<typeof HostSummarySchema>;
export type HostDirectory = z.infer<typeof HostDirectorySchema>;
export const HostsListRequestSchema = z.object({
type: z.literal("hosts.list.request"),
requestId: z.string(),
observe: z.boolean().optional(),
});
export const HostsAddRequestSchema = z.object({
type: z.literal("hosts.add.request"),
requestId: z.string(),
input: HostAddSchema,
});
export const HostsUpdateRequestSchema = z.object({
type: z.literal("hosts.update.request"),
requestId: z.string(),
registrationId: z.string(),
patch: HostUpdateSchema,
});
export const HostsRemoveRequestSchema = z.object({
type: z.literal("hosts.remove.request"),
requestId: z.string(),
registrationId: z.string(),
});
export const HostsRouteOpenRequestSchema = z.object({
type: z.literal("hosts.route.open.request"),
requestId: z.string(),
serverId: z.string(),
scopeId: z.string().min(1).max(256),
});
export const HostsRouteFrameRequestSchema = z.object({
type: z.literal("hosts.route.frame.request"),
routeId: z.string(),
data: z.string().max(24 * 1024 * 1024),
binary: z.boolean(),
});
export const HostsRouteCloseRequestSchema = z.object({
type: z.literal("hosts.route.close.request"),
routeId: z.string().optional(),
scopeId: z.string().optional(),
});
export const HostsListResponseSchema = z.object({
type: z.literal("hosts.list.response"),
payload: z.object({
requestId: z.string(),
directory: HostDirectorySchema,
subscriptionId: z.string().optional(),
}),
});
export const HostsChangedSchema = z.object({
type: z.literal("hosts.changed"),
payload: z.object({ subscriptionId: z.string(), directory: HostDirectorySchema }),
});
export const HostsAddResponseSchema = z.object({
type: z.literal("hosts.add.response"),
payload: z.object({ requestId: z.string(), host: HostSummarySchema }),
});
export const HostsUpdateResponseSchema = z.object({
type: z.literal("hosts.update.response"),
payload: z.object({ requestId: z.string(), host: HostSummarySchema }),
});
export const HostsRemoveResponseSchema = z.object({
type: z.literal("hosts.remove.response"),
payload: z.object({ requestId: z.string() }),
});
export const HostsRouteOpenResponseSchema = z.object({
type: z.literal("hosts.route.open.response"),
payload: z.object({ requestId: z.string(), routeId: z.string() }),
});
export const HostsRouteFrameSchema = z.object({
type: z.literal("hosts.route.frame"),
payload: z.object({ subscriptionId: z.string(), data: z.string(), binary: z.boolean() }),
});
export const HostsRouteClosedSchema = z.object({
type: z.literal("hosts.route.closed"),
payload: z.object({ subscriptionId: z.string(), code: z.string(), message: z.string() }),
});
+57 -32
View File
@@ -1,4 +1,21 @@
import { AgentMessageSchema } from "./agent-message.js";
import {
HostsListRequestSchema,
HostsAddRequestSchema,
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
HostsListResponseSchema,
HostsChangedSchema,
HostsAddResponseSchema,
HostsUpdateResponseSchema,
HostsRemoveResponseSchema,
HostsRouteOpenResponseSchema,
HostsRouteFrameSchema,
HostsRouteClosedSchema,
} from "./hosts.js";
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
export {
@@ -885,10 +902,8 @@ export type RecentProviderSessionDescriptorPayload = z.infer<
typeof RecentProviderSessionDescriptorPayloadSchema
>;
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
// =====================================================================// Session Inbound Messages (Session receives these)
// =====================================================================
export const VoiceAudioChunkMessageSchema = z.object({
type: z.literal("voice_audio_chunk"),
audio: z.string(), // base64 encoded
@@ -1214,10 +1229,8 @@ export const SendAgentMessageSchema = z.object({
attachments: AgentAttachmentsSchema,
});
// ============================================================================
// Agent RPCs (requestId-correlated)
// ============================================================================
// =====================================================================// Agent RPCs (requestId-correlated)
// =====================================================================
const DirectorySyncRequestSchema = z.object({
generation: z.string().optional(),
afterSeq: z.number().int().nonnegative().optional(),
@@ -1641,10 +1654,8 @@ export const WriteProjectConfigRequestMessageSchema = z.object({
expectedRevision: PaseoConfigRevisionSchema.nullable(),
});
// ============================================================================
// Dictation Streaming (lossless, resumable)
// ============================================================================
// =====================================================================// Dictation Streaming (lossless, resumable)
// =====================================================================
export const DictationStreamStartMessageSchema = z.object({
type: z.literal("dictation_stream_start"),
dictationId: z.string(),
@@ -2941,10 +2952,8 @@ export const PushUnregisterResponseSchema = z.object({
}),
});
// ============================================================================
// Terminal Messages
// ============================================================================
// =====================================================================// Terminal Messages
// =====================================================================
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string().optional(),
@@ -3208,6 +3217,14 @@ export const SubscriptionReleaseResponseSchema = z.object({
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HostsListRequestSchema,
HostsAddRequestSchema,
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
BrowserHostRegisterRequestSchema,
SubscriptionReleaseRequestSchema,
SessionEventsSetSubscriptionRequestSchema,
@@ -3419,10 +3436,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
// ============================================================================
// Session Outbound Messages (Session emits these)
// ============================================================================
// =====================================================================// Session Outbound Messages (Session emits these)
// =====================================================================
export const ActivityLogPayloadSchema = z.object({
subscriptionId: z.string().optional(),
id: z.string(),
@@ -3578,6 +3593,7 @@ export const ServerInfoStatusPayloadSchema = z
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
// COMPAT(sessionPermissions): optional while clients support older daemons.
principalId: z.string().optional(),
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: z.boolean().optional(),
@@ -3598,6 +3614,7 @@ export const ServerInfoStatusPayloadSchema = z
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
@@ -6397,10 +6414,8 @@ export const ListCommandsResponseSchema = z.object({
}),
});
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
// =====================================================================// Terminal Outbound Messages
// =====================================================================
const TerminalInfoSchema = z.object({
id: z.string(),
name: z.string(),
@@ -6879,6 +6894,15 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HostsListResponseSchema,
HostsChangedSchema,
HostsAddResponseSchema,
HostsUpdateResponseSchema,
HostsRemoveResponseSchema,
HostsRouteOpenResponseSchema,
HostsRouteFrameSchema,
HostsRouteClosedSchema,
BrowserHostRegisterResponseSchema,
SubscriptionReleaseResponseSchema,
SessionEventsSetSubscriptionResponseSchema,
@@ -7556,10 +7580,8 @@ export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
// =====================================================================// WebSocket Level Messages (wraps session messages)
// =====================================================================
// WebSocket-only messages (not session messages)
export const WSPingMessageSchema = z.object({
type: z.literal("ping"),
@@ -7571,6 +7593,11 @@ export const WSPongMessageSchema = z.object({
export const WSHelloMessageSchema = z.object({
type: z.literal("hello"),
sessionPermissions: z.array(DaemonPermissionSchema).optional(),
routed: z.boolean().optional(),
producer: z
.object({ serverId: z.string().min(1).max(256), sourceId: z.string().min(1).max(1024) })
.optional(),
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
protocolVersion: z.number().int(),
@@ -7644,10 +7671,8 @@ export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>;
export type WSHelloMessage = z.infer<typeof WSHelloMessageSchema>;
// ============================================================================
// Helper functions for message conversion
// ============================================================================
// =====================================================================// Helper functions for message conversion
// =====================================================================
/**
* Extract session message from WebSocket message
* Returns null if message should be handled at WS level only