feat(server): built-in service proxy — absorb portless into daemon
Services defined in paseo.json get reverse-proxied through the daemon
via hostname-based routing on *.localhost. Each service receives $PORT,
$HOST, and $PASEO_SERVICE_URL env vars, and is accessible at
{service}.localhost:6767 (main) or {branch}.{service}.localhost:6767
(worktrees).
This commit is contained in:
@@ -48,11 +48,14 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
|||||||
import { expandUserPath } from "../path-utils.js";
|
import { expandUserPath } from "../path-utils.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||||
|
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||||
|
|
||||||
export interface AgentManagementMcpOptions {
|
export interface AgentManagementMcpOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
|
serviceRouteStore?: ServiceRouteStore;
|
||||||
|
getDaemonTcpPort?: () => number | null;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
@@ -339,6 +342,8 @@ export async function createAgentManagementMcpServer(
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
serviceRouteStore: options.serviceRouteStore,
|
||||||
|
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
|||||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||||
|
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||||
|
|
||||||
export interface AgentMcpServerOptions {
|
export interface AgentMcpServerOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
|
serviceRouteStore?: ServiceRouteStore;
|
||||||
|
getDaemonTcpPort?: () => number | null;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
/**
|
/**
|
||||||
* ID of the agent that is connecting to this MCP server.
|
* ID of the agent that is connecting to this MCP server.
|
||||||
@@ -510,6 +513,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
serviceRouteStore: options.serviceRouteStore,
|
||||||
|
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ import { resolveDaemonVersion } from "./daemon-version.js";
|
|||||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||||
|
import {
|
||||||
|
ServiceRouteStore,
|
||||||
|
createServiceProxyMiddleware,
|
||||||
|
createServiceProxyUpgradeHandler,
|
||||||
|
} from "./service-proxy.js";
|
||||||
import {
|
import {
|
||||||
createVoiceMcpSocketBridgeManager,
|
createVoiceMcpSocketBridgeManager,
|
||||||
type VoiceMcpSocketBridgeManager,
|
type VoiceMcpSocketBridgeManager,
|
||||||
@@ -189,6 +194,7 @@ export interface PaseoDaemon {
|
|||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
|
serviceRouteStore: ServiceRouteStore;
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
getListenTarget(): ListenTarget | null;
|
getListenTarget(): ListenTarget | null;
|
||||||
@@ -218,6 +224,8 @@ export async function createPaseoDaemon(
|
|||||||
const app = express();
|
const app = express();
|
||||||
let boundListenTarget: ListenTarget | null = null;
|
let boundListenTarget: ListenTarget | null = null;
|
||||||
|
|
||||||
|
const serviceRouteStore = new ServiceRouteStore();
|
||||||
|
|
||||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||||
// For non-TCP (unix sockets), skip host validation.
|
// For non-TCP (unix sockets), skip host validation.
|
||||||
if (listenTarget.type === "tcp") {
|
if (listenTarget.type === "tcp") {
|
||||||
@@ -231,6 +239,14 @@ export async function createPaseoDaemon(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service proxy — intercepts requests for registered *.localhost hostnames
|
||||||
|
// and forwards them to the corresponding local service port. Placed after
|
||||||
|
// the host allowlist (*.localhost is already allowed) but before CORS and
|
||||||
|
// the rest of the routes so proxied requests skip unnecessary middleware.
|
||||||
|
app.use(
|
||||||
|
createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }),
|
||||||
|
);
|
||||||
|
|
||||||
// CORS - allow same-origin + configured origins
|
// CORS - allow same-origin + configured origins
|
||||||
const allowedOrigins = new Set([
|
const allowedOrigins = new Set([
|
||||||
...config.corsAllowedOrigins,
|
...config.corsAllowedOrigins,
|
||||||
@@ -352,6 +368,16 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
const httpServer = createHTTPServer(app);
|
const httpServer = createHTTPServer(app);
|
||||||
|
|
||||||
|
// Service proxy WebSocket upgrade handler — must be registered before the
|
||||||
|
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||||
|
// service-bound upgrades are forwarded first. The handler is a no-op for
|
||||||
|
// requests that don't match a registered service route.
|
||||||
|
const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({
|
||||||
|
routeStore: serviceRouteStore,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
httpServer.on("upgrade", serviceProxyUpgradeHandler);
|
||||||
|
|
||||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||||
const projectRegistry = new FileBackedProjectRegistry(
|
const projectRegistry = new FileBackedProjectRegistry(
|
||||||
path.join(config.paseoHome, "projects", "projects.json"),
|
path.join(config.paseoHome, "projects", "projects.json"),
|
||||||
@@ -433,6 +459,9 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
serviceRouteStore,
|
||||||
|
getDaemonTcpPort: () =>
|
||||||
|
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
enableVoiceTools: false,
|
enableVoiceTools: false,
|
||||||
resolveSpeakHandler: (callerAgentId) =>
|
resolveSpeakHandler: (callerAgentId) =>
|
||||||
@@ -459,6 +488,9 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
serviceRouteStore,
|
||||||
|
getDaemonTcpPort: () =>
|
||||||
|
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
enableVoiceTools: false,
|
enableVoiceTools: false,
|
||||||
@@ -579,6 +611,9 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
serviceRouteStore,
|
||||||
|
getDaemonTcpPort: () =>
|
||||||
|
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
voiceOnly: true,
|
voiceOnly: true,
|
||||||
@@ -639,6 +674,8 @@ export async function createPaseoDaemon(
|
|||||||
loopService,
|
loopService,
|
||||||
scheduleService,
|
scheduleService,
|
||||||
checkoutDiffManager,
|
checkoutDiffManager,
|
||||||
|
serviceRouteStore,
|
||||||
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||||
@@ -763,6 +800,7 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
serviceRouteStore,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
getListenTarget: () => boundListenTarget,
|
getListenTarget: () => boundListenTarget,
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import express from "express";
|
||||||
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
|
import pino from "pino";
|
||||||
|
import {
|
||||||
|
ServiceRouteStore,
|
||||||
|
createServiceProxyMiddleware,
|
||||||
|
createServiceProxyUpgradeHandler,
|
||||||
|
findFreePort,
|
||||||
|
} from "./service-proxy.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for cleanup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function closeServer(server: http.Server): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ServiceRouteStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ServiceRouteStore", () => {
|
||||||
|
it("addRoute and findRoute with exact match", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("editor.localhost", 3000);
|
||||||
|
|
||||||
|
const route = store.findRoute("editor.localhost");
|
||||||
|
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findRoute strips port from host header", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("editor.localhost", 3000);
|
||||||
|
|
||||||
|
const route = store.findRoute("editor.localhost:6767");
|
||||||
|
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findRoute subdomain match", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("editor.localhost", 3000);
|
||||||
|
|
||||||
|
const route = store.findRoute("fix-auth.editor.localhost");
|
||||||
|
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeRoute works", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("editor.localhost", 3000);
|
||||||
|
store.removeRoute("editor.localhost");
|
||||||
|
|
||||||
|
expect(store.findRoute("editor.localhost")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeRoutesForPort works", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("a.localhost", 3000);
|
||||||
|
store.addRoute("b.localhost", 3000);
|
||||||
|
store.addRoute("c.localhost", 4000);
|
||||||
|
|
||||||
|
store.removeRoutesForPort(3000);
|
||||||
|
|
||||||
|
expect(store.findRoute("a.localhost")).toBeNull();
|
||||||
|
expect(store.findRoute("b.localhost")).toBeNull();
|
||||||
|
expect(store.findRoute("c.localhost")).toEqual({
|
||||||
|
hostname: "c.localhost",
|
||||||
|
port: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findRoute returns null for unknown hosts", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("editor.localhost", 3000);
|
||||||
|
|
||||||
|
expect(store.findRoute("unknown.example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listRoutes returns all routes", () => {
|
||||||
|
const store = new ServiceRouteStore();
|
||||||
|
store.addRoute("a.localhost", 3000);
|
||||||
|
store.addRoute("b.localhost", 4000);
|
||||||
|
|
||||||
|
const routes = store.listRoutes();
|
||||||
|
expect(routes).toHaveLength(2);
|
||||||
|
expect(routes).toContainEqual({ hostname: "a.localhost", port: 3000 });
|
||||||
|
expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP proxy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("HTTP proxy", () => {
|
||||||
|
const servers: http.Server[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.map(closeServer));
|
||||||
|
servers.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Start a real HTTP server that echoes back a known body and records received headers. */
|
||||||
|
async function startUpstream(): Promise<{
|
||||||
|
port: number;
|
||||||
|
server: http.Server;
|
||||||
|
receivedHeaders: () => http.IncomingHttpHeaders;
|
||||||
|
}> {
|
||||||
|
const port = await findFreePort();
|
||||||
|
let lastHeaders: http.IncomingHttpHeaders = {};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
lastHeaders = req.headers;
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
res.end("upstream-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(port, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
servers.push(server);
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
server,
|
||||||
|
receivedHeaders: () => lastHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start an Express app with the service proxy middleware and an optional fallback. */
|
||||||
|
async function startProxy(
|
||||||
|
routeStore: ServiceRouteStore,
|
||||||
|
opts?: { fallback?: boolean },
|
||||||
|
): Promise<{ port: number; server: http.Server }> {
|
||||||
|
const port = await findFreePort();
|
||||||
|
const app = express();
|
||||||
|
app.use(createServiceProxyMiddleware({ routeStore, logger }));
|
||||||
|
|
||||||
|
if (opts?.fallback) {
|
||||||
|
app.use((_req, res) => {
|
||||||
|
res.status(404).send("no route");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(port, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
servers.push(server);
|
||||||
|
|
||||||
|
return { port, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple HTTP GET helper that returns status code and body. */
|
||||||
|
function httpGet(
|
||||||
|
port: number,
|
||||||
|
host: string,
|
||||||
|
path = "/",
|
||||||
|
): Promise<{ status: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.get(
|
||||||
|
{ hostname: "127.0.0.1", port, path, headers: { host } },
|
||||||
|
(res) => {
|
||||||
|
let body = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||||
|
res.on("end", () =>
|
||||||
|
resolve({ status: res.statusCode ?? 0, body }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("proxies requests to the correct upstream based on Host header", async () => {
|
||||||
|
const upstream = await startUpstream();
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
routeStore.addRoute("test-service.localhost", upstream.port);
|
||||||
|
|
||||||
|
const proxy = await startProxy(routeStore);
|
||||||
|
const res = await httpGet(
|
||||||
|
proxy.port,
|
||||||
|
`test-service.localhost:${proxy.port}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toBe("upstream-ok");
|
||||||
|
|
||||||
|
const headers = upstream.receivedHeaders();
|
||||||
|
expect(headers["x-forwarded-for"]).toBeDefined();
|
||||||
|
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through when no route matches", async () => {
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
const proxy = await startProxy(routeStore, { fallback: true });
|
||||||
|
|
||||||
|
const res = await httpGet(
|
||||||
|
proxy.port,
|
||||||
|
`unknown.localhost:${proxy.port}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toBe("no route");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 502 when upstream is down", async () => {
|
||||||
|
// Get a port that nothing is listening on
|
||||||
|
const deadPort = await findFreePort();
|
||||||
|
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
routeStore.addRoute("dead-service.localhost", deadPort);
|
||||||
|
|
||||||
|
const proxy = await startProxy(routeStore);
|
||||||
|
const res = await httpGet(
|
||||||
|
proxy.port,
|
||||||
|
`dead-service.localhost:${proxy.port}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body).toBe("502 Bad Gateway");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket proxy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("WebSocket proxy", () => {
|
||||||
|
const httpServers: http.Server[] = [];
|
||||||
|
const wsServers: WebSocketServer[] = [];
|
||||||
|
const wsClients: WebSocket[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const ws of wsClients) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||||
|
}
|
||||||
|
wsClients.length = 0;
|
||||||
|
|
||||||
|
for (const wss of wsServers) {
|
||||||
|
wss.close();
|
||||||
|
}
|
||||||
|
wsServers.length = 0;
|
||||||
|
|
||||||
|
await Promise.all(httpServers.map(closeServer));
|
||||||
|
httpServers.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proxies WebSocket connections to the correct upstream", async () => {
|
||||||
|
// 1. Start a real WebSocket echo server
|
||||||
|
const upstreamPort = await findFreePort();
|
||||||
|
const upstreamServer = http.createServer();
|
||||||
|
const wss = new WebSocketServer({ server: upstreamServer });
|
||||||
|
wsServers.push(wss);
|
||||||
|
|
||||||
|
wss.on("connection", (ws) => {
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
ws.send(`echo: ${data.toString()}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
upstreamServer.listen(upstreamPort, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
httpServers.push(upstreamServer);
|
||||||
|
|
||||||
|
// 2. Create the proxy server with the upgrade handler
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
routeStore.addRoute("ws-service.localhost", upstreamPort);
|
||||||
|
|
||||||
|
const proxyPort = await findFreePort();
|
||||||
|
const proxyServer = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const upgradeHandler = createServiceProxyUpgradeHandler({
|
||||||
|
routeStore,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
proxyServer.on("upgrade", upgradeHandler);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
proxyServer.listen(proxyPort, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
httpServers.push(proxyServer);
|
||||||
|
|
||||||
|
// 3. Connect a WebSocket client through the proxy
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
|
||||||
|
headers: { host: `ws-service.localhost:${proxyPort}` },
|
||||||
|
});
|
||||||
|
wsClients.push(ws);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.on("open", resolve);
|
||||||
|
ws.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Send a message and verify echo
|
||||||
|
const reply = await new Promise<string>((resolve, reject) => {
|
||||||
|
ws.on("message", (data) => resolve(data.toString()));
|
||||||
|
ws.on("error", reject);
|
||||||
|
ws.send("hello proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reply).toBe("echo: hello proxy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findFreePort
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("findFreePort", () => {
|
||||||
|
it("returns a number", async () => {
|
||||||
|
const port = await findFreePort();
|
||||||
|
expect(typeof port).toBe("number");
|
||||||
|
expect(port).toBeGreaterThan(0);
|
||||||
|
expect(port).toBeLessThan(65536);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a port that is actually available", async () => {
|
||||||
|
const port = await findFreePort();
|
||||||
|
|
||||||
|
// Verify we can bind a server to it
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.listen(port, "127.0.0.1", () => resolve());
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addr = server.address();
|
||||||
|
expect(addr).not.toBeNull();
|
||||||
|
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(
|
||||||
|
port,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hop-by-hop headers that must not be forwarded
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
|
"connection",
|
||||||
|
"transfer-encoding",
|
||||||
|
"keep-alive",
|
||||||
|
"upgrade",
|
||||||
|
"proxy-connection",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ServiceRouteStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ServiceRoute {
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceRouteStore {
|
||||||
|
private routes = new Map<string, number>();
|
||||||
|
|
||||||
|
addRoute(hostname: string, port: number): void {
|
||||||
|
this.routes.set(hostname, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRoute(hostname: string): void {
|
||||||
|
this.routes.delete(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRoutesForPort(port: number): void {
|
||||||
|
for (const [hostname, p] of this.routes) {
|
||||||
|
if (p === port) {
|
||||||
|
this.routes.delete(hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findRoute(host: string): ServiceRoute | null {
|
||||||
|
// Strip port suffix from the Host header value
|
||||||
|
const hostname = host.replace(/:\d+$/, "");
|
||||||
|
|
||||||
|
// 1. Exact match
|
||||||
|
const exactPort = this.routes.get(hostname);
|
||||||
|
if (exactPort !== undefined) {
|
||||||
|
return { hostname, port: exactPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Subdomain match — walk up the labels looking for a registered parent
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const candidate = parts.slice(i).join(".");
|
||||||
|
const candidatePort = this.routes.get(candidate);
|
||||||
|
if (candidatePort !== undefined) {
|
||||||
|
return { hostname: candidate, port: candidatePort };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
listRoutes(): ServiceRoute[] {
|
||||||
|
return Array.from(this.routes.entries()).map(([hostname, port]) => ({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stripHopByHopHeaders(
|
||||||
|
rawHeaders: http.IncomingHttpHeaders,
|
||||||
|
): Record<string, string | string[]> {
|
||||||
|
const out: Record<string, string | string[]> = {};
|
||||||
|
for (const [key, value] of Object.entries(rawHeaders)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createServiceProxyMiddleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createServiceProxyMiddleware({
|
||||||
|
routeStore,
|
||||||
|
logger,
|
||||||
|
}: {
|
||||||
|
routeStore: ServiceRouteStore;
|
||||||
|
logger: Logger;
|
||||||
|
}): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const hostHeader = req.headers.host;
|
||||||
|
if (!hostHeader) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = routeStore.findRoute(hostHeader);
|
||||||
|
if (!route) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||||
|
forwardedHeaders["x-forwarded-for"] =
|
||||||
|
req.socket.remoteAddress ?? "127.0.0.1";
|
||||||
|
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||||
|
forwardedHeaders["x-forwarded-proto"] = req.protocol;
|
||||||
|
|
||||||
|
const proxyReq = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: route.port,
|
||||||
|
path: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
headers: forwardedHeaders,
|
||||||
|
},
|
||||||
|
(proxyRes) => {
|
||||||
|
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
|
||||||
|
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
|
||||||
|
proxyRes.pipe(res, { end: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
proxyReq.on("error", (err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err, hostname: route.hostname, port: route.port },
|
||||||
|
"Service proxy: upstream unreachable",
|
||||||
|
);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { "content-type": "text/plain" });
|
||||||
|
res.end("502 Bad Gateway");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.pipe(proxyReq, { end: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createServiceProxyUpgradeHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createServiceProxyUpgradeHandler({
|
||||||
|
routeStore,
|
||||||
|
logger,
|
||||||
|
}: {
|
||||||
|
routeStore: ServiceRouteStore;
|
||||||
|
logger: Logger;
|
||||||
|
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
|
||||||
|
return (req, socket, head) => {
|
||||||
|
const hostHeader = req.headers.host;
|
||||||
|
if (!hostHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = routeStore.findRoute(hostHeader);
|
||||||
|
if (!route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSocket = net.connect(
|
||||||
|
{ host: "127.0.0.1", port: route.port },
|
||||||
|
() => {
|
||||||
|
// Reconstruct the raw HTTP upgrade request to send to the target
|
||||||
|
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||||
|
forwardedHeaders["x-forwarded-for"] =
|
||||||
|
req.socket.remoteAddress ?? "127.0.0.1";
|
||||||
|
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||||
|
forwardedHeaders["x-forwarded-proto"] = "http";
|
||||||
|
|
||||||
|
// Re-include upgrade and connection headers — they are required for
|
||||||
|
// WebSocket handshake even though they are hop-by-hop.
|
||||||
|
forwardedHeaders["connection"] = "Upgrade";
|
||||||
|
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
|
||||||
|
|
||||||
|
const headerLines: string[] = [];
|
||||||
|
headerLines.push(
|
||||||
|
`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`,
|
||||||
|
);
|
||||||
|
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
headerLines.push(`${key}: ${v}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headerLines.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headerLines.push("\r\n");
|
||||||
|
|
||||||
|
targetSocket.write(headerLines.join("\r\n"));
|
||||||
|
|
||||||
|
if (head.length > 0) {
|
||||||
|
targetSocket.write(head);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipe in both directions
|
||||||
|
targetSocket.pipe(socket);
|
||||||
|
socket.pipe(targetSocket);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
targetSocket.on("error", (err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err, hostname: route.hostname, port: route.port },
|
||||||
|
"Service proxy: WebSocket upstream unreachable",
|
||||||
|
);
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", () => {
|
||||||
|
targetSocket.destroy();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findFreePort
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function findFreePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close();
|
||||||
|
reject(new Error("Failed to get assigned port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(port);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ import {
|
|||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||||
|
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
@@ -372,6 +373,8 @@ export type SessionOptions = {
|
|||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
tts: Resolvable<TextToSpeechProvider | null>;
|
tts: Resolvable<TextToSpeechProvider | null>;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
|
serviceRouteStore?: ServiceRouteStore;
|
||||||
|
getDaemonTcpPort?: () => number | null;
|
||||||
voice?: {
|
voice?: {
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||||
@@ -567,6 +570,8 @@ export class Session {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
|
private readonly serviceRouteStore: ServiceRouteStore | null;
|
||||||
|
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||||
@@ -618,6 +623,8 @@ export class Session {
|
|||||||
stt,
|
stt,
|
||||||
tts,
|
tts,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
serviceRouteStore,
|
||||||
|
getDaemonTcpPort,
|
||||||
voice,
|
voice,
|
||||||
voiceBridge,
|
voiceBridge,
|
||||||
dictation,
|
dictation,
|
||||||
@@ -642,6 +649,8 @@ export class Session {
|
|||||||
this.checkoutDiffManager = checkoutDiffManager;
|
this.checkoutDiffManager = checkoutDiffManager;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
|
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||||
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||||
if (this.terminalManager) {
|
if (this.terminalManager) {
|
||||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||||
this.handleTerminalsChanged(event),
|
this.handleTerminalsChanged(event),
|
||||||
@@ -2732,6 +2741,8 @@ export class Session {
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||||
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
|||||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import { PushService } from "./push/push-service.js";
|
import { PushService } from "./push/push-service.js";
|
||||||
|
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -241,6 +242,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||||
private readonly speech: SpeechService | null;
|
private readonly speech: SpeechService | null;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
|
private readonly serviceRouteStore: ServiceRouteStore | null;
|
||||||
|
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||||
private readonly dictation: {
|
private readonly dictation: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -307,6 +310,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
loopService?: LoopService,
|
loopService?: LoopService,
|
||||||
scheduleService?: ScheduleService,
|
scheduleService?: ScheduleService,
|
||||||
checkoutDiffManager?: CheckoutDiffManager,
|
checkoutDiffManager?: CheckoutDiffManager,
|
||||||
|
serviceRouteStore?: ServiceRouteStore | null,
|
||||||
|
getDaemonTcpPort?: () => number | null,
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.serverId = serverId;
|
this.serverId = serverId;
|
||||||
@@ -343,6 +348,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||||
|
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||||
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||||
this.serverCapabilities = buildServerCapabilities({
|
this.serverCapabilities = buildServerCapabilities({
|
||||||
readiness: this.speech?.getReadiness() ?? null,
|
readiness: this.speech?.getReadiness() ?? null,
|
||||||
});
|
});
|
||||||
@@ -643,6 +650,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
stt: () => this.speech?.resolveStt() ?? null,
|
stt: () => this.speech?.resolveStt() ?? null,
|
||||||
tts: () => this.speech?.resolveTts() ?? null,
|
tts: () => this.speech?.resolveTts() ?? null,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
|
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||||
|
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||||
voice: {
|
voice: {
|
||||||
...(this.voice ?? {}),
|
...(this.voice ?? {}),
|
||||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
|||||||
import type { TerminalSession } from "../terminal/terminal.js";
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
createWorktree,
|
||||||
|
getServiceConfigs,
|
||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
resolveWorktreeRuntimeEnv,
|
resolveWorktreeRuntimeEnv,
|
||||||
runWorktreeSetupCommands,
|
runWorktreeSetupCommands,
|
||||||
|
slugify,
|
||||||
WorktreeSetupError,
|
WorktreeSetupError,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
type WorktreeSetupCommandResult,
|
type WorktreeSetupCommandResult,
|
||||||
type WorktreeRuntimeEnv,
|
type WorktreeRuntimeEnv,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
|
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
|
||||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||||
|
|
||||||
export interface WorktreeBootstrapTerminalResult {
|
export interface WorktreeBootstrapTerminalResult {
|
||||||
@@ -30,6 +33,8 @@ export interface RunAsyncWorktreeBootstrapOptions {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
|
serviceRouteStore?: ServiceRouteStore;
|
||||||
|
daemonPort?: number | null;
|
||||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||||
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
@@ -653,4 +658,111 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!options.terminalManager ||
|
||||||
|
!options.serviceRouteStore ||
|
||||||
|
options.daemonPort === null ||
|
||||||
|
options.daemonPort === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnWorktreeServices({
|
||||||
|
repoRoot: options.worktree.worktreePath,
|
||||||
|
branchName: options.worktree.branchName,
|
||||||
|
daemonPort: options.daemonPort,
|
||||||
|
routeStore: options.serviceRouteStore,
|
||||||
|
terminalManager: options.terminalManager,
|
||||||
|
logger: options.logger,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
options.logger?.warn(
|
||||||
|
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
|
||||||
|
"Failed to spawn worktree services",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service lifecycle helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface WorktreeServiceResult {
|
||||||
|
serviceName: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
terminalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spawnWorktreeServices(options: {
|
||||||
|
repoRoot: string;
|
||||||
|
branchName: string | null;
|
||||||
|
daemonPort: number;
|
||||||
|
routeStore: ServiceRouteStore;
|
||||||
|
terminalManager: TerminalManager;
|
||||||
|
logger?: Logger;
|
||||||
|
}): Promise<WorktreeServiceResult[]> {
|
||||||
|
const { repoRoot, branchName, daemonPort, routeStore, terminalManager, logger } = options;
|
||||||
|
const serviceConfigs = getServiceConfigs(repoRoot);
|
||||||
|
if (serviceConfigs.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: WorktreeServiceResult[] = [];
|
||||||
|
|
||||||
|
for (const [serviceName, config] of serviceConfigs) {
|
||||||
|
const port = config.port ?? (await findFreePort());
|
||||||
|
const branchHostnameLabel = branchName ? slugify(branchName) : null;
|
||||||
|
|
||||||
|
const isDefaultBranch =
|
||||||
|
branchName === null || branchName === "main" || branchName === "master";
|
||||||
|
const hostname = isDefaultBranch
|
||||||
|
? `${serviceName}.localhost`
|
||||||
|
: `${branchHostnameLabel}.${serviceName}.localhost`;
|
||||||
|
|
||||||
|
routeStore.addRoute(hostname, port);
|
||||||
|
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
PORT: String(port),
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PASEO_SERVICE_URL: `http://${hostname}:${daemonPort}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminal = await terminalManager.createTerminal({
|
||||||
|
cwd: repoRoot,
|
||||||
|
name: serviceName,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForTerminalBootstrapReadiness(terminal);
|
||||||
|
terminal.send({ type: "input", data: `${config.command}\r` });
|
||||||
|
|
||||||
|
logger?.info(
|
||||||
|
{ serviceName, hostname, port, terminalId: terminal.id },
|
||||||
|
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
serviceName,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
terminalId: terminal.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownWorktreeServices(options: {
|
||||||
|
hostnames: string[];
|
||||||
|
routeStore: ServiceRouteStore;
|
||||||
|
logger: Logger;
|
||||||
|
}): void {
|
||||||
|
const { hostnames, routeStore, logger } = options;
|
||||||
|
for (const hostname of hostnames) {
|
||||||
|
routeStore.removeRoute(hostname);
|
||||||
|
logger.info({ hostname }, "Removed service proxy route");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface PaseoConfig {
|
|||||||
teardown?: string[];
|
teardown?: string[];
|
||||||
terminals?: WorktreeTerminalConfig[];
|
terminals?: WorktreeTerminalConfig[];
|
||||||
};
|
};
|
||||||
|
services?: Record<string, ServiceConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -84,6 +85,11 @@ export interface WorktreeTerminalConfig {
|
|||||||
command: string;
|
command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceConfig {
|
||||||
|
command: string;
|
||||||
|
port?: number; // explicit port override, otherwise auto-assigned
|
||||||
|
}
|
||||||
|
|
||||||
export class WorktreeSetupError extends Error {
|
export class WorktreeSetupError extends Error {
|
||||||
readonly results: WorktreeSetupCommandResult[];
|
readonly results: WorktreeSetupCommandResult[];
|
||||||
|
|
||||||
@@ -194,6 +200,40 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
|
|||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig> {
|
||||||
|
const config = readPaseoConfig(repoRoot);
|
||||||
|
const services = config?.services;
|
||||||
|
if (!services || typeof services !== "object") {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, ServiceConfig>();
|
||||||
|
for (const [name, entry] of Object.entries(services)) {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCommand = entry.command;
|
||||||
|
if (typeof rawCommand !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const command = rawCommand.trim();
|
||||||
|
if (!command) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceConfig: ServiceConfig = { command };
|
||||||
|
|
||||||
|
if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
|
||||||
|
serviceConfig.port = entry.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.set(name, serviceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function execSetupCommand(
|
async function execSetupCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
|||||||
Reference in New Issue
Block a user