mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: emit connection offer
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
113
packages/server/src/server/connection-offer.ts
Normal file
113
packages/server/src/server/connection-offer.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user