From 30f7842e8f074136ca7e420dababe0908e87373f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Apr 2026 21:37:51 +0800 Subject: [PATCH] feat(cli): connect via relay using pairing offer URL (#639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass `--host https://app.paseo.sh/#offer=...` (or `PASEO_HOST=...`) to any CLI command and the connection routes through the relay with E2EE, the same way the mobile and desktop apps connect. Same `paseo daemon pair` link the app already accepts — no new pairing flow. Closes #181 --- packages/app/src/utils/daemon-endpoints.ts | 14 +- packages/cli/src/utils/client.ts | 80 +++++- packages/cli/tests/e2e/relay-host.test.ts | 232 ++++++++++++++++++ packages/cli/tests/helpers/test-daemon.ts | 9 +- packages/server/src/server/exports.ts | 8 + .../server/src/shared/connection-offer.ts | 36 +++ public-docs/cli.md | 21 +- 7 files changed, 373 insertions(+), 27 deletions(-) create mode 100644 packages/cli/tests/e2e/relay-host.test.ts diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index c81a052ee..2b903df91 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -1,4 +1,3 @@ -import { Buffer } from "buffer"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl, @@ -9,6 +8,8 @@ import { type HostPortParts, } from "@server/shared/daemon-endpoints"; +export { decodeOfferFragmentPayload } from "@server/shared/connection-offer"; + export type { HostPortParts }; export { @@ -19,17 +20,6 @@ export { parseHostPort, }; -function decodeBase64UrlToUtf8(input: string): string { - const base64 = input.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); - return Buffer.from(padded, "base64").toString("utf8"); -} - -export function decodeOfferFragmentPayload(encoded: string): unknown { - const json = decodeBase64UrlToUtf8(encoded); - return JSON.parse(json) as unknown; -} - export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string { return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); } diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index c46c006cd..87916ca75 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,13 @@ import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome, DaemonClient } from "@getpaseo/server"; +import { + loadConfig, + resolvePaseoHome, + DaemonClient, + buildRelayWebSocketUrl, + parseConnectionOfferFromUrl, + type ConnectionOffer, + type WebSocketLike, +} from "@getpaseo/server"; import path from "node:path"; import { WebSocket } from "ws"; import { getOrCreateCliClientId } from "./client-id.js"; @@ -176,18 +184,14 @@ export function resolveDaemonTarget(host: string): DaemonTarget { * Create a WebSocket factory that works in Node.js */ function createNodeWebSocketFactory() { - return (url: string, options?: { headers?: Record; socketPath?: string }) => { + return ( + url: string, + options?: { headers?: Record; socketPath?: string }, + ): WebSocketLike => { return new WebSocket(url, { headers: options?.headers, ...(options?.socketPath ? { socketPath: options.socketPath } : {}), - }) as unknown as { - readyState: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; - close: (code?: number, reason?: string) => void; - binaryType?: string; - on: (event: string, listener: (...args: unknown[]) => void) => void; - off: (event: string, listener: (...args: unknown[]) => void) => void; - }; + }) as unknown as WebSocketLike; }; } @@ -214,7 +218,7 @@ async function tryConnectHost( ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), }), reconnect: { enabled: false }, - } as unknown as ConstructorParameters[0]); + }); try { await client.connect(); @@ -225,12 +229,64 @@ async function tryConnectHost( } } +async function connectViaRelayOffer( + offer: ConnectionOffer, + clientId: string, + timeout: number, + nodeWebSocketFactory: ReturnType, +): Promise { + const url = buildRelayWebSocketUrl({ + endpoint: offer.relay.endpoint, + serverId: offer.serverId, + role: "client", + }); + + const client = new DaemonClient({ + url, + clientId, + clientType: "cli", + appVersion: resolveCliVersion(), + connectTimeoutMs: timeout, + webSocketFactory: (target: string, config?: { headers?: Record }) => + nodeWebSocketFactory(target, { headers: config?.headers }), + 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 }); + } +} + export async function connectToDaemon(options?: ConnectOptions): Promise { const timeout = options?.timeout ?? DEFAULT_TIMEOUT; const clientId = await getOrCreateCliClientId(); - const hosts = resolveDaemonHostCandidates(options); const nodeWebSocketFactory = createNodeWebSocketFactory(); + const explicitHost = options?.host ?? process.env.PASEO_HOST; + const offer = parseHostOfferOrNull(explicitHost); + if (offer) { + return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory); + } + + const hosts = resolveDaemonHostCandidates(options); + async function tryNext(index: number, lastError: unknown): Promise { if (index >= hosts.length) { if (lastError instanceof Error) throw lastError; diff --git a/packages/cli/tests/e2e/relay-host.test.ts b/packages/cli/tests/e2e/relay-host.test.ts new file mode 100644 index 000000000..65315e7a5 --- /dev/null +++ b/packages/cli/tests/e2e/relay-host.test.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createRequire } from "node:module"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildRelayWebSocketUrl, + DaemonClient, + generateLocalPairingOffer, + parseConnectionOfferFromUrl, +} from "@getpaseo/server"; +import { WebSocket } from "ws"; +import { getAvailablePort } from "../helpers/network.ts"; +import { createE2ETestContext } from "../helpers/test-daemon.ts"; + +const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0"); +const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25; +const wranglerCliPath = createRequire(import.meta.url).resolve("wrangler/bin/wrangler.js"); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const relayDir = path.resolve(__dirname, "../../../relay"); +const STARTUP_HOOK_TIMEOUT_MS = 120_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function emitRelayLines(chunk: Buffer, prefix: "stdout" | "stderr"): void { + const lines = chunk.toString().split("\n"); + for (const line of lines) { + if (line.trim().length === 0) continue; + // eslint-disable-next-line no-console + if (prefix === "stderr") console.error(`[relay] ${line}`); + // eslint-disable-next-line no-console + else console.log(`[relay] ${line}`); + } +} + +function forwardRelayStdout(data: Buffer): void { + emitRelayLines(data, "stdout"); +} + +function forwardRelayStderr(data: Buffer): void { + emitRelayLines(data, "stderr"); +} + +function spawnRelayDevServer(port: number): ChildProcess { + return spawn( + process.execPath, + [ + wranglerCliPath, + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + String(port), + "--live-reload=false", + "--show-interactive-dev-session=false", + ], + { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); +} + +function assertRelayStillRunning(relayProcess: ChildProcess): void { + if (relayProcess.exitCode !== null) { + throw new Error( + `relay process exited before startup completed (code: ${relayProcess.exitCode})`, + ); + } +} + +function tryConnect(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.end(); + resolve(); + }); + socket.on("error", reject); + }); +} + +async function waitForServer( + port: number, + relayProcess: ChildProcess, + timeout = 30000, +): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + assertRelayStillRunning(relayProcess); + try { + await tryConnect(port); + return; + } catch { + await sleep(150); + } + } + throw new Error(`Server did not start on port ${port} within ${timeout}ms`); +} + +async function waitForProcessExit(relayProcess: ChildProcess, deadline: number): Promise { + while (relayProcess.exitCode === null && Date.now() < deadline) { + await sleep(50); + } +} + +async function stopRelayProcess(relayProcess: ChildProcess): Promise { + if (relayProcess.exitCode !== null) return; + relayProcess.kill("SIGTERM"); + await waitForProcessExit(relayProcess, Date.now() + SHUTDOWN_TIMEOUT_MS); + if (relayProcess.exitCode !== null) return; + relayProcess.kill("SIGKILL"); + await waitForProcessExit(relayProcess, Date.now() + 2000); +} + +async function probeRelayConnection(offerUrl: string): Promise { + const offer = parseConnectionOfferFromUrl(offerUrl); + if (!offer) return false; + const url = buildRelayWebSocketUrl({ + endpoint: offer.relay.endpoint, + serverId: offer.serverId, + role: "client", + }); + const client = new DaemonClient({ + url, + clientId: `relay-probe-${Date.now()}`, + clientType: "cli", + connectTimeoutMs: 4000, + e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, + reconnect: { enabled: false }, + webSocketFactory: (target: string, opts?: { headers?: Record }) => + new WebSocket(target, { headers: opts?.headers }) as unknown as ReturnType< + NonNullable[0]["webSocketFactory"]> + >, + }); + try { + await client.connect(); + return true; + } catch { + return false; + } finally { + await client.close().catch(() => {}); + } +} + +async function waitForDaemonRelayRegistered(offerUrl: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probeRelayConnection(offerUrl)) return; + await sleep(500); + } + throw new Error("Daemon failed to register with the relay within timeout"); +} + +(shouldRunRelayE2e ? describe : describe.skip)("CLI --host offer URL via relay", () => { + let relayPort: number; + let relayProcess: ChildProcess | null = null; + let ctx: Awaited> | null = null; + let offerUrl: string; + + beforeAll(async () => { + relayPort = await getAvailablePort(); + relayProcess = spawnRelayDevServer(relayPort); + relayProcess.stdout?.on("data", forwardRelayStdout); + relayProcess.stderr?.on("data", forwardRelayStderr); + + try { + await waitForServer(relayPort, relayProcess, 60_000); + } catch (error) { + await stopRelayProcess(relayProcess); + relayProcess = null; + throw error; + } + + const relayEndpoint = `127.0.0.1:${relayPort}`; + ctx = await createE2ETestContext({ + timeout: 60_000, + env: { + PASEO_RELAY_ENABLED: "true", + PASEO_RELAY_ENDPOINT: relayEndpoint, + PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint, + }, + }); + + const offer = await generateLocalPairingOffer({ + paseoHome: ctx.paseoHome, + relayEnabled: true, + relayEndpoint, + relayPublicEndpoint: relayEndpoint, + includeQr: false, + }); + if (!offer.url) throw new Error("generateLocalPairingOffer returned no URL"); + offerUrl = offer.url; + + await waitForDaemonRelayRegistered(offerUrl, 30_000); + }, STARTUP_HOOK_TIMEOUT_MS); + + afterAll(async () => { + if (ctx) { + await ctx.stop(); + ctx = null; + } + if (relayProcess) { + await stopRelayProcess(relayProcess); + relayProcess = null; + } + }, SHUTDOWN_TIMEOUT_MS); + + it("runs `paseo --host ls` over the relay and matches direct ls output", async () => { + if (!ctx) throw new Error("test context not initialized"); + + const direct = await ctx.paseo(["ls", "--json"]); + expect(direct.exitCode, `direct ls failed: ${direct.stderr}`).toBe(0); + const directAgents = JSON.parse(direct.stdout.trim() || "[]"); + expect(Array.isArray(directAgents)).toBe(true); + + const relay = await ctx.paseo(["ls", "--json", "--host", offerUrl], { + timeout: 30_000, + env: { PASEO_HOST: offerUrl }, + }); + expect(relay.exitCode, `relay ls failed: ${relay.stderr}\nstdout: ${relay.stdout}`).toBe(0); + const relayAgents = JSON.parse(relay.stdout.trim() || "[]"); + expect(Array.isArray(relayAgents)).toBe(true); + expect(relayAgents.length).toBe(directAgents.length); + }, 60_000); +}); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 57fafb0e3..95bbe3791 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -217,6 +217,7 @@ export async function startTestDaemon(options?: { paseoHome?: string; workDir?: string; timeout?: number; + env?: NodeJS.ProcessEnv; }): Promise { const port = options?.port ?? (await getAvailablePort()); const { paseoHome, workDir } = @@ -240,6 +241,7 @@ export async function startTestDaemon(options?: { PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, // Force no TTY to prevent QR code output CI: "true", + ...options?.env, }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", @@ -396,7 +398,10 @@ export async function runPaseoCli( * * This is the main entry point for E2E tests. */ -export async function createE2ETestContext(options?: { timeout?: number }): Promise< +export async function createE2ETestContext(options?: { + timeout?: number; + env?: NodeJS.ProcessEnv; +}): Promise< TestDaemonContext & { /** Run a paseo CLI command against this daemon */ paseo: ( @@ -409,7 +414,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom }>; } > { - const ctx = await startTestDaemon({ timeout: options?.timeout }); + const ctx = await startTestDaemon({ timeout: options?.timeout, env: options?.env }); const paseo = ( args: string[], diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 12f5d85c6..9b081b1e6 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -6,12 +6,20 @@ export { getOrCreateServerId } from "./server-id.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; +export { + ConnectionOfferSchema, + decodeOfferFragmentPayload, + parseConnectionOfferFromUrl, + type ConnectionOffer, +} from "../shared/connection-offer.js"; +export { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent, } from "../client/daemon-client.js"; +export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.js"; export { ensureLocalSpeechModels, listLocalSpeechModels, diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts index 9ecec54bf..7cd9372d7 100644 --- a/packages/server/src/shared/connection-offer.ts +++ b/packages/server/src/shared/connection-offer.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { z } from "zod"; /** @@ -19,3 +20,38 @@ export type ConnectionOfferV2 = z.infer; export const ConnectionOfferSchema = ConnectionOfferV2Schema; export type ConnectionOffer = ConnectionOfferV2; + +function decodeBase64UrlToUtf8(input: string): string { + const base64 = input.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + return Buffer.from(padded, "base64").toString("utf8"); +} + +export function decodeOfferFragmentPayload(encoded: string): unknown { + const json = decodeBase64UrlToUtf8(encoded); + return JSON.parse(json) as unknown; +} + +const OFFER_FRAGMENT_PREFIX = "#offer="; + +function extractOfferFragmentEncoded(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const fragmentIndex = trimmed.indexOf(OFFER_FRAGMENT_PREFIX); + if (fragmentIndex === -1) return null; + const encoded = trimmed.slice(fragmentIndex + OFFER_FRAGMENT_PREFIX.length).trim(); + return encoded.length > 0 ? encoded : null; +} + +/** + * Parse a pairing-offer URL of the form `https://app.paseo.sh/#offer=`. + * + * Returns `null` if the input has no `#offer=` fragment. Throws if the fragment + * exists but the payload is malformed or fails schema validation. + */ +export function parseConnectionOfferFromUrl(input: string): ConnectionOffer | null { + const encoded = extractOfferFragmentEncoded(input); + if (!encoded) return null; + const payload = decodeOfferFragmentPayload(encoded); + return ConnectionOfferSchema.parse(payload); +} diff --git a/public-docs/cli.md b/public-docs/cli.md index 1f5a971ba..9dd9a384f 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -120,6 +120,25 @@ paseo daemon stop # Stop the daemon Use `PASEO_HOME` to run multiple isolated daemon instances. +## Connecting to a remote daemon + +`--host` accepts either a local target (`host:port`, a unix socket, or a Windows pipe) or a pairing offer URL — the same `https://app.paseo.sh/#offer=...` link the mobile app uses for QR pairing. With an offer URL the CLI connects through the Paseo relay with end-to-end encryption, so you can drive a daemon on another machine without exposing it to the network. + +Get an offer URL from the daemon you want to control: + +```bash +paseo daemon pair --json # prints { url, qr, ... } +``` + +Use it from anywhere: + +```bash +paseo ls --host 'https://app.paseo.sh/#offer=eyJ2IjoyLC...' +paseo run --host "$OFFER_URL" "fix the failing tests" +``` + +You can also set it once via `PASEO_HOST` instead of passing `--host` on every command. + ## Multi-agent workflows The CLI is designed to be used by agents themselves. You can instruct an agent to spawn sub-agents for parallel work: @@ -160,7 +179,7 @@ paseo ls -q # IDs only (quiet) ## Global options -- `--host ` — connect to a different daemon +- `--host ` — connect to a different daemon (`host:port`, unix socket, or `https://app.paseo.sh/#offer=...` for relay). See [Connecting to a remote daemon](#connecting-to-a-remote-daemon). - `--json` — JSON output - `-q, --quiet` — minimal output - `--no-color` — disable colors