feat: emit connection offer

This commit is contained in:
Mohamed Boudra
2026-01-21 08:23:42 +07:00
parent cba3ad2721
commit 4a90312480
6 changed files with 351 additions and 10 deletions

View File

@@ -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<Record<AgentProvider, AgentClient>>;
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);

View File

@@ -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),
};
}

View File

@@ -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<typeof ConnectionOfferV1Schema>;
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<ConnectionOfferV1> {
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<string> {
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<string>();
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}

View File

@@ -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<number> {
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<string>((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
);
});

View File

@@ -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();

View File

@@ -10,6 +10,7 @@ type TestPaseoDaemonOptions = {
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
logger?: Parameters<typeof createPaseoDaemon>[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();