mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +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 { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
|
import {
|
||||||
|
buildOfferEndpoints,
|
||||||
|
createConnectionOfferV1,
|
||||||
|
encodeOfferToFragmentUrl,
|
||||||
|
} from "./connection-offer.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentControlMcpConfig,
|
AgentControlMcpConfig,
|
||||||
@@ -74,6 +79,9 @@ export type PaseoDaemonConfig = {
|
|||||||
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||||
agentRegistryPath: string;
|
agentRegistryPath: string;
|
||||||
agentControlMcp: AgentControlMcpConfig;
|
agentControlMcp: AgentControlMcpConfig;
|
||||||
|
relayEnabled?: boolean;
|
||||||
|
relayEndpoint?: string;
|
||||||
|
appBaseUrl?: string;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
};
|
};
|
||||||
@@ -406,15 +414,37 @@ export async function createPaseoDaemon(
|
|||||||
};
|
};
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
httpServer.off("error", onError);
|
httpServer.off("error", onError);
|
||||||
if (listenTarget.type === "tcp") {
|
const logAndResolve = async () => {
|
||||||
logger.info(
|
if (listenTarget.type === "tcp") {
|
||||||
{ host: listenTarget.host, port: listenTarget.port },
|
logger.info(
|
||||||
`Server listening on http://${listenTarget.host}:${listenTarget.port}`
|
{ 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}`);
|
|
||||||
}
|
const relayEnabled = config.relayEnabled ?? true;
|
||||||
resolve();
|
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("error", onError);
|
||||||
httpServer.once("listening", onListening);
|
httpServer.once("listening", onListening);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { loadPersistedConfig } from "./persisted-config.js";
|
|||||||
|
|
||||||
const DEFAULT_LISTEN = "127.0.0.1:6767";
|
const DEFAULT_LISTEN = "127.0.0.1:6767";
|
||||||
const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents";
|
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) {
|
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
|
||||||
const apiKey = env.OPENAI_API_KEY;
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
@@ -84,6 +86,9 @@ export function loadConfig(
|
|||||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||||
staticDir: "public",
|
staticDir: "public",
|
||||||
agentClients: {},
|
agentClients: {},
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: env.PASEO_RELAY_ENDPOINT ?? DEFAULT_RELAY_ENDPOINT,
|
||||||
|
appBaseUrl: env.PASEO_APP_BASE_URL ?? DEFAULT_APP_BASE_URL,
|
||||||
openai: parseOpenAIConfig(env),
|
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 persistedConfig = loadPersistedConfig(paseoHome);
|
||||||
const logger = createRootLogger(persistedConfig);
|
const logger = createRootLogger(persistedConfig);
|
||||||
const config = loadConfig(paseoHome);
|
const config = loadConfig(paseoHome);
|
||||||
|
|
||||||
|
if (process.argv.includes("--no-relay")) {
|
||||||
|
config.relayEnabled = false;
|
||||||
|
}
|
||||||
const daemon = await createPaseoDaemon(config, logger);
|
const daemon = await createPaseoDaemon(config, logger);
|
||||||
|
|
||||||
await daemon.start();
|
await daemon.start();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type TestPaseoDaemonOptions = {
|
|||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
corsAllowedOrigins?: string[];
|
corsAllowedOrigins?: string[];
|
||||||
listen?: string;
|
listen?: string;
|
||||||
|
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestPaseoDaemon = {
|
export type TestPaseoDaemon = {
|
||||||
@@ -62,11 +63,14 @@ export async function createTestPaseoDaemon(
|
|||||||
agentControlMcp: {
|
agentControlMcp: {
|
||||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
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,
|
openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined,
|
||||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const logger = pino({ level: "silent" });
|
const logger = options.logger ?? pino({ level: "silent" });
|
||||||
const daemon = await createPaseoDaemon(config, logger);
|
const daemon = await createPaseoDaemon(config, logger);
|
||||||
try {
|
try {
|
||||||
await daemon.start();
|
await daemon.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user