From 4992b8d3da57db99c26ca16bdd16067b0a269554 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 04:56:27 +0100 Subject: [PATCH] feat(protocol): expose structured host connection targets --- packages/protocol/src/host-target.test.ts | 28 +++++++++++++++++++++++ packages/protocol/src/host-target.ts | 27 +++++++++++++++++++++- packages/protocol/src/ssh-transport.ts | 13 +++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/protocol/src/host-target.test.ts b/packages/protocol/src/host-target.test.ts index df0127e573..de76050a73 100644 --- a/packages/protocol/src/host-target.test.ts +++ b/packages/protocol/src/host-target.test.ts @@ -1,5 +1,33 @@ import { expect, test } from "vitest"; import { parseHostTarget } from "./host-target.js"; +import { serializeSshTransportUri } from "./ssh-transport.js"; +import { serializeSocketHostTarget } from "./host-target.js"; + +test("SSH and local socket targets round trip through their serializers", () => { + const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777"); + if (ssh.kind !== "ssh") throw new Error("expected SSH"); + expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh); + for (const [type, path] of [ + ["unix", "/tmp/paseo.sock"], + ["pipe", "\\\\.\\pipe\\paseo"], + ] as const) { + const target = parseHostTarget(`${type}://${path}`); + expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target); + } +}); + +test.each([ + ["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }], + [ + "tcp://example.com:6767?ssl=true", + { type: "directTcp", endpoint: "example.com:6767", useTls: true }, + ], + ["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }], + ["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }], + ["pipe://mypipe", { type: "directPipe", path: "mypipe" }], +])("%s exposes its structured connection", (input, connection) => { + expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection }); +}); test.each([ ["127.0.0.1:6767", "tcp://127.0.0.1:6767"], diff --git a/packages/protocol/src/host-target.ts b/packages/protocol/src/host-target.ts index 498b20b1e7..1fca67d70a 100644 --- a/packages/protocol/src/host-target.ts +++ b/packages/protocol/src/host-target.ts @@ -14,12 +14,23 @@ export type HostTarget = | { kind: "socket"; url: string; + connection: + | { type: "directTcp"; endpoint: string; useTls: boolean } + | { type: "directSocket"; path: string } + | { type: "directPipe"; path: string } + | { type: "relay"; offer: ConnectionOffer }; socketPath?: string; password?: string; offer?: ConnectionOffer; description: string; }; +/** Serialize a local socket target in the shared host grammar. */ +export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string { + if (!path || path === "~") throw new Error("Invalid socket path"); + return `${type}://${path}`; +} + /** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */ export function parseHostTarget(input: string): HostTarget { try { @@ -57,6 +68,9 @@ function parse(input: string): HostTarget { const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\"); return { kind: "socket", + connection: pipe + ? { type: "directPipe", path: socketPath } + : { type: "directSocket", path: socketPath }, socketPath, url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`, description: `${pipe ? "pipe" : "unix"}://${socketPath}`, @@ -67,6 +81,7 @@ function parse(input: string): HostTarget { const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input); return { kind: "socket", + connection: { type: "directTcp", endpoint, useTls: false }, url: buildDaemonWebSocketUrl(endpoint, { useTls: false }), description: `tcp://${endpoint}`, }; @@ -75,6 +90,7 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget { const endpoint = validatedEndpoint(offer.relay.endpoint); return { kind: "socket", + connection: { type: "relay", offer }, url: buildRelayWebSocketUrl({ endpoint, serverId: offer.serverId, @@ -97,6 +113,7 @@ function tcpTarget(input: string): HostTarget { ); return { kind: "socket", + connection: { type: "directTcp", endpoint, useTls: parts.useTls }, url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }), password: parts.password, description: `tcp://${endpoint}`, @@ -109,7 +126,15 @@ function websocketTarget(input: string): HostTarget { // 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 }; + const useTls = url.protocol === "wss:"; + const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`; + return { + kind: "socket", + connection: { type: "directTcp", endpoint, useTls }, + url: url.toString(), + password, + description: url.origin, + }; } function sshTarget(input: string): HostTarget { diff --git a/packages/protocol/src/ssh-transport.ts b/packages/protocol/src/ssh-transport.ts index 9878b10bcf..3e4da53e34 100644 --- a/packages/protocol/src/ssh-transport.ts +++ b/packages/protocol/src/ssh-transport.ts @@ -60,6 +60,19 @@ export function parseSshTransportUri(value: string): SshTransportTarget { }; } +/** Serialize an SSH target without daemon credentials. */ +export function serializeSshTransportUri(target: SshTransportTarget): string { + const host = validateSshHost(target.host); + const separator = host.lastIndexOf("@"); + const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`; + const hostname = host.slice(separator + 1); + const authority = hostname.includes(":") ? `[${hostname}]` : hostname; + const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`; + const daemonPort = validatePort(target.daemonPort, "Daemon port"); + const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`; + return `ssh://${username}${authority}${port}${query}`; +} + export function buildSshTunnelArgs(target: SshTransportTarget): string[] { const host = validateSshHost(target.host); const daemonPort = validatePort(target.daemonPort, "Daemon port");