Add configurable service proxy (#1280)

* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
This commit is contained in:
Matt Cowger
2026-06-01 23:08:41 -07:00
committed by GitHub
parent eac5d93f78
commit f1a20a9fe9
19 changed files with 614 additions and 36 deletions

View File

@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |

90
docs/service-proxy.md Normal file
View File

@@ -0,0 +1,90 @@
# Service Proxy
Paseo can proxy HTTP traffic to services running inside your workspaces and expose them at stable, publicly-accessible URLs. This lets you open a running dev server from your phone or share it with a teammate without any tunneling tools.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.<base-domain>
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.<base-domain>
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.paseoapps.my.domain.com
```
The public route uses one combined leftmost label (`script--branch--project`) rather than the local development shape (`script.branch.project.localhost`). This is intentional: dotted script/branch/project hostnames would require multi-level wildcard DNS and certificates for arbitrary branch names, while a single label works with normal `*.base-domain` DNS and wildcard TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```json
{
"version": 1,
"daemon": {
"serviceProxy": {
"enabled": true,
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
```
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------- |
| `enabled` | No | Enables the proxy. Defaults to `true` if `publicBaseUrl` is set, `false` otherwise. |
| `listen` | No | Address and port the proxy listens on. Defaults to `127.0.0.1:6868`. |
| `publicBaseUrl` | No | Base URL used to generate public service links. If omitted, links use localhost addresses only. |
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of the daemon, ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description |
| ------------------------------------- | ----------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | `true` or `false` |
| `PASEO_SERVICE_PROXY_LISTEN` | Listen address, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Public base URL |

View File

@@ -14,6 +14,19 @@ function isLoopbackHost(host: string): boolean {
);
}
function isLocalOnlyUrl(url: string | null | undefined): boolean {
if (!url) {
return true;
}
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
} catch {
return true;
}
}
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
try {
const { host, isIpv6 } = parseHostPort(endpoint);
@@ -37,6 +50,12 @@ export function resolveWorkspaceScriptLink(input: {
return { openUrl: null, labelUrl: script.proxyUrl };
}
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl);
if (!proxyUrlIsLocalOnly) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
}
if (activeConnection.type === "relay") {
return { openUrl: null, labelUrl: script.proxyUrl };
}

View File

@@ -249,6 +249,11 @@ export interface PaseoDaemonConfig {
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
serviceProxy?: {
enabled: boolean;
listen: string;
publicBaseUrl: string | null;
};
appBaseUrl?: string;
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
@@ -334,7 +339,13 @@ export async function createPaseoDaemon(
const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
const serviceProxyPublicBaseUrl =
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
? config.serviceProxy.publicBaseUrl
: null;
let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
onChange: createScriptStatusEmitter({
@@ -348,6 +359,7 @@ export async function createPaseoDaemon(
resolveWorkspaceDirectory: async (workspaceId) =>
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
logger,
serviceProxyPublicBaseUrl,
}),
});
const handleBranchChange = createBranchChangeRouteHandler({
@@ -501,6 +513,30 @@ export async function createPaseoDaemon(
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
if (config.serviceProxy?.enabled) {
const serviceProxyTarget = parseListenString(config.serviceProxy.listen);
const serviceProxyApp = express();
serviceProxyApp.set("trust proxy", true);
serviceProxyApp.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
serviceProxyApp.use((_req, res) => {
res.status(404).send("404 Not Found");
});
serviceProxyHttpServer = createHTTPServer(serviceProxyApp);
const serviceProxyUpgradeHandler = createScriptProxyUpgradeHandler({
routeStore: scriptRouteStore,
logger,
});
serviceProxyHttpServer.on("upgrade", (req, socket, head) => {
const hostHeader = req.headers.host;
if (!hostHeader || !scriptRouteStore.findRoute(hostHeader)) {
socket.destroy();
return;
}
serviceProxyUpgradeHandler(req, socket as import("node:net").Socket, head);
});
serviceProxyListenTarget = serviceProxyTarget;
}
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
@@ -737,6 +773,7 @@ export async function createPaseoDaemon(
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
},
input,
@@ -979,6 +1016,7 @@ export async function createPaseoDaemon(
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
);
if (relayEnabled) {
@@ -1025,6 +1063,41 @@ export async function createPaseoDaemon(
}
});
if (serviceProxyHttpServer && serviceProxyListenTarget) {
const proxyServer = serviceProxyHttpServer;
const proxyTarget = serviceProxyListenTarget;
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
proxyServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
proxyServer.off("error", onError);
serviceProxyListenTarget = resolveBoundListenTarget(proxyTarget, proxyServer);
logger.info(
{
listen: formatListenTarget(serviceProxyListenTarget),
publicBaseUrl: serviceProxyPublicBaseUrl,
elapsed: elapsed(),
},
"Service proxy listening",
);
resolve();
};
proxyServer.once("error", onError);
proxyServer.once("listening", onListening);
if (proxyTarget.type === "tcp") {
proxyServer.listen(proxyTarget.port, proxyTarget.host);
} else {
if (proxyTarget.type === "socket" && existsSync(proxyTarget.path)) {
unlinkSync(proxyTarget.path);
}
proxyServer.listen(proxyTarget.path);
}
});
}
// Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections.
speechService.start();
@@ -1045,6 +1118,18 @@ export async function createPaseoDaemon(
if (wsServer) {
await wsServer.close();
}
if (serviceProxyHttpServer) {
serviceProxyHttpServer.closeAllConnections();
await new Promise<void>((resolve) => {
serviceProxyHttpServer?.close(() => resolve());
});
if (
serviceProxyListenTarget?.type === "socket" &&
existsSync(serviceProxyListenTarget.path)
) {
unlinkSync(serviceProxyListenTarget.path);
}
}
// Force-drop remaining sockets so httpServer.close() resolves promptly.
// We've already closed wsServer (which sent ws-layer close frames) and
// stopped every other service, so anything still attached is a TCP

View File

@@ -84,6 +84,43 @@ describe("daemon relay config", () => {
});
});
describe("daemon service proxy config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("loads public base URL from env before persisted config", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
serviceProxy: {
publicBaseUrl: "https://persisted.example.com",
},
},
});
const config = loadConfig(home, {
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "https://env.example.com/" },
});
expect(config.serviceProxy).toEqual({
enabled: true,
listen: "127.0.0.1:6868",
publicBaseUrl: "https://env.example.com",
});
});
test("rejects invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL values", async () => {
const home = await createPaseoHome({ version: 1 });
expect(() =>
loadConfig(home, {
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "not-a-url" },
}),
).toThrow("Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: not-a-url");
});
});
describe("daemon worktree root config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));

View File

@@ -24,6 +24,7 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
const DEFAULT_SERVICE_PROXY_LISTEN = "127.0.0.1:6868";
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) {
@@ -152,6 +153,12 @@ interface ResolvedRelay {
publicUseTls: boolean;
}
interface ResolvedServiceProxy {
enabled: boolean;
listen: string;
publicBaseUrl: string | null;
}
function resolveTlsFromEnv(
envValue: string | undefined,
persistedValue: boolean | undefined,
@@ -198,6 +205,38 @@ interface ResolvedVoiceLlm {
model: string | null;
}
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
if (value === null) {
return null;
}
try {
return new URL(value).toString().replace(/\/$/, "");
} catch {
throw new Error(`Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: ${value}`);
}
}
function resolveServiceProxyConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedServiceProxy {
const publicBaseUrl = resolveServiceProxyPublicBaseUrl(
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
persisted.daemon?.serviceProxy?.publicBaseUrl ??
null,
);
const enabled =
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ??
persisted.daemon?.serviceProxy?.enabled ??
publicBaseUrl !== null;
const listen =
env.PASEO_SERVICE_PROXY_LISTEN ??
persisted.daemon?.serviceProxy?.listen ??
DEFAULT_SERVICE_PROXY_LISTEN;
return { enabled, listen, publicBaseUrl };
}
function resolveVoiceLlmConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
@@ -322,6 +361,7 @@ export function loadConfig(
cliRelayEnabled: options?.cli?.relayEnabled,
cliRelayUseTls: options?.cli?.relayUseTls,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
@@ -354,6 +394,7 @@ export function loadConfig(
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
serviceProxy,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,

View File

@@ -237,6 +237,14 @@ export const PersistedConfigSchema = z
})
.strict()
.optional(),
serviceProxy: z
.object({
enabled: z.boolean().optional(),
listen: z.string().optional(),
publicBaseUrl: z.string().url().optional(),
})
.strict()
.optional(),
auth: DaemonAuthSchema.optional(),
})
.strict()

View File

@@ -33,30 +33,49 @@ export interface ScriptRouteEntry extends ScriptRoute {
workspaceId: string;
projectSlug: string;
scriptName: string;
publicHostname?: string | null;
publicBaseUrl?: string | null;
}
export class ScriptRouteStore {
private routes = new Map<string, ScriptRouteEntry>();
private hostnameAliases = new Map<string, string>();
private workspaceHostnames = new Map<string, Set<string>>();
registerRoute(entry: ScriptRouteEntry): void {
const previous = this.routes.get(entry.hostname);
if (previous) {
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
this.removeRoute(previous.hostname);
}
const storedEntry = { ...entry };
const { publicHostname, publicBaseUrl, ...requiredEntry } = entry;
const storedEntry: ScriptRouteEntry = {
...requiredEntry,
...(publicHostname ? { publicHostname } : {}),
...(publicBaseUrl ? { publicBaseUrl } : {}),
};
this.routes.set(storedEntry.hostname, storedEntry);
for (const alias of this.getRouteHostnames(storedEntry)) {
const aliasedCanonicalHostname = this.hostnameAliases.get(alias);
if (aliasedCanonicalHostname && aliasedCanonicalHostname !== storedEntry.hostname) {
this.removeRoute(aliasedCanonicalHostname);
}
this.hostnameAliases.set(alias, storedEntry.hostname);
}
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
}
removeRoute(hostname: string): void {
const entry = this.routes.get(hostname);
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
const entry = this.routes.get(canonicalHostname);
if (!entry) {
return;
}
this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
this.routes.delete(canonicalHostname);
for (const alias of this.getRouteHostnames(entry)) {
this.hostnameAliases.delete(alias);
}
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, canonicalHostname);
}
removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void {
@@ -72,6 +91,9 @@ export class ScriptRouteStore {
for (const [hostname, entry] of this.routes) {
if (entry.port === port) {
this.routes.delete(hostname);
for (const alias of this.getRouteHostnames(entry)) {
this.hostnameAliases.delete(alias);
}
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
}
}
@@ -82,7 +104,7 @@ export class ScriptRouteStore {
const hostname = host.replace(/:\d+$/, "");
// 1. Exact match
const exactRoute = this.routes.get(hostname);
const exactRoute = this.getRouteByHostname(hostname);
if (exactRoute !== undefined) {
return { hostname: exactRoute.hostname, port: exactRoute.port };
}
@@ -91,7 +113,7 @@ export class ScriptRouteStore {
const parts = hostname.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
const candidateRoute = this.routes.get(candidate);
const candidateRoute = this.getRouteByHostname(candidate);
if (candidateRoute !== undefined) {
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
}
@@ -101,7 +123,7 @@ export class ScriptRouteStore {
}
getRouteEntry(hostname: string): ScriptRouteEntry | null {
const entry = this.routes.get(hostname);
const entry = this.getRouteByHostname(hostname);
return entry ? { ...entry } : null;
}
@@ -125,6 +147,17 @@ export class ScriptRouteStore {
return routes;
}
private getRouteByHostname(hostname: string): ScriptRouteEntry | undefined {
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
return this.routes.get(canonicalHostname);
}
private getRouteHostnames(
entry: Pick<ScriptRouteEntry, "hostname" | "publicHostname">,
): string[] {
return [entry.hostname, ...(entry.publicHostname ? [entry.publicHostname] : [])];
}
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
hostnames.add(hostname);

View File

@@ -49,12 +49,16 @@ function registerRoute(
workspaceId = "workspace-a",
projectSlug = "paseo",
scriptName,
publicHostname,
publicBaseUrl,
}: {
hostname: string;
port: number;
workspaceId?: string;
projectSlug?: string;
scriptName: string;
publicHostname?: string | null;
publicBaseUrl?: string | null;
},
): void {
routeStore.registerRoute({
@@ -63,6 +67,8 @@ function registerRoute(
workspaceId,
projectSlug,
scriptName,
...(publicHostname ? { publicHostname } : {}),
...(publicBaseUrl ? { publicBaseUrl } : {}),
});
}
@@ -151,6 +157,42 @@ describe("script-route-branch-handler", () => {
expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
});
it("updates public route aliases from the stored public base URL", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
publicHostname: "api--feature-auth--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
hostname: "api.feature-billing.paseo.localhost",
port: 3001,
});
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.feature-billing.paseo.localhost",
publicHostname: "api--feature-billing--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
scriptName: "api",
},
]);
});
it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {

View File

@@ -1,5 +1,5 @@
import type { Logger } from "pino";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions {
@@ -11,6 +11,7 @@ interface BranchChangeRouteHandlerOptions {
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
newPublicHostname: string | null;
route: ScriptRouteEntry;
}
@@ -31,10 +32,19 @@ export function createBranchChangeRouteHandler(
branchName: newBranch,
scriptName: route.scriptName,
});
if (newHostname !== route.hostname) {
const newPublicHostname = route.publicBaseUrl
? buildPublicScriptHostname({
projectSlug: route.projectSlug,
branchName: newBranch,
scriptName: route.scriptName,
publicBaseUrl: route.publicBaseUrl,
})
: null;
if (newHostname !== route.hostname || newPublicHostname !== (route.publicHostname ?? null)) {
updates.push({
oldHostname: route.hostname,
newHostname,
newPublicHostname,
route,
});
}
@@ -44,10 +54,12 @@ export function createBranchChangeRouteHandler(
return;
}
for (const { oldHostname, newHostname, route } of updates) {
for (const { oldHostname, newHostname, newPublicHostname, route } of updates) {
options.routeStore.removeRoute(oldHostname);
options.routeStore.registerRoute({
hostname: newHostname,
publicHostname: newPublicHostname,
publicBaseUrl: route.publicBaseUrl ?? null,
port: route.port,
workspaceId: route.workspaceId,
projectSlug: route.projectSlug,

View File

@@ -5,7 +5,7 @@ import type {
WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
@@ -23,6 +23,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: {
projectSlug: string;
currentBranch: string | null;
@@ -52,11 +53,26 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort;
}
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
function toServiceProxyUrl(params: {
hostname: string;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
projectSlug: string;
branchName: string | null;
scriptName: string;
}): string | null {
if (params.serviceProxyPublicBaseUrl) {
return buildPublicScriptProxyUrl({
projectSlug: params.projectSlug,
branchName: params.branchName,
scriptName: params.scriptName,
publicBaseUrl: params.serviceProxyPublicBaseUrl,
});
}
if (params.daemonPort === null) {
return null;
}
return `http://${hostname}:${daemonPort}`;
return `http://${params.hostname}:${params.daemonPort}`;
}
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
@@ -82,6 +98,7 @@ interface BuildPayloadContext {
projectSlug: string;
branchName: string | null;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
@@ -110,7 +127,17 @@ function buildConfiguredScriptPayload(
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
proxyUrl:
type === "service"
? toServiceProxyUrl({
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
})
: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
exitCode: runtimeEntry?.exitCode ?? null,
@@ -138,7 +165,17 @@ function buildOrphanRuntimePayload(
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null,
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
proxyUrl:
type === "service"
? toServiceProxyUrl({
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
})
: null,
lifecycle: runtimeEntry.lifecycle,
health:
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
@@ -170,6 +207,7 @@ export function buildWorkspaceScriptPayloads(
projectSlug,
branchName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
resolveHealth: options.resolveHealth,
};
@@ -210,6 +248,7 @@ export function createScriptStatusEmitter({
routeStore,
runtimeStore,
daemonPort,
serviceProxyPublicBaseUrl,
resolveWorkspaceDirectory,
logger,
}: {
@@ -217,6 +256,7 @@ export function createScriptStatusEmitter({
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null);
serviceProxyPublicBaseUrl?: string | null;
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
logger: Logger;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
@@ -239,6 +279,7 @@ export function createScriptStatusEmitter({
routeStore,
runtimeStore,
daemonPort: resolvedDaemonPort,
serviceProxyPublicBaseUrl,
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
});

View File

@@ -598,6 +598,7 @@ export interface SessionOptions {
) => void;
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
serviceProxyPublicBaseUrl?: string | null;
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: {
turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -811,6 +812,7 @@ export class Session {
) => void;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly getDaemonTcpHost: (() => string | null) | null;
private readonly serviceProxyPublicBaseUrl: string | null;
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
private readonly terminalController: TerminalSessionController;
private inflightRequests = 0;
@@ -885,6 +887,7 @@ export class Session {
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
voice,
voiceBridge,
@@ -969,6 +972,7 @@ export class Session {
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
this.sttLanguage = sttLanguage ?? "en";
this.subscribeToOptionalManagers();
@@ -6299,6 +6303,7 @@ export class Session {
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
resolveHealth: this.resolveScriptHealth ?? undefined,
})
@@ -7129,6 +7134,7 @@ export class Session {
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
resolveHealth: this.resolveScriptHealth ?? undefined,
});
@@ -7194,6 +7200,7 @@ export class Session {
scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
@@ -7345,6 +7352,7 @@ export class Session {
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
},

View File

@@ -361,6 +361,7 @@ export class VoiceAssistantWebSocketServer {
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
private getDaemonTcpPort!: (() => number | null) | null;
private getDaemonTcpHost!: (() => string | null) | null;
private serviceProxyPublicBaseUrl!: string | null;
private resolveScriptHealth!: ((hostname: string) => ScriptHealthState | null) | null;
private dictation!: {
finalTimeoutMs?: number;
@@ -429,6 +430,7 @@ export class VoiceAssistantWebSocketServer {
publicUseTls: boolean;
};
},
serviceProxyPublicBaseUrl?: string | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -468,6 +470,7 @@ export class VoiceAssistantWebSocketServer {
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
});
if (!providerSnapshotManager) {
@@ -518,6 +521,7 @@ export class VoiceAssistantWebSocketServer {
| undefined;
getDaemonTcpPort: (() => number | null) | undefined;
getDaemonTcpHost: (() => string | null) | undefined;
serviceProxyPublicBaseUrl: string | null | undefined;
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | undefined;
}): void {
this.speech = params.speech ?? null;
@@ -529,6 +533,7 @@ export class VoiceAssistantWebSocketServer {
this.onBranchChanged = params.onBranchChanged ?? null;
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
this.getDaemonTcpHost = params.getDaemonTcpHost ?? null;
this.serviceProxyPublicBaseUrl = params.serviceProxyPublicBaseUrl ?? null;
this.resolveScriptHealth = params.resolveScriptHealth ?? null;
}
@@ -885,6 +890,7 @@ export class VoiceAssistantWebSocketServer {
onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
voice: {
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,

View File

@@ -1,4 +1,4 @@
import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
export interface WorkspaceServicePeer {
scriptName: string;
@@ -11,6 +11,7 @@ export interface BuildWorkspaceServiceEnvOptions {
branchName: string | null;
daemonPort: number | null | undefined;
daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl?: string | null;
peers: readonly WorkspaceServicePeer[];
}
@@ -37,26 +38,30 @@ export function buildWorkspaceServiceEnv(
PASEO_PORT: String(selfPeer.port),
};
if (options.daemonPort !== null && options.daemonPort !== undefined) {
env.PASEO_URL = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
daemonPort: options.daemonPort,
});
const selfProxyUrl = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
});
if (selfProxyUrl) {
env.PASEO_URL = selfProxyUrl;
}
for (const peer of options.peers) {
const envName = normalizeServiceEnvName(peer.scriptName);
env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port);
if (options.daemonPort !== null && options.daemonPort !== undefined) {
env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: peer.scriptName,
daemonPort: options.daemonPort,
});
const peerProxyUrl = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: peer.scriptName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
});
if (peerProxyUrl) {
env[`PASEO_SERVICE_${envName}_URL`] = peerProxyUrl;
}
}
@@ -71,10 +76,24 @@ interface BuildServiceProxyUrlOptions {
projectSlug: string;
branchName: string | null;
scriptName: string;
daemonPort: number;
daemonPort: number | null | undefined;
serviceProxyPublicBaseUrl?: string | null;
}
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string {
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
if (options.serviceProxyPublicBaseUrl) {
return buildPublicScriptProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
publicBaseUrl: options.serviceProxyPublicBaseUrl,
});
}
if (options.daemonPort === null || options.daemonPort === undefined) {
return null;
}
const hostname = buildScriptHostname({
projectSlug: options.projectSlug,
branchName: options.branchName,

View File

@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import {
getScriptConfigs,
getWorktreeTerminalSpecs,
@@ -699,6 +699,7 @@ interface SpawnWorkspaceScriptOptions {
scriptName: string;
daemonPort?: number | null;
daemonListenHost?: string | null;
serviceProxyPublicBaseUrl?: string | null;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
@@ -721,6 +722,7 @@ async function setupServiceScriptRoute(params: {
workspaceId: string;
daemonPort: number | null | undefined;
daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore;
}): Promise<ServiceScriptSetupResult> {
@@ -733,10 +735,19 @@ async function setupServiceScriptRoute(params: {
workspaceId,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
} = params;
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
const publicHostname = serviceProxyPublicBaseUrl
? buildPublicScriptHostname({
projectSlug,
branchName,
scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl,
})
: null;
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
@@ -779,11 +790,14 @@ async function setupServiceScriptRoute(params: {
branchName,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
peers,
});
routeStore.registerRoute({
hostname,
publicHostname,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
port,
workspaceId,
projectSlug,
@@ -828,6 +842,7 @@ export async function spawnWorkspaceScript(
scriptName,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
routeStore,
runtimeStore,
terminalManager,
@@ -869,6 +884,7 @@ export async function spawnWorkspaceScript(
workspaceId,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
});

View File

@@ -107,6 +107,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl?: string | null;
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
}

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
import {
buildPublicScriptHostname,
buildPublicScriptProxyUrl,
buildScriptHostname,
} from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("builds default branch hostnames with script and project labels", () => {
@@ -69,3 +73,54 @@ describe("buildScriptHostname", () => {
).toBe("untitled.untitled.untitled.localhost");
});
});
describe("buildPublicScriptHostname", () => {
it("uses one combined service label under the configured public base host", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web--feature-auth--paseo.services.example.com");
});
it("omits default branch names from the public service label", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "main",
scriptName: "web",
}),
).toBe("web--paseo.services.example.com");
});
it("caps the public service label to the DNS label length limit", () => {
const hostname = buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
});
const [serviceLabel] = hostname.split(".");
expect(serviceLabel.length).toBeLessThanOrEqual(63);
expect(serviceLabel).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
expect(hostname).toBe(`${serviceLabel}.services.example.com`);
});
});
describe("buildPublicScriptProxyUrl", () => {
it("preserves the configured public base protocol and port", () => {
expect(
buildPublicScriptProxyUrl({
publicBaseUrl: "https://services.example.com:8443/base-is-ignored",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("https://web--feature-auth--paseo.services.example.com:8443");
});
});

View File

@@ -1,15 +1,48 @@
import { slugify } from "./worktree.js";
const MAX_DNS_LABEL_LENGTH = 63;
interface BuildScriptHostnameOptions {
projectSlug: string;
branchName: string | null;
scriptName: string;
}
interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
publicBaseUrl: string;
}
function toHostnameLabel(value: string): string {
return slugify(value) || "untitled";
}
function capDnsLabel(label: string): string {
if (label.length <= MAX_DNS_LABEL_LENGTH) {
return label;
}
return label.slice(0, MAX_DNS_LABEL_LENGTH).replace(/-+$/g, "") || "untitled";
}
function toPublicServiceLabel({
projectSlug,
branchName,
scriptName,
}: BuildScriptHostnameOptions): string {
const labels = [toHostnameLabel(scriptName)];
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (!isDefaultBranch) {
labels.push(toHostnameLabel(branchName));
}
labels.push(toHostnameLabel(projectSlug));
// Public URLs must keep script/branch/project in one label. The local
// script.branch.project.localhost shape would require multi-level wildcard
// DNS/certificates for arbitrary branch names, while a single label works with
// normal `*.base-domain` DNS and wildcard TLS. Cap it to the DNS 63-octet
// label limit so long branch/project/script names still produce resolvable URLs.
return capDnsLabel(labels.join("--"));
}
export function buildScriptHostname({
projectSlug,
branchName,
@@ -25,3 +58,18 @@ export function buildScriptHostname({
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
}
export function buildPublicScriptHostname({
publicBaseUrl,
...script
}: BuildPublicScriptHostnameOptions): string {
const base = new URL(publicBaseUrl);
return `${toPublicServiceLabel(script)}.${base.hostname}`;
}
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
const base = new URL(options.publicBaseUrl);
const hostname = buildPublicScriptHostname(options);
const port = base.port ? `:${base.port}` : "";
return `${base.protocol}//${hostname}${port}`;
}

View File

@@ -93,6 +93,22 @@
},
"additionalProperties": false
},
"serviceProxy": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"listen": {
"type": "string"
},
"publicBaseUrl": {
"type": "string",
"format": "uri"
}
},
"additionalProperties": false
},
"auth": {
"type": "object",
"properties": {