diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 993ca4b14..585bed458 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -49,6 +49,11 @@ import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; +import { + buildOfferEndpoints, + createConnectionOfferV1, + encodeOfferToFragmentUrl, +} from "./connection-offer.js"; import type { AgentClient, AgentControlMcpConfig, @@ -74,6 +79,9 @@ export type PaseoDaemonConfig = { agentClients: Partial>; agentRegistryPath: string; agentControlMcp: AgentControlMcpConfig; + relayEnabled?: boolean; + relayEndpoint?: string; + appBaseUrl?: string; openai?: PaseoOpenAIConfig; downloadTokenTtlMs?: number; }; @@ -406,15 +414,37 @@ export async function createPaseoDaemon( }; const onListening = () => { httpServer.off("error", onError); - if (listenTarget.type === "tcp") { - logger.info( - { host: listenTarget.host, port: listenTarget.port }, - `Server listening on http://${listenTarget.host}:${listenTarget.port}` - ); - } else { - logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`); - } - resolve(); + const logAndResolve = async () => { + if (listenTarget.type === "tcp") { + logger.info( + { host: listenTarget.host, port: listenTarget.port }, + `Server listening on http://${listenTarget.host}:${listenTarget.port}` + ); + + const relayEnabled = config.relayEnabled ?? true; + const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443"; + const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh"; + + const endpoints = buildOfferEndpoints({ + listenHost: listenTarget.host, + port: listenTarget.port, + relayEnabled, + relayEndpoint, + }); + + const offer = await createConnectionOfferV1({ + sessionId: randomUUID(), + endpoints, + }); + + const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); + logger.info({ url }, "pairing_offer"); + } else { + logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`); + } + }; + + logAndResolve().then(resolve, reject); }; httpServer.once("error", onError); httpServer.once("listening", onListening); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index b76babf1b..c66b0070a 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -7,6 +7,8 @@ import { loadPersistedConfig } from "./persisted-config.js"; const DEFAULT_LISTEN = "127.0.0.1:6767"; const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents"; +const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; +const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; function parseOpenAIConfig(env: NodeJS.ProcessEnv) { const apiKey = env.OPENAI_API_KEY; @@ -84,6 +86,9 @@ export function loadConfig( agentRegistryPath: path.join(paseoHome, "agents.json"), staticDir: "public", agentClients: {}, + relayEnabled: true, + relayEndpoint: env.PASEO_RELAY_ENDPOINT ?? DEFAULT_RELAY_ENDPOINT, + appBaseUrl: env.PASEO_APP_BASE_URL ?? DEFAULT_APP_BASE_URL, openai: parseOpenAIConfig(env), }; } diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts new file mode 100644 index 000000000..41b03c57d --- /dev/null +++ b/packages/server/src/server/connection-offer.ts @@ -0,0 +1,113 @@ +import os from "node:os"; +import { webcrypto } from "node:crypto"; +import { z } from "zod"; + +export const ConnectionOfferV1Schema = z.object({ + v: z.literal(1), + sessionId: z.string().min(1), + endpoints: z.array(z.string().min(1)).min(1), + daemonPublicKeyB64: z.string().min(1), +}); + +export type ConnectionOfferV1 = z.infer; + +type BuildOfferEndpointsArgs = { + listenHost: string; + port: number; + relayEnabled: boolean; + relayEndpoint: string; +}; + +export function buildOfferEndpoints({ + listenHost, + port, + relayEnabled, + relayEndpoint, +}: BuildOfferEndpointsArgs): string[] { + const endpoints: string[] = []; + + const isLoopbackHost = listenHost === "127.0.0.1" || listenHost === "localhost"; + const isWildcardHost = + listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "[::]"; + + if (isWildcardHost) { + const lanIp = getPrimaryLanIp(); + if (lanIp) { + endpoints.push(`${lanIp}:${port}`); + } + } else if (!isLoopbackHost) { + endpoints.push(`${listenHost}:${port}`); + } + + endpoints.push(`localhost:${port}`); + endpoints.push(`127.0.0.1:${port}`); + + if (relayEnabled) { + endpoints.push(relayEndpoint); + } + + return dedupePreserveOrder(endpoints); +} + +export async function createConnectionOfferV1(args: { + sessionId: string; + endpoints: string[]; +}): Promise { + const daemonPublicKeyB64 = await generateDaemonPublicKeyB64(); + + return ConnectionOfferV1Schema.parse({ + v: 1, + sessionId: args.sessionId, + endpoints: args.endpoints, + daemonPublicKeyB64, + }); +} + +export function encodeOfferToFragmentUrl(args: { + offer: ConnectionOfferV1; + appBaseUrl: string; +}): string { + const json = JSON.stringify(args.offer); + const encoded = Buffer.from(json, "utf8").toString("base64url"); + return `${args.appBaseUrl.replace(/\/$/, "")}/#offer=${encoded}`; +} + +function getPrimaryLanIp(): string | null { + const override = process.env.PASEO_PRIMARY_LAN_IP?.trim(); + if (override) return override; + + const nets = os.networkInterfaces(); + const names = Object.keys(nets).sort(); + + for (const name of names) { + const addrs = nets[name] ?? []; + for (const addr of addrs) { + if (addr.family === "IPv4" && !addr.internal) { + return addr.address; + } + } + } + return null; +} + +async function generateDaemonPublicKeyB64(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"] + ); + + const raw = await webcrypto.subtle.exportKey("raw", keyPair.publicKey); + return Buffer.from(new Uint8Array(raw)).toString("base64"); +} + +function dedupePreserveOrder(values: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts new file mode 100644 index 000000000..93f7a3720 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test } from "vitest"; +import pino from "pino"; +import net from "node:net"; +import path from "node:path"; +import os from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; +import { Writable } from "node:stream"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +function createCapturingLogger() { + const lines: string[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + lines.push(chunk.toString("utf8")); + cb(); + }, + }); + const logger = pino({ level: "info" }, stream); + return { logger, lines }; +} + +function parseOfferUrlFromLogs(lines: string[]): string { + for (const line of lines) { + try { + const obj = JSON.parse(line) as { msg?: string; url?: string }; + if (obj.msg === "pairing_offer" && typeof obj.url === "string") { + return obj.url; + } + } catch { + // ignore non-JSON lines + } + } + throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); +} + +function decodeOfferFromFragmentUrl(url: string): unknown { + const marker = "#offer="; + const idx = url.indexOf(marker); + if (idx === -1) { + throw new Error(`missing ${marker} fragment: ${url}`); + } + const encoded = url.slice(idx + marker.length); + const json = Buffer.from(encoded, "base64url").toString("utf8"); + return JSON.parse(json) as unknown; +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +describe("ConnectionOfferV1 (daemon E2E)", () => { + const ORIGINAL_ENV = { ...process.env }; + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + test("emits offer URL with sessionId + host:port endpoints (includes relay unless opted out)", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + + const daemon = await createTestPaseoDaemon({ + listen: "0.0.0.0", + logger, + }); + + try { + const offerUrl = parseOfferUrlFromLogs(lines); + expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true); + + const offer = decodeOfferFromFragmentUrl(offerUrl) as { + v: number; + sessionId: string; + endpoints: string[]; + daemonPublicKeyB64: string; + }; + + expect(offer.v).toBe(1); + expect(typeof offer.sessionId).toBe("string"); + expect(offer.sessionId.length).toBeGreaterThan(0); + expect(Array.isArray(offer.endpoints)).toBe(true); + expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`); + expect(offer.endpoints).toContain(`localhost:${daemon.port}`); + expect(offer.endpoints).toContain("relay.paseo.sh:443"); + expect(typeof offer.daemonPublicKeyB64).toBe("string"); + expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0); + expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow(); + } finally { + await daemon.close(); + } + }); + + test( + "respects --no-relay (CLI) by omitting relay endpoint from offer", + async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-")); + const port = await getAvailablePort(); + + const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url)); + const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx"); + + const env = { + ...process.env, + PASEO_HOME: tempHome, + PASEO_LISTEN: `0.0.0.0:${port}`, + OPENAI_API_KEY: "", + }; + + const stdoutLines: string[] = []; + const proc = spawn(tsxBin, [indexPath, "--no-relay"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + const offerUrl = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error("timed out waiting for pairing_offer log")); + }, 15000); + + const onData = (data: Buffer) => { + const text = data.toString("utf8"); + stdoutLines.push(text); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + if (!line.includes("pairing_offer")) continue; + const match = line.match(/"url":"([^"]+)"/); + if (match) { + clearTimeout(timeout); + resolve(match[1]); + return; + } + } + }; + + proc.stdout?.on("data", onData); + proc.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + proc.on("exit", (code) => { + if (code && code !== 0) { + clearTimeout(timeout); + reject(new Error(`daemon process exited early with code ${code}`)); + } + }); + }); + + const offer = decodeOfferFromFragmentUrl(offerUrl) as { + endpoints: string[]; + }; + + expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); + expect(offer.endpoints).toContain(`localhost:${port}`); + expect(offer.endpoints).toContain(`192.168.1.12:${port}`); + } catch (err) { + throw new Error( + `failed; stdout so far:\\n${stdoutLines.join("")}\\n\\n${String(err)}` + ); + } finally { + proc.kill(); + await rm(tempHome, { recursive: true, force: true }); + } + }, + 30000 + ); +}); diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index e23ca7ced..ca46e9c50 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -15,6 +15,10 @@ async function main() { const persistedConfig = loadPersistedConfig(paseoHome); const logger = createRootLogger(persistedConfig); const config = loadConfig(paseoHome); + + if (process.argv.includes("--no-relay")) { + config.relayEnabled = false; + } const daemon = await createPaseoDaemon(config, logger); await daemon.start(); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 6a3b953aa..c2a20a654 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -10,6 +10,7 @@ type TestPaseoDaemonOptions = { downloadTokenTtlMs?: number; corsAllowedOrigins?: string[]; listen?: string; + logger?: Parameters[1]; }; export type TestPaseoDaemon = { @@ -62,11 +63,14 @@ export async function createTestPaseoDaemon( agentControlMcp: { url: `http://127.0.0.1:${port}/mcp/agents`, }, + relayEnabled: true, + relayEndpoint: "relay.paseo.sh:443", + appBaseUrl: "https://app.paseo.sh", openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined, downloadTokenTtlMs: options.downloadTokenTtlMs, }; - const logger = pino({ level: "silent" }); + const logger = options.logger ?? pino({ level: "silent" }); const daemon = await createPaseoDaemon(config, logger); try { await daemon.start();