Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-02 20:31:09 +07:00
48 changed files with 2949 additions and 1545 deletions
+3 -1
View File
@@ -158,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value | | Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. | | `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. | | `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. | | `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. | | `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. | | `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error. `<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command: `PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
+17 -15
View File
@@ -1,6 +1,6 @@
# Service Proxy # 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. Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works ## How it works
@@ -9,22 +9,22 @@ When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a loc
The generated hostname is built from the script name, branch, and project: The generated hostname is built from the script name, branch, and project:
``` ```
<script>--<branch>--<project>.<base-domain> <script>--<branch>--<project>.localhost
``` ```
If the branch is `main` or `master`, the branch segment is omitted: If the branch is `main` or `master`, the branch segment is omitted:
``` ```
<script>--<project>.<base-domain> <script>--<project>.localhost
``` ```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at: **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 dev--feature-auth--miniweb.localhost
``` ```
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. Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration ## Configuration
@@ -35,7 +35,6 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
"version": 1, "version": 1,
"daemon": { "daemon": {
"serviceProxy": { "serviceProxy": {
"enabled": true,
"listen": "0.0.0.0:8080", "listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com" "publicBaseUrl": "https://paseoapps.my.domain.com"
} }
@@ -44,10 +43,11 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
``` ```
| Field | Required | Description | | Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------- | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | No | Enables the proxy. Defaults to `true` if `publicBaseUrl` is set, `false` otherwise. | | `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `listen` | No | Address and port the proxy listens on. Defaults to `127.0.0.1:6868`. | | `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
| `publicBaseUrl` | No | Base URL used to generate public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup ## DNS and reverse proxy setup
@@ -63,7 +63,9 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config. 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. 3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and 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.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example: Nginx example:
@@ -84,7 +86,7 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`: The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description | | Variable | Description |
| ------------------------------------- | ----------------------------------- | | ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | `true` or `false` | | `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Listen address, e.g. `0.0.0.0:8080` | | `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Public base URL | | `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
+2 -6
View File
@@ -1,6 +1,6 @@
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
export function derivePendingPermissionKey( export function derivePendingPermissionKey(
agentId: string, agentId: string,
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
? new Date(snapshot.attentionTimestamp) ? new Date(snapshot.attentionTimestamp)
: null; : null;
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL]; const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
const parentAgentId =
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
? parentAgentLabel.trim()
: null;
return { return {
serverId, serverId,
@@ -6,9 +6,11 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
const runningService: WorkspaceScriptPayload = { const runningService: WorkspaceScriptPayload = {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature.paseo.localhost", hostname: "web--feature--paseo.localhost",
port: 3000, port: 3000,
proxyUrl: "http://web.feature.paseo.localhost:6767", localProxyUrl: "http://web--feature--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature--paseo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect( expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }), resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({ ).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767", openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767", labelUrl: "http://web--feature--paseo.localhost:6767",
}); });
}); });
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect( expect(
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }), resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
).toEqual({ ).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767", openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767", labelUrl: "http://web--feature--paseo.localhost:6767",
}); });
}); });
@@ -59,7 +61,114 @@ describe("resolveWorkspaceScriptLink", () => {
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }), resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
).toEqual({ ).toEqual({
openUrl: null, openUrl: null,
labelUrl: "http://web.feature.paseo.localhost:6767", labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("opens the public URL over relay when one is provided", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("uses local URL for direct loopback even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses local URL for direct socket and pipe even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses public URL for direct remote TCP when split URLs exist", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: {
type: "directTcp",
endpoint: "mac-mini.tail123.ts.net:6767",
display: "remote",
},
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("keeps old daemon local-only proxyUrl payloads working", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = runningService;
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("keeps old daemon public proxyUrl payloads working over relay", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = {
...runningService,
proxyUrl: "https://web--feature--paseo.services.example.com",
};
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
}); });
}); });
}); });
@@ -50,27 +50,33 @@ export function resolveWorkspaceScriptLink(input: {
return { openUrl: null, labelUrl: script.proxyUrl }; return { openUrl: null, labelUrl: script.proxyUrl };
} }
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl); const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
const publicProxyUrl =
if (!proxyUrlIsLocalOnly) { script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
}
if (activeConnection.type === "relay") { if (activeConnection.type === "relay") {
return { openUrl: null, labelUrl: script.proxyUrl }; return {
openUrl: publicProxyUrl,
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
};
} }
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
} }
try { try {
const { host } = parseHostPort(activeConnection.endpoint); const { host } = parseHostPort(activeConnection.endpoint);
if (isLoopbackHost(host)) { if (isLoopbackHost(host)) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
} }
} catch { } catch {
return { openUrl: null, labelUrl: script.proxyUrl }; return { openUrl: null, labelUrl: preferredProxyUrl };
}
if (publicProxyUrl) {
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
} }
if (script.port === null) { if (script.port === null) {
@@ -80,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port); const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
return { return {
openUrl: directUrl, openUrl: directUrl,
labelUrl: directUrl ?? script.proxyUrl, labelUrl: directUrl ?? preferredProxyUrl,
}; };
} }
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import {
getParentAgentIdFromLabels,
isDelegatedAgent,
PARENT_AGENT_ID_LABEL,
} from "./agent-labels.js";
describe("agent label policy", () => {
test("treats a non-empty parent agent label as delegation", () => {
const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" };
expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent");
expect(isDelegatedAgent({ labels })).toBe(true);
});
test("ignores missing, empty, and non-string parent agent labels", () => {
expect(isDelegatedAgent({ labels: {} })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
});
});
+15
View File
@@ -1 +1,16 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id"; export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
export interface AgentLabelSource {
labels?: Record<string, unknown> | null;
}
export function getParentAgentIdFromLabels(labels: Record<string, unknown> | null | undefined) {
const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL];
return typeof parentAgentId === "string" && parentAgentId.trim().length > 0
? parentAgentId.trim()
: null;
}
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
return getParentAgentIdFromLabels(agent.labels) !== null;
}
+2
View File
@@ -2325,6 +2325,8 @@ export const WorkspaceScriptPayloadSchema = z.object({
type: z.enum(["script", "service"]).optional().default("service"), type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(), hostname: z.string(),
port: z.number().int().positive().nullable(), port: z.number().int().positive().nullable(),
localProxyUrl: z.string().nullable().optional(),
publicProxyUrl: z.string().nullable().optional(),
proxyUrl: z.string().nullable().optional().default(null), proxyUrl: z.string().nullable().optional().default(null),
lifecycle: WorkspaceScriptLifecycleSchema, lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(), health: WorkspaceScriptHealthSchema.nullable(),
@@ -5,6 +5,7 @@ import {
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema, WorkspaceDescriptorPayloadSchema,
WorkspaceScriptPayloadSchema,
} from "./messages.js"; } from "./messages.js";
describe("workspace message schemas", () => { describe("workspace message schemas", () => {
@@ -485,6 +486,53 @@ describe("workspace message schemas", () => {
}); });
}); });
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBeUndefined();
expect(parsed.publicProxyUrl).toBeUndefined();
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
});
test("parses workspace service payloads with split local and public proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: "https://web--repo.services.example.com",
proxyUrl: "https://web--repo.services.example.com",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
});
test("defaults omitted workspace script proxyUrl to null", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
lifecycle: "stopped",
health: null,
});
expect(parsed.proxyUrl).toBeNull();
});
test("parses workspace_setup_progress payload", () => { test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress", type: "workspace_setup_progress",
@@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => {
expect(attentionCalls).toHaveLength(0); expect(attentionCalls).toHaveLength(0);
}); });
test("onAgentAttention is not called for delegated child agents", async () => {
const childAgentId = "00000000-0000-4000-8000-000000000112";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const attentionCalls: string[] = [];
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => childAgentId,
onAgentAttention: ({ agentId }) => {
attentionCalls.push(agentId);
},
});
const agent = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Delegated Child Agent",
},
undefined,
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } },
);
await manager.runAgent(agent.id, "hello");
expect(attentionCalls).toEqual([]);
});
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => { test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -5,7 +5,7 @@ import {
AGENT_LIFECYCLE_STATUSES, AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus, type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle"; } from "@getpaseo/protocol/agent-lifecycle";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -3368,6 +3368,10 @@ export class AgentManager {
agent: ManagedAgent, agent: ManagedAgent,
reason: "finished" | "error" | "permission", reason: "finished" | "error" | "permission",
): void { ): void {
if (isDelegatedAgent(agent)) {
return;
}
this.onAgentAttention?.({ this.onAgentAttention?.({
agentId: agent.id, agentId: agent.id,
provider: agent.provider, provider: agent.provider,
@@ -97,6 +97,18 @@ class SessionEvents {
.map((event) => event.item); .map((event) => event.item);
} }
timelineAndCompletionEvents() {
return this.events.flatMap((event) => {
if (event.type === "timeline") {
return [{ type: "timeline" as const, item: event.item }];
}
if (event.type === "turn_completed") {
return [{ type: "turn_completed" as const }];
}
return [];
});
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> { nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent( return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> => (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
@@ -459,6 +471,28 @@ describe("PiRpcAgentSession", () => {
]); ]);
}); });
test("surfaces Pi extension command messages and completes when no agent turn starts", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("/show-status");
fakeSession.emit({
type: "message_end",
message: {
role: "custom",
content: [{ type: "text", text: "Extension command output" }],
},
});
expect(events.timelineAndCompletionEvents()).toEqual([
{
type: "timeline",
item: { type: "assistant_message", text: "Extension command output" },
},
{ type: "turn_completed" },
]);
});
test("adds Pi assistant context to generic provider finish errors", async () => { test("adds Pi assistant context to generic provider finish errors", async () => {
const { pi, session, events } = await createSession(); const { pi, session, events } = await createSession();
@@ -1499,6 +1499,20 @@ export class PiRpcAgentSession implements AgentSession {
event: Extract<PiAgentSessionEvent, { type: "message_end" }>, event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
turnId: string | undefined, turnId: string | undefined,
): void { ): void {
if (event.message.role === "custom") {
const text = getUserMessageText(event.message.content);
if (text) {
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId,
item: { type: "assistant_message", text },
});
}
this.completeTurn(turnId, []);
return;
}
if (event.message.role !== "user") { if (event.message.role !== "user") {
return; return;
} }
@@ -30,6 +30,10 @@ export type PiAgentMessage =
role: "user"; role: "user";
content: string | Array<PiTextContent | PiImageContent>; content: string | Array<PiTextContent | PiImageContent>;
} }
| {
role: "custom";
content: string | Array<PiTextContent | PiImageContent>;
}
| { | {
role: "assistant"; role: "assistant";
content: PiAssistantContent[]; content: PiAssistantContent[];
@@ -1,14 +1,18 @@
import os from "node:os"; import os from "node:os";
import http from "node:http";
import path from "node:path"; import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino"; import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js"; import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
describe("paseo daemon bootstrap", () => { describe("paseo daemon bootstrap", () => {
afterEach(() => { afterEach(() => {
@@ -41,6 +45,253 @@ describe("paseo daemon bootstrap", () => {
} }
}); });
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode ?? 0,
headers: res.headers as HeadersInit,
}),
);
});
},
);
req.on("error", reject);
});
}
test("proxies registered service hosts before daemon auth while daemon APIs stay protected", async () => {
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-service-auth",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: address.port,
});
const serviceResponse = await httpGetWithHost(
daemonHandle.port,
`web--repo.localhost:${daemonHandle.port}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
const daemonResponse = await httpGetWithHost(
daemonHandle.port,
`daemon.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(daemonResponse.status).toBe(401);
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("configured public service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: {
publicBaseUrl: "https://services.example.com",
standaloneListen: null,
},
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing.services.example.com:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("rolls back daemon listener when standalone service proxy startup fails", async () => {
const occupiedServer = http.createServer((_req, res) => {
res.end("occupied");
});
await new Promise<void>((resolve) => occupiedServer.listen(0, "127.0.0.1", resolve));
const address = occupiedServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected occupied TCP address");
}
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-standalone-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: {
standaloneListen: `127.0.0.1:${address.port}`,
},
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${daemon.port}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedServer.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("local service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing--repo.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("daemon websocket still upgrades when service proxy upgrade handler is mounted", async () => {
const daemonHandle = await createTestPaseoDaemon();
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`);
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
expect(ws.readyState).toBe(WebSocket.OPEN);
} finally {
ws.close();
await daemonHandle.close();
}
});
test("standalone listener exposes services only", async () => {
const standalonePort = await findFreePort();
const upstream = http.createServer((_req, res) => {
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const upstreamAddress = upstream.address();
if (!upstreamAddress || typeof upstreamAddress === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-standalone",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: upstreamAddress.port,
});
const serviceResponse = await httpGetWithHost(
standalonePort,
`web--repo.localhost:${standalonePort}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
for (const requestPath of ["/api/health", "/ws", "/mcp/agents", "/index.html", "/files/x"]) {
const response = await httpGetWithHost(
standalonePort,
`daemon.localhost:${standalonePort}`,
requestPath,
);
expect(response.status).toBe(404);
}
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${mainPort}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("redacts Agent MCP debug request credentials and bodies", async () => { test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = []; const logLines: string[] = [];
const logger = pino( const logger = pino(
+51 -99
View File
@@ -126,11 +126,7 @@ import type {
ProviderOverride, ProviderOverride,
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js"; import type { PersistedConfig } from "./persisted-config.js";
import { import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
} from "./script-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js"; import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -250,9 +246,8 @@ export interface PaseoDaemonConfig {
relayUseTls?: boolean; relayUseTls?: boolean;
relayPublicUseTls?: boolean; relayPublicUseTls?: boolean;
serviceProxy?: { serviceProxy?: {
enabled: boolean;
listen: string;
publicBaseUrl: string | null; publicBaseUrl: string | null;
standaloneListen: string | null;
}; };
appBaseUrl?: string; appBaseUrl?: string;
auth?: DaemonAuthConfig; auth?: DaemonAuthConfig;
@@ -282,7 +277,7 @@ export interface PaseoDaemon {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
terminalManager: TerminalManager; terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
scriptRuntimeStore: WorkspaceScriptRuntimeStore; scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
@@ -336,24 +331,25 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const scriptRouteStore = new ScriptRouteStore(); const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
const serviceProxyPublicBaseUrl =
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
? config.serviceProxy.publicBaseUrl ? config.serviceProxy.publicBaseUrl
: null; : null;
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: serviceProxyPublicBaseUrl,
});
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
let wsServer: VoiceAssistantWebSocketServer | null = null; let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
let serviceProxyListenTarget: ListenTarget | null = null; let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({ const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore, serviceProxy,
onChange: createScriptStatusEmitter({ onChange: createScriptStatusEmitter({
sessions: () => sessions: () =>
wsServer?.listActiveSessions().map((session) => ({ wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message), emit: (message) => session.emitServerMessage(message),
})) ?? [], })) ?? [],
routeStore: scriptRouteStore, serviceProxy,
runtimeStore: scriptRuntimeStore, runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) => resolveWorkspaceDirectory: async (workspaceId) =>
@@ -363,13 +359,18 @@ export async function createPaseoDaemon(
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore, serviceProxy,
onRoutesChanged: (workspaceId) => { onRoutesChanged: (workspaceId) => {
scriptHealthMonitor.invalidateWorkspace(workspaceId); scriptHealthMonitor.invalidateWorkspace(workspaceId);
}, },
logger, logger,
}); });
// Service proxy classifies service hosts before daemon auth/route fallthrough.
// Registered service hosts proxy directly; known service namespaces without a
// route return 404 and never reach daemon APIs.
app.use(serviceProxy.middleware());
// 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") {
@@ -419,11 +420,6 @@ export async function createPaseoDaemon(
}), }),
); );
// Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local script port. Placed after
// host/CORS/auth checks but before the rest of the routes.
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// Serve static files from public directory // Serve static files from public directory
app.use("/public", express.static(staticDir)); app.use("/public", express.static(staticDir));
@@ -507,34 +503,10 @@ export async function createPaseoDaemon(
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// script-bound upgrades are forwarded first. The handler is a no-op for // script-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered script route. // requests that don't match a registered script route.
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({ httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
routeStore: scriptRouteStore,
logger,
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
if (config.serviceProxy?.enabled) { if (config.serviceProxy?.standaloneListen) {
const serviceProxyTarget = parseListenString(config.serviceProxy.listen); serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
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 agentStorage = new AgentStorage(config.agentStoragePath, logger);
@@ -767,7 +739,7 @@ export async function createPaseoDaemon(
sessionLogger: logger, sessionLogger: logger,
terminalManager, terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
@@ -914,6 +886,23 @@ export async function createPaseoDaemon(
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => { const start = async () => {
let mainStarted = false;
try {
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
});
serviceProxyListenTarget = boundServiceProxyTarget;
logger.info(
{
listen: formatListenTarget(serviceProxyListenTarget),
publicBaseUrl: serviceProxyPublicBaseUrl,
elapsed: elapsed(),
},
"Service proxy listening",
);
}
// Start main HTTP server // Start main HTTP server
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => { const onError = (err: Error) => {
@@ -922,6 +911,7 @@ export async function createPaseoDaemon(
}; };
const onListening = () => { const onListening = () => {
httpServer.off("error", onError); httpServer.off("error", onError);
mainStarted = true;
const logAndResolve = async () => { const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer); boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null; const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
@@ -995,7 +985,7 @@ export async function createPaseoDaemon(
loopService, loopService,
scheduleService, scheduleService,
checkoutDiffManager, checkoutDiffManager,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
handleBranchChange, handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
@@ -1063,45 +1053,18 @@ 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 // Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections. // model loading doesn't block the server from accepting connections.
speechService.start(); speechService.start();
scriptHealthMonitor.start(); scriptHealthMonitor.start();
} catch (error) {
await serviceProxy.stopStandalone().catch(() => undefined);
if (mainStarted) {
httpServer.closeAllConnections();
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
throw error;
}
}; };
const stop = async () => { const stop = async () => {
@@ -1118,18 +1081,7 @@ export async function createPaseoDaemon(
if (wsServer) { if (wsServer) {
await wsServer.close(); await wsServer.close();
} }
if (serviceProxyHttpServer) { await serviceProxy.stopStandalone();
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. // Force-drop remaining sockets so httpServer.close() resolves promptly.
// We've already closed wsServer (which sent ws-layer close frames) and // We've already closed wsServer (which sent ws-layer close frames) and
// stopped every other service, so anything still attached is a TCP // stopped every other service, so anything still attached is a TCP
@@ -1152,7 +1104,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
start, start,
stop, stop,
@@ -104,9 +104,38 @@ describe("daemon service proxy config", () => {
}); });
expect(config.serviceProxy).toEqual({ expect(config.serviceProxy).toEqual({
enabled: true,
listen: "127.0.0.1:6868",
publicBaseUrl: "https://env.example.com", publicBaseUrl: "https://env.example.com",
standaloneListen: null,
});
});
test("does not synthesize a standalone service listener from enabled true", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { serviceProxy: { enabled: true } },
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
});
});
test("enabled false suppresses optional service proxy layers only", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
serviceProxy: {
enabled: false,
listen: "127.0.0.1:9999",
publicBaseUrl: "https://persisted.example.com",
},
},
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
}); });
}); });
+15 -14
View File
@@ -24,7 +24,6 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
const DEFAULT_PORT = 6767; const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; 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 { function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) { if (value === undefined) {
@@ -154,9 +153,8 @@ interface ResolvedRelay {
} }
interface ResolvedServiceProxy { interface ResolvedServiceProxy {
enabled: boolean;
listen: string;
publicBaseUrl: string | null; publicBaseUrl: string | null;
standaloneListen: string | null;
} }
function resolveTlsFromEnv( function resolveTlsFromEnv(
@@ -220,21 +218,24 @@ function resolveServiceProxyConfig(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>, persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedServiceProxy { ): ResolvedServiceProxy {
const publicBaseUrl = resolveServiceProxyPublicBaseUrl( const enabledShim =
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// `enabled=false` used to disable the separate service proxy listener. Localhost
// service proxying is now always enabled; this only suppresses optional layers.
const optionalLayersEnabled = enabledShim !== false;
const publicBaseUrl = optionalLayersEnabled
? resolveServiceProxyPublicBaseUrl(
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ?? env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
persisted.daemon?.serviceProxy?.publicBaseUrl ?? persisted.daemon?.serviceProxy?.publicBaseUrl ??
null, null,
); )
const enabled = : null;
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? const standaloneListen = optionalLayersEnabled
persisted.daemon?.serviceProxy?.enabled ?? ? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
publicBaseUrl !== null; : null;
const listen =
env.PASEO_SERVICE_PROXY_LISTEN ??
persisted.daemon?.serviceProxy?.listen ??
DEFAULT_SERVICE_PROXY_LISTEN;
return { enabled, listen, publicBaseUrl }; return { publicBaseUrl, standaloneListen };
} }
function resolveVoiceLlmConfig( function resolveVoiceLlmConfig(
@@ -239,6 +239,9 @@ export const PersistedConfigSchema = z
.optional(), .optional(),
serviceProxy: z serviceProxy: z
.object({ .object({
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// Parsed only to suppress optional public/listen layers for old configs;
// localhost service proxying remains always enabled.
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
listen: z.string().optional(), listen: z.string().optional(),
publicBaseUrl: z.string().url().optional(), publicBaseUrl: z.string().url().optional(),
@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
branchName: null, branchName: null,
scriptName, scriptName,
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager( terminalManager: createStubTerminalManager(
createTerminalCalls, createTerminalCalls,
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
expect(createTerminalCalls).toHaveLength(2); expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: service.port, port: service.port,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [ expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
{ {
scriptName: "api", scriptName: "api",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: service.port, port: service.port,
health: "healthy", health: "healthy",
}, },
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -1,5 +1,5 @@
import net from "node:net"; import net from "node:net";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy"; export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -18,7 +18,7 @@ interface RouteHealthState {
} }
export class ScriptHealthMonitor { export class ScriptHealthMonitor {
private readonly routeStore: ScriptRouteStore; private readonly serviceProxy: ServiceProxySubsystem;
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
private readonly pollIntervalMs: number; private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number; private readonly probeTimeoutMs: number;
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
private pollInFlight = false; private pollInFlight = false;
constructor({ constructor({
routeStore, serviceProxy,
onChange, onChange,
pollIntervalMs = 3_000, pollIntervalMs = 3_000,
probeTimeoutMs = 500, probeTimeoutMs = 500,
graceMs = 5_000, graceMs = 5_000,
failuresBeforeStopped = 2, failuresBeforeStopped = 2,
}: { }: {
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
pollIntervalMs?: number; pollIntervalMs?: number;
probeTimeoutMs?: number; probeTimeoutMs?: number;
graceMs?: number; graceMs?: number;
failuresBeforeStopped?: number; failuresBeforeStopped?: number;
}) { }) {
this.routeStore = routeStore; this.serviceProxy = serviceProxy;
this.onChange = onChange; this.onChange = onChange;
this.pollIntervalMs = pollIntervalMs; this.pollIntervalMs = pollIntervalMs;
this.probeTimeoutMs = probeTimeoutMs; this.probeTimeoutMs = probeTimeoutMs;
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
} }
const now = Date.now(); const now = Date.now();
for (const route of this.routeStore.listRoutes()) { for (const route of this.serviceProxy.getHealthCheckTargets()) {
this.getOrCreateState(route, now); this.getOrCreateState(route, now);
} }
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
this.pollInFlight = true; this.pollInFlight = true;
try { try {
const routes = this.routeStore.listRoutes(); const routes = this.serviceProxy.getHealthCheckTargets();
const activeHostnames = new Set(routes.map((route) => route.hostname)); const activeHostnames = new Set(routes.map((route) => route.hostname));
const changedWorkspaceIds = new Set<string>(); const changedWorkspaceIds = new Set<string>();
const now = Date.now(); const now = Date.now();
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
} }
private getOrCreateState( private getOrCreateState(
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">, route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
registeredAt: number, registeredAt: number,
): RouteHealthState { ): RouteHealthState {
const existing = this.routeStates.get(route.hostname); const existing = this.routeStates.get(route.hostname);
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
} }
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] { private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => { return this.serviceProxy.getWorkspaceHealthTargets(workspaceId).flatMap((route) => {
const state = this.routeStates.get(route.hostname); const state = this.routeStates.get(route.hostname);
if (!state) { if (!state) {
return []; return [];
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
return state.health; return state.health;
} }
const route = this.routeStore.getRouteEntry(hostname); const route = this.serviceProxy.getHealthTargetForHostname(hostname);
if (!route) { if (!route) {
return null; return null;
} }
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
} }
private toScriptHealthEntry( private toScriptHealthEntry(
route: ScriptRouteEntry, route: ServiceProxyHealthTarget,
health: ScriptHealthEntry["health"], health: ScriptHealthEntry["health"],
): ScriptHealthEntry { ): ScriptHealthEntry {
return { return {
+8 -476
View File
@@ -1,484 +1,16 @@
import { it, expect, afterEach } from "vitest"; import { describe, expect, it } 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 { import {
ScriptRouteStore,
createScriptProxyMiddleware, createScriptProxyMiddleware,
createScriptProxyUpgradeHandler, createScriptProxyUpgradeHandler,
findFreePort, findFreePort,
ScriptRouteStore,
} from "./script-proxy.js"; } from "./script-proxy.js";
const logger = pino({ level: "silent" }); describe("script-proxy compatibility re-exports", () => {
it("keeps the legacy imports available", () => {
// --------------------------------------------------------------------------- expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
// Helpers for cleanup expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
// --------------------------------------------------------------------------- expect(findFreePort).toEqual(expect.any(Function));
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
it("registerRoute and findRoute with exact match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute strips port from host header", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost:6767");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute subdomain match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "editor.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("tenant.editor.example.localhost");
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
});
it("listRoutes returns enriched entries", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
}); });
}); });
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.registerRoute({
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
},
]);
});
it("removeRoute works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("route-a.example.localhost")).toBeNull();
});
it("removeRoute cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("removeRoutesForPort works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
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("removeRoutesForPort cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("findRoute returns null for unknown hosts", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
// ---------------------------------------------------------------------------
// 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: ScriptRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createScriptProxyMiddleware({ 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 ScriptRouteStore();
routeStore.registerRoute({
hostname: "test-service.localhost",
port: upstream.port,
workspaceId: "workspace-test",
projectSlug: "test",
scriptName: "service",
});
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 ScriptRouteStore();
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 ScriptRouteStore();
routeStore.registerRoute({
hostname: "dead-service.localhost",
port: deadPort,
workspaceId: "workspace-dead",
projectSlug: "dead",
scriptName: "service",
});
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
// ---------------------------------------------------------------------------
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 ScriptRouteStore();
routeStore.registerRoute({
hostname: "ws-service.localhost",
port: upstreamPort,
workspaceId: "workspace-ws",
projectSlug: "ws",
scriptName: "service",
});
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createScriptProxyUpgradeHandler({
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
// ---------------------------------------------------------------------------
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()));
});
+7 -353
View File
@@ -1,353 +1,7 @@
import http from "node:http"; export {
import net from "node:net"; createScriptProxyMiddleware,
import type { IncomingMessage } from "node:http"; createScriptProxyUpgradeHandler,
import type { Logger } from "pino"; findFreePort,
import type { RequestHandler } from "express"; ScriptRouteStore,
} from "./service-proxy.js";
// --------------------------------------------------------------------------- export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
// 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",
]);
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
export interface ScriptRoute {
hostname: string;
port: number;
}
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.removeRoute(previous.hostname);
}
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 canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
const entry = this.routes.get(canonicalHostname);
if (!entry) {
return;
}
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 {
const routes = this.listRoutesForWorkspace(params.workspaceId);
const route = routes.find((entry) => entry.scriptName === params.scriptName);
if (!route) {
return;
}
this.removeRoute(route.hostname);
}
removeRoutesForPort(port: number): void {
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);
}
}
}
findRoute(host: string): ScriptRoute | null {
// Strip port suffix from the Host header value
const hostname = host.replace(/:\d+$/, "");
// 1. Exact match
const exactRoute = this.getRouteByHostname(hostname);
if (exactRoute !== undefined) {
return { hostname: exactRoute.hostname, port: exactRoute.port };
}
// 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 candidateRoute = this.getRouteByHostname(candidate);
if (candidateRoute !== undefined) {
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
}
}
return null;
}
getRouteEntry(hostname: string): ScriptRouteEntry | null {
const entry = this.getRouteByHostname(hostname);
return entry ? { ...entry } : null;
}
listRoutes(): ScriptRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => Object.assign({}, entry));
}
listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return [];
}
const routes: ScriptRouteEntry[] = [];
for (const hostname of hostnames) {
const entry = this.routes.get(hostname);
if (entry) {
routes.push({ ...entry });
}
}
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);
this.workspaceHostnames.set(workspaceId, hostnames);
}
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return;
}
hostnames.delete(hostname);
if (hostnames.size === 0) {
this.workspaceHostnames.delete(workspaceId);
}
}
}
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// createScriptProxyMiddleware
// ---------------------------------------------------------------------------
export function createScriptProxyMiddleware({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
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 },
"Script proxy: upstream unreachable",
);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
res.end("502 Bad Gateway");
}
});
req.pipe(proxyReq, { end: true });
};
}
// ---------------------------------------------------------------------------
// createScriptProxyUpgradeHandler
// ---------------------------------------------------------------------------
export function createScriptProxyUpgradeHandler({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
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 },
"Script 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);
});
}
@@ -76,22 +76,22 @@ describe("script-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => { it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull(); expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({ expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
port: 3001, port: 3001,
}); });
}); });
@@ -100,7 +100,7 @@ describe("script-route-branch-handler", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -113,14 +113,14 @@ describe("script-route-branch-handler", () => {
it("is a no-op when the resolved hostnames do not change", () => { it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.paseo.localhost", hostname: "api--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -128,7 +128,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{ {
hostname: "api.paseo.localhost", hostname: "api--paseo.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
@@ -141,14 +141,14 @@ describe("script-route-branch-handler", () => {
it("triggers shared reprojection after a route change", () => { it("triggers shared reprojection after a route change", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -160,7 +160,7 @@ describe("script-route-branch-handler", () => {
it("updates public route aliases from the stored public base URL", () => { it("updates public route aliases from the stored public base URL", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
publicHostname: "api--feature-auth--paseo.services.example.com", publicHostname: "api--feature-auth--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443", publicBaseUrl: "https://services.example.com:8443",
port: 3001, port: 3001,
@@ -169,7 +169,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -177,12 +177,12 @@ describe("script-route-branch-handler", () => {
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull(); expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({ expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
port: 3001, port: 3001,
}); });
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{ {
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
publicHostname: "api--feature-billing--paseo.services.example.com", publicHostname: "api--feature-billing--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443", publicBaseUrl: "https://services.example.com:8443",
port: 3001, port: 3001,
@@ -196,17 +196,17 @@ describe("script-route-branch-handler", () => {
it("updates all services for a workspace when multiple routes are registered", () => { it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "web.feature-auth.paseo.localhost", hostname: "web--feature-auth--paseo.localhost",
port: 3002, port: 3002,
scriptName: "web", scriptName: "web",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "docs.docs-app.localhost", hostname: "docs--docs-app.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
projectSlug: "docs-app", projectSlug: "docs-app",
@@ -215,7 +215,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -223,14 +223,14 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{ {
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
scriptName: "api", scriptName: "api",
}, },
{ {
hostname: "web.feature-billing.paseo.localhost", hostname: "web--feature-billing--paseo.localhost",
port: 3002, port: 3002,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
@@ -239,7 +239,7 @@ describe("script-route-branch-handler", () => {
]); ]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
{ {
hostname: "docs.docs-app.localhost", hostname: "docs--docs-app.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
projectSlug: "docs-app", projectSlug: "docs-app",
@@ -251,14 +251,14 @@ describe("script-route-branch-handler", () => {
it("does not emit a status update when no changes are needed", () => { it("does not emit a status update when no changes are needed", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "web.paseo.localhost", hostname: "web--paseo.localhost",
port: 3002, port: 3002,
scriptName: "web", scriptName: "web",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -279,7 +279,7 @@ describe("script-route-branch-handler", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.repo.localhost", hostname: "api--feature-auth--repo.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -288,7 +288,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -297,7 +297,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([ expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
{ {
hostname: "api.feature-billing.repo.localhost", hostname: "api--feature-billing--repo.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -309,4 +309,99 @@ describe("script-route-branch-handler", () => {
workspace.cleanup(); workspace.cleanup();
} }
}); });
it("leaves existing local and public routes intact when a branch rename collides", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
registerRoute(routeStore, {
hostname: "api--feature-billing--repo.localhost",
publicHostname: "api--feature-billing--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4001,
workspaceId: "workspace-b",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
},
]);
expect(routeStore.getRouteEntry("api--feature-auth--repo.services.example.com")).toMatchObject({
workspaceId: "workspace-a",
port: 3001,
});
expect(
routeStore.getRouteEntry("api--feature-billing--repo.services.example.com"),
).toMatchObject({
workspaceId: "workspace-b",
port: 4001,
});
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("leaves old routes intact when branch rename creates an internal incoming collision", () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api--feature-one--repo.localhost",
publicHostname: "api--feature-one--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
routeStore.registerRoute({
hostname: "api--feature-two--repo.localhost",
publicHostname: "api--feature-two--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3002,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
port: 3001,
});
expect(routeStore.getRouteEntry("api--feature-two--repo.localhost")).toMatchObject({
port: 3002,
});
expect(routeStore.getRouteEntry("api--feature-collide--repo.localhost")).toBeNull();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
}); });
@@ -1,80 +1,24 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions { interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
onRoutesChanged: (workspaceId: string) => void; onRoutesChanged: (workspaceId: string) => void;
logger?: Logger; logger?: Logger;
} }
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
newPublicHostname: string | null;
route: ScriptRouteEntry;
}
export function createBranchChangeRouteHandler( export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions, options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void { ): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => { return (workspaceId, _oldBranch, newBranch) => {
// Only service scripts register routes, so branch renames only touch services. const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
const routes = options.routeStore.listRoutesForWorkspace(workspaceId); if (!changed) {
if (routes.length === 0) {
return; return;
} }
const updates: RouteHostnameUpdate[] = [];
for (const route of routes) {
const newHostname = buildScriptHostname({
projectSlug: route.projectSlug,
branchName: newBranch,
scriptName: route.scriptName,
});
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,
});
}
}
if (updates.length === 0) {
return;
}
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,
scriptName: route.scriptName,
});
options.logger?.info( options.logger?.info(
{ { workspaceId, newBranch },
oldHostname, "Updated service proxy routes for branch rename",
newHostname,
scriptName: route.scriptName,
},
"Updated script route for branch rename",
); );
}
options.onRoutesChanged(workspaceId); options.onRoutesChanged(workspaceId);
}; };
} }
@@ -54,15 +54,22 @@ function buildPayloads(input: {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig?: PaseoConfig | null; paseoConfig?: PaseoConfig | null;
routeStore: ScriptRouteStore; routeStore?: ScriptRouteStore;
serviceProxy?: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: { projectSlug: string; currentBranch: string | null }; gitMetadata?: { projectSlug: string; currentBranch: string | null };
resolveHealth?: (hostname: string) => ScriptHealthState | null; resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) { }) {
const paseoConfig = const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory); input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
return buildWorkspaceScriptPayloads({ ...input, paseoConfig }); const { routeStore, serviceProxy, ...rest } = input;
return buildWorkspaceScriptPayloads({
...rest,
serviceProxy: serviceProxy ?? routeStore ?? new ScriptRouteStore(),
paseoConfig,
});
} }
function loadConfig(repoRoot: string): PaseoConfig | null { function loadConfig(repoRoot: string): PaseoConfig | null {
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 3000, port: 3000,
proxyUrl: "http://web.repo.localhost:6767", localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
const payloads = buildPayloads({ const payloads = buildPayloads({
workspaceId, workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
daemonPort: 6767, daemonPort: 6767,
gitMetadata: { gitMetadata: {
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature-from-service.service-provided.localhost", hostname: "web--feature-from-service--service-provided.localhost",
port: 3000, port: 3000,
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767", localProxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
terminalId: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
const workspaceId = "workspace-public-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
web: { type: "service", command: "npm run web", port: 3000 },
},
},
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
try {
expect(
buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
gitMetadata: { projectSlug: "repo", currentBranch: "feature/card" },
}),
).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web--feature-card--repo.localhost",
port: 3000,
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: "https://web--feature-card--repo.services.example.com",
proxyUrl: "https://web--feature-card--repo.services.example.com",
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.feature-card.repo.localhost", hostname: "web--feature-card--repo.localhost",
port: 4321, port: 4321,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature-card.repo.localhost", hostname: "web--feature-card--repo.localhost",
port: 4321, port: 4321,
proxyUrl: "http://web.feature-card.repo.localhost:6767", localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-card--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 4321, port: 4321,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 4321, port: 4321,
proxyUrl: "http://web.repo.localhost:6767", localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
const workspace = createWorkspaceRepo(); const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "docs.repo.localhost", hostname: "docs--repo.localhost",
port: 3002, port: 3002,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "docs", scriptName: "docs",
type: "service", type: "service",
hostname: "docs.repo.localhost", hostname: "docs--repo.localhost",
port: 3002, port: 3002,
proxyUrl: "http://docs.repo.localhost:6767", localProxyUrl: "http://docs--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://docs--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
const session = { emit: vi.fn() }; const session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({ const emitUpdate = createScriptStatusEmitter({
sessions: () => [session], sessions: () => [session],
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
daemonPort: 6767, daemonPort: 6767,
resolveWorkspaceDirectory: async (requestedWorkspaceId) => resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
emitUpdate(workspaceId, [ emitUpdate(workspaceId, [
{ {
scriptName: "api", scriptName: "api",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
health: "healthy", health: "healthy",
}, },
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "api", scriptName: "api",
type: "service", type: "service",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
proxyUrl: "http://api.repo.localhost:6767", localProxyUrl: "http://api--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://api--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -5,11 +5,13 @@ import type {
WorkspaceScriptPayload, WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js"; import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type {
ServiceProxySubsystem,
ServiceProxyWorkspaceScriptProjection,
} from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
interface SessionEmitter { interface SessionEmitter {
@@ -20,7 +22,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig: PaseoConfig | null; paseoConfig: PaseoConfig | null;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
@@ -53,28 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort; return daemonPort;
} }
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://${params.hostname}:${params.daemonPort}`;
}
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] { function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
if (health === "pending" || health === null) { if (health === "pending" || health === null) {
return null; return null;
@@ -92,54 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
} }
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number]; type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
interface BuildPayloadContext { interface BuildPayloadContext {
projectSlug: string; projectSlug: string;
branchName: string | null; branchName: string | null;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
serviceProxy: ServiceProxySubsystem;
resolveHealth?: (hostname: string) => ScriptHealthState | null; resolveHealth?: (hostname: string) => ScriptHealthState | null;
} }
function projectWorkspaceServiceState(params: {
workspaceId: string;
scriptName: string;
ctx: BuildPayloadContext;
}): ServiceProxyWorkspaceScriptProjection {
return params.ctx.serviceProxy.projectWorkspaceServiceState({
workspaceId: params.workspaceId,
projectSlug: params.ctx.projectSlug,
branchName: params.ctx.branchName,
scriptName: params.scriptName,
daemonPort: params.ctx.daemonPort,
publicBaseUrl: params.ctx.serviceProxyPublicBaseUrl,
});
}
function buildConfiguredPlainScriptPayload(
scriptName: string,
runtimeEntry: RuntimeEntry | null,
): WorkspaceScriptPayload {
return {
scriptName,
type: "script",
hostname: scriptName,
port: null,
proxyUrl: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: null,
exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
};
}
function buildConfiguredScriptPayload( function buildConfiguredScriptPayload(
scriptName: string, scriptName: string,
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never, config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
runtimeEntry: RuntimeEntry | null, runtimeEntry: RuntimeEntry | null,
routeEntry: RouteEntry | null, serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext, ctx: BuildPayloadContext,
): WorkspaceScriptPayload { ): WorkspaceScriptPayload {
const configIsService = isServiceScript(config); const configIsService = isServiceScript(config);
const type = configIsService ? "service" : "script"; if (!configIsService) {
const configuredPort = configIsService ? (config.port ?? null) : null; return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
const hostname = }
type === "service"
? (routeEntry?.hostname ?? const type = "service";
buildScriptHostname({ const configuredPort = config.port ?? null;
const hostname = (
serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug, projectSlug: ctx.projectSlug,
branchName: ctx.branchName, branchName: ctx.branchName,
scriptName, scriptName,
})) daemonPort: ctx.daemonPort,
: scriptName; publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return { return {
scriptName, scriptName,
type, type,
hostname, hostname,
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null, port: serviceState?.port ?? configuredPort,
proxyUrl: localProxyUrl: urls.localProxyUrl,
type === "service" publicProxyUrl: urls.publicProxyUrl,
? toServiceProxyUrl({ proxyUrl: urls.proxyUrl,
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
})
: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped", lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
exitCode: runtimeEntry?.exitCode ?? null, exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null, terminalId: runtimeEntry?.terminalId ?? null,
}; };
@@ -147,38 +165,47 @@ function buildConfiguredScriptPayload(
function buildOrphanRuntimePayload( function buildOrphanRuntimePayload(
runtimeEntry: RuntimeEntry, runtimeEntry: RuntimeEntry,
routeEntry: RouteEntry | null, serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext, ctx: BuildPayloadContext,
): WorkspaceScriptPayload { ): WorkspaceScriptPayload {
const type = runtimeEntry.type; const type = runtimeEntry.type;
const hostname = const hostname =
type === "service" type === "service"
? (routeEntry?.hostname ?? ? (
buildScriptHostname({ serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug, projectSlug: ctx.projectSlug,
branchName: ctx.branchName, branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName, scriptName: runtimeEntry.scriptName,
})) daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname
: runtimeEntry.scriptName; : runtimeEntry.scriptName;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return { return {
scriptName: runtimeEntry.scriptName, scriptName: runtimeEntry.scriptName,
type, type,
hostname, hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null, port: type === "service" ? (serviceState?.port ?? null) : null,
proxyUrl: ...(type === "service"
type === "service" ? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
? toServiceProxyUrl({ : {}),
hostname, proxyUrl: type === "service" ? urls.proxyUrl : null,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
})
: null,
lifecycle: runtimeEntry.lifecycle, lifecycle: runtimeEntry.lifecycle,
health: health:
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, type === "service" && serviceState?.port !== null
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
: null,
exitCode: runtimeEntry.exitCode, exitCode: runtimeEntry.exitCode,
terminalId: runtimeEntry.terminalId, terminalId: runtimeEntry.terminalId,
}; };
@@ -197,17 +224,12 @@ export function buildWorkspaceScriptPayloads(
.listForWorkspace(workspaceId) .listForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const), .map((entry) => [entry.scriptName, entry] as const),
); );
const routesByScriptName = new Map(
options.routeStore
.listRoutesForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const),
);
const ctx: BuildPayloadContext = { const ctx: BuildPayloadContext = {
projectSlug, projectSlug,
branchName, branchName,
daemonPort: options.daemonPort, daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
serviceProxy: options.serviceProxy,
resolveHealth: options.resolveHealth, resolveHealth: options.resolveHealth,
}; };
@@ -215,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
for (const [scriptName, config] of scriptConfigs.entries()) { for (const [scriptName, config] of scriptConfigs.entries()) {
const runtimeEntry = runtimeEntries.get(scriptName) ?? null; const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
const routeEntry = routesByScriptName.get(scriptName) ?? null; const serviceState = isServiceScript(config)
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx)); ? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
: null;
payloads.push(
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
);
} }
for (const runtimeEntry of runtimeEntries.values()) { for (const runtimeEntry of runtimeEntries.values()) {
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") { if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
continue; continue;
} }
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null; const serviceState =
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx)); runtimeEntry.type === "service"
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
: null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
} }
return sortPayloads(payloads); return sortPayloads(payloads);
@@ -245,7 +274,7 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({ export function createScriptStatusEmitter({
sessions, sessions,
routeStore, serviceProxy,
runtimeStore, runtimeStore,
daemonPort, daemonPort,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
@@ -253,7 +282,7 @@ export function createScriptStatusEmitter({
logger, logger,
}: { }: {
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
@@ -276,7 +305,7 @@ export function createScriptStatusEmitter({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
routeStore, serviceProxy,
runtimeStore, runtimeStore,
daemonPort: resolvedDaemonPort, daemonPort: resolvedDaemonPort,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
@@ -0,0 +1,295 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildServiceProxyLabel,
createServiceProxySubsystem,
findFreePort,
ServiceProxyRouteRegistry,
} from "./service-proxy.js";
const logger = pino({ level: "silent" });
function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[] {
const entries: string[] = [];
for (const name of readdirSync(dir)) {
const fullPath = path.join(dir, name);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
entries.push(...readServerSourceFiles(fullPath));
} else if (fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) {
entries.push(fullPath);
}
}
return entries;
}
function httpGet(port: number, host: string, requestPath = "/api/health") {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, 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);
});
}
describe("service proxy subsystem shape", () => {
it("keeps production imports behind the service-proxy entrypoint", () => {
const offenders: string[] = [];
for (const filePath of readServerSourceFiles()) {
if (filePath.endsWith("service-proxy.ts") || filePath.endsWith("script-proxy.ts")) {
continue;
}
const source = readFileSync(filePath, "utf8");
for (const needle of ["./script-proxy.js", "../utils/script-hostname.js"]) {
if (source.includes(needle)) {
offenders.push(`${path.relative(import.meta.dirname, filePath)} imports ${needle}`);
}
}
}
expect(offenders).toEqual([]);
});
it("classifies the configured public namespace before any route exists", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
await expect(httpGet(port, `daemon.localhost:${port}`)).resolves.toEqual({
status: 200,
body: "daemon-api",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps configured public namespace classified after the last public route is removed", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.removeWorkspaceService({ workspaceId: "workspace-a", scriptName: "api" });
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("uses the same hash-truncated service label for local and public hostnames", () => {
const input = {
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
};
const label = buildServiceProxyLabel(input);
expect(label.length).toBeLessThanOrEqual(63);
expect(label.endsWith("-")).toBe(false);
expect(buildLocalServiceHostname(input)).toBe(`${label}.localhost`);
expect(
buildPublicServiceHostname({ ...input, publicBaseUrl: "https://services.example.com" }),
).toBe(`${label}.services.example.com`);
expect(buildServiceProxyLabel(input)).toBe(label);
expect(
buildServiceProxyLabel({ ...input, scriptName: `different-${input.scriptName}` }),
).not.toBe(label);
});
it("gives long labels with the same prefix different hash suffixes", () => {
const sharedPrefix = "service-".repeat(12);
const first = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}alpha`,
});
const second = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}beta`,
});
expect(first).not.toBe(second);
expect(first.slice(0, -10)).toBe(second.slice(0, -10));
expect(first.split("--").at(-1)).not.toBe(second.split("--").at(-1));
});
it("rejects cross-service collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
workspaceId: "workspace-a",
port: 3000,
});
});
it("rejects public alias collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
publicBaseUrl: "https://services.example.com",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
{
workspaceId: "workspace-a",
port: 3000,
},
);
});
it("rejects public alias collisions even when canonical hostnames differ", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo-a.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo-a",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api--repo-b.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "repo-b",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
});
it("rejects canonical-to-public-alias collisions", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api.services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "other",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
});
it("allows same workspace/script replacement", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
});
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
port: 4000,
});
});
});
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -229,7 +229,7 @@ interface SessionForTestOptions {
workspaceRegistry?: { get: ReturnType<typeof vi.fn> }; workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>; projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"]; terminalManager?: SessionOptions["terminalManager"];
scriptRouteStore?: SessionOptions["scriptRouteStore"]; serviceProxy?: SessionOptions["serviceProxy"];
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"]; scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null; getDaemonTcpHost?: () => string | null;
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
terminalManager: options.terminalManager ?? null, terminalManager: options.terminalManager ?? null,
providerSnapshotManager: providerSnapshotManager:
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager, options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
scriptRouteStore: options.scriptRouteStore, serviceProxy: options.serviceProxy,
scriptRuntimeStore: options.scriptRuntimeStore, scriptRuntimeStore: options.scriptRuntimeStore,
getDaemonTcpPort: options.getDaemonTcpPort, getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost, getDaemonTcpHost: options.getDaemonTcpHost,
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
workspaceGitService, workspaceGitService,
workspaceRegistry, workspaceRegistry,
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) }, terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) }, serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) }, scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
getDaemonTcpPort: () => 6767, getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1", getDaemonTcpHost: () => "127.0.0.1",
+12 -12
View File
@@ -192,7 +192,7 @@ import {
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import { import {
checkoutResolvedBranch, checkoutResolvedBranch,
type CheckoutExistingBranchResult, type CheckoutExistingBranchResult,
@@ -588,7 +588,7 @@ export interface SessionOptions {
tts: Resolvable<TextToSpeechProvider | null>; tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager; providerSnapshotManager: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore; serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore; scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>; workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
onBranchChanged?: ( onBranchChanged?: (
@@ -803,7 +803,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly providerSnapshotManager: ProviderSnapshotManager;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null; private readonly serviceProxy: ServiceProxySubsystem | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: ( private readonly onBranchChanged?: (
workspaceId: string, workspaceId: string,
@@ -881,7 +881,7 @@ export class Session {
tts, tts,
terminalManager, terminalManager,
providerSnapshotManager, providerSnapshotManager,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
workspaceSetupSnapshots, workspaceSetupSnapshots,
onBranchChanged, onBranchChanged,
@@ -966,7 +966,7 @@ export class Session {
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.providerSnapshotManager = providerSnapshotManager; this.providerSnapshotManager = providerSnapshotManager;
this.scriptRouteStore = scriptRouteStore ?? null; this.serviceProxy = serviceProxy ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
this.onBranchChanged = onBranchChanged; this.onBranchChanged = onBranchChanged;
@@ -6295,12 +6295,12 @@ export class Session {
activityAt: null, activityAt: null,
diffStat, diffStat,
scripts: scripts:
this.scriptRouteStore && this.scriptRuntimeStore this.serviceProxy && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({ ? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger), paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7124,14 +7124,14 @@ export class Session {
workspaceId: string, workspaceId: string,
workspaceDirectory: string, workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] { ): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) { if (!this.serviceProxy || !this.scriptRuntimeStore) {
return []; return [];
} }
return buildWorkspaceScriptPayloads({ return buildWorkspaceScriptPayloads({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger), paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7182,7 +7182,7 @@ export class Session {
request: StartWorkspaceScriptRequest, request: StartWorkspaceScriptRequest,
): Promise<void> { ): Promise<void> {
try { try {
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) { if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon"); throw new Error("Workspace scripts are not available on this daemon");
} }
@@ -7201,7 +7201,7 @@ export class Session {
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null, daemonListenHost: this.getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
logger: this.sessionLogger, logger: this.sessionLogger,
@@ -7348,7 +7348,7 @@ export class Session {
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore, scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost, getDaemonTcpHost: this.getDaemonTcpHost,
@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import path from "node:path"; import path from "node:path";
import type pino from "pino"; import type pino from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
import { ScriptRouteStore } from "./script-proxy.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { Session, type SessionOptions } from "./session.js"; import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { createTestLogger } from "../test-utils/test-logger.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { import type {
WorkspaceGitListener, WorkspaceGitListener,
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
scriptRouteStore?: ScriptRouteStore; serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore; scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
}): { }): {
session: Session; session: Session;
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
tts: null, tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager, providerSnapshotManager: createProviderSnapshotManagerStub().manager,
terminalManager: null, terminalManager: null,
scriptRouteStore: options?.scriptRouteStore, serviceProxy: options?.serviceProxy,
scriptRuntimeStore: options?.scriptRuntimeStore, scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged, onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767, getDaemonTcpPort: () => 6767,
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
}); });
test("updates running service script URLs when the git branch changes", async () => { test("updates running service script URLs when the git branch changes", async () => {
const routeStore = new ScriptRouteStore(); const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
routeStore.registerRoute({ serviceProxy.registerWorkspaceService({
hostname: "app.old-branch.paseo.localhost",
port: 4321, port: 4321,
workspaceId: "ws-10", workspaceId: "ws-10",
projectSlug: "paseo", projectSlug: "paseo",
branchName: "old-branch",
scriptName: "app", scriptName: "app",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy,
onRoutesChanged: vi.fn(), onRoutesChanged: vi.fn(),
}); });
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests( const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
{ {
scriptRouteStore: routeStore, serviceProxy,
scriptRuntimeStore: runtimeStore, scriptRuntimeStore: runtimeStore,
onBranchChanged: handleBranchChange, onBranchChanged: handleBranchChange,
}, },
@@ -474,18 +475,19 @@ describe("workspace git watch targets", () => {
}), }),
); );
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([ expect(serviceProxy.getWorkspaceHealthTargets("ws-10")).toEqual([
expect.objectContaining({ expect.objectContaining({
hostname: "app.new-branch.paseo.localhost", hostname: "app--new-branch--paseo.localhost",
projectSlug: "paseo",
scriptName: "app", scriptName: "app",
}), }),
]); ]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({ expect.objectContaining({
scriptName: "app", scriptName: "app",
hostname: "app.new-branch.paseo.localhost", hostname: "app--new-branch--paseo.localhost",
proxyUrl: "http://app.new-branch.paseo.localhost:6767", localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
}), }),
]); ]);
@@ -33,6 +33,7 @@ interface TestPaseoDaemonOptions {
dictationFinalTimeoutMs?: number; dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"]; auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender; pushNotificationSender?: PushNotificationSender;
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
} }
export interface TestPaseoDaemon { export interface TestPaseoDaemon {
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
appBaseUrl: "https://app.paseo.sh", appBaseUrl: "https://app.paseo.sh",
auth: options.auth, auth: options.auth,
pushNotificationSender: options.pushNotificationSender, pushNotificationSender: options.pushNotificationSender,
serviceProxy: options.serviceProxy,
openai: options.openai, openai: options.openai,
speech: options.speech, speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProvider: options.voiceLlmProvider ?? null,
@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
return createStub<SessionOptions["workspaceGitService"]>(stub); return createStub<SessionOptions["workspaceGitService"]>(stub);
} }
export function asScriptRouteStore(stub: { export function asServiceProxy(stub: {
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown; [K in keyof SessionOptions["serviceProxy"]]?: unknown;
}): SessionOptions["scriptRouteStore"] { }): SessionOptions["serviceProxy"] {
return createStub<SessionOptions["scriptRouteStore"]>(stub); return createStub<SessionOptions["serviceProxy"]>(stub);
} }
export function asWorkspaceScriptRuntimeStore(stub: { export function asWorkspaceScriptRuntimeStore(stub: {
@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -357,7 +357,7 @@ export class VoiceAssistantWebSocketServer {
private readonly mcpBaseUrl: string | null; private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null; private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null; private terminalManager!: TerminalManager | null;
private scriptRouteStore!: ScriptRouteStore | null; private serviceProxy!: ServiceProxySubsystem | null;
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null; private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
private getDaemonTcpPort!: (() => number | null) | null; private getDaemonTcpPort!: (() => number | null) | null;
private getDaemonTcpHost!: (() => string | null) | null; private getDaemonTcpHost!: (() => string | null) | null;
@@ -405,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService, loopService?: LoopService,
scheduleService?: ScheduleService, scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager, checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null, serviceProxy?: ServiceProxySubsystem | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null, scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
@@ -465,7 +465,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager, terminalManager,
dictation, dictation,
onLifecycleIntent, onLifecycleIntent,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
onBranchChanged, onBranchChanged,
getDaemonTcpPort, getDaemonTcpPort,
@@ -514,7 +514,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: TerminalManager | null | undefined; terminalManager: TerminalManager | null | undefined;
dictation: { finalTimeoutMs?: number } | undefined; dictation: { finalTimeoutMs?: number } | undefined;
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined; onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
scriptRouteStore: ScriptRouteStore | null | undefined; serviceProxy: ServiceProxySubsystem | null | undefined;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
onBranchChanged: onBranchChanged:
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
@@ -528,7 +528,7 @@ export class VoiceAssistantWebSocketServer {
this.terminalManager = params.terminalManager ?? null; this.terminalManager = params.terminalManager ?? null;
this.dictation = params.dictation ?? null; this.dictation = params.dictation ?? null;
this.onLifecycleIntent = params.onLifecycleIntent ?? null; this.onLifecycleIntent = params.onLifecycleIntent ?? null;
this.scriptRouteStore = params.scriptRouteStore ?? null; this.serviceProxy = params.serviceProxy ?? null;
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null; this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
this.onBranchChanged = params.onBranchChanged ?? null; this.onBranchChanged = params.onBranchChanged ?? null;
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null; this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
@@ -884,7 +884,7 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null, tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager, providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined, serviceProxy: this.serviceProxy ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
workspaceSetupSnapshots: this.workspaceSetupSnapshots, workspaceSetupSnapshots: this.workspaceSetupSnapshots,
onBranchChanged: this.onBranchChanged ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined,
@@ -0,0 +1,153 @@
import { describe, expect, test } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../test-utils/test-logger.js";
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
import { WorkspaceDirectory } from "./workspace-directory.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
const NOW = "2026-03-01T12:00:00.000Z";
class WorkspaceStatus {
private readonly project: PersistedProjectRecord = {
projectId: "project-1",
rootPath: "/workspace/project",
kind: "git",
displayName: "project",
customName: null,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
private readonly workspace: PersistedWorkspaceRecord = {
workspaceId: "workspace-1",
projectId: this.project.projectId,
cwd: this.project.rootPath,
kind: "local_checkout",
displayName: "main",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
private readonly agents: AgentSnapshotPayload[] = [];
private readonly directory = new WorkspaceDirectory({
logger: createTestLogger(),
projectRegistry: { list: async () => [this.project] },
workspaceRegistry: { list: async () => [this.workspace] },
listAgentPayloads: async () => this.agents,
isProviderVisibleToClient: () => true,
buildWorkspaceDescriptor: async ({ workspace }) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: "project",
projectCustomName: null,
projectRootPath: this.project.rootPath,
workspaceDirectory: workspace.cwd,
projectKind: "git",
workspaceKind: workspace.kind,
name: workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: null,
githubRuntime: null,
}),
});
hasRootAgent(input: AgentState): void {
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
}
hasDelegatedAgent(input: AgentState): void {
this.agents.push(
createAgent({
...input,
cwd: this.workspace.cwd,
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
}),
);
}
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
const entries = await this.directory.listFetchEntries({
type: "fetch_workspaces_request",
requestId: "workspace-status",
});
return entries.entries[0]?.status ?? "done";
}
}
interface AgentState {
id: string;
status: AgentSnapshotPayload["status"];
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentSnapshotPayload["attentionReason"];
}
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: NOW,
updatedAt: NOW,
lastUserMessageAt: null,
status: input.status,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
id: `permission-${input.id}-${index}`,
provider: "codex",
name: "tool",
kind: "tool" as const,
})),
persistence: null,
runtimeInfo: {
provider: "codex",
sessionId: null,
},
title: null,
labels: input.labels ?? {},
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: null,
archivedAt: null,
} satisfies AgentSnapshotPayload;
}
describe("WorkspaceDirectory", () => {
test("uses root agent activity, not delegated child activity, for workspace status", async () => {
const workspace = new WorkspaceStatus();
workspace.hasRootAgent({ id: "root-agent", status: "running" });
workspace.hasDelegatedAgent({
id: "child-needs-input",
status: "idle",
pendingPermissionCount: 1,
});
workspace.hasDelegatedAgent({
id: "child-error",
status: "error",
requiresAttention: true,
attentionReason: "error",
});
await expect(workspace.workspaceStatus()).resolves.toBe("running");
});
});
@@ -11,6 +11,7 @@ import {
deriveAgentStateBucket, deriveAgentStateBucket,
getWorkspaceStateBucketPriority, getWorkspaceStateBucketPriority,
} from "@getpaseo/protocol/agent-state-bucket"; } from "@getpaseo/protocol/agent-state-bucket";
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
import { SortablePager } from "./pagination/sortable-pager.js"; import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js";
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
if (!this.deps.isProviderVisibleToClient(agent.provider)) { if (!this.deps.isProviderVisibleToClient(agent.provider)) {
continue; continue;
} }
if (isDelegatedAgent(agent)) {
continue;
}
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)); const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
if (workspaceId === undefined) { if (workspaceId === undefined) {
@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", PASEO_PORT: "5173",
PASEO_URL: "http://daemon.paseo.localhost:6767", PASEO_URL: "http://daemon--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173", PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767", PASEO_SERVICE_DAEMON_URL: "http://daemon--paseo.localhost:6767",
}); });
}); });
@@ -84,9 +84,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", PASEO_PORT: "5173",
PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767", PASEO_URL: "http://daemon--feature-x--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173", PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767", PASEO_SERVICE_DAEMON_URL: "http://daemon--feature-x--paseo.localhost:6767",
}); });
}); });
@@ -137,11 +137,32 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", PASEO_PORT: "5173",
PASEO_URL: "http://web.feature-x.paseo.localhost:6767", PASEO_URL: "http://web--feature-x--paseo.localhost:6767",
PASEO_SERVICE_API_PORT: "4000", PASEO_SERVICE_API_PORT: "4000",
PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767", PASEO_SERVICE_API_URL: "http://api--feature-x--paseo.localhost:6767",
PASEO_SERVICE_WEB_PORT: "5173", PASEO_SERVICE_WEB_PORT: "5173",
PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767", PASEO_SERVICE_WEB_URL: "http://web--feature-x--paseo.localhost:6767",
});
});
it("uses public service URLs when a public base URL is configured", () => {
expect(
buildWorkspaceServiceEnv({
scriptName: "web",
projectSlug: "paseo",
branchName: "feature-x",
daemonPort: 6767,
daemonListenHost: null,
serviceProxyPublicBaseUrl: "https://services.example.com",
peers: [
{ scriptName: "api", port: 4000 },
{ scriptName: "web", port: 5173 },
],
}),
).toMatchObject({
PASEO_URL: "https://web--feature-x--paseo.services.example.com",
PASEO_SERVICE_API_URL: "https://api--feature-x--paseo.services.example.com",
PASEO_SERVICE_WEB_URL: "https://web--feature-x--paseo.services.example.com",
}); });
}); });
@@ -1,4 +1,4 @@
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js"; import { projectServiceProxyUrls } from "./service-proxy.js";
export interface WorkspaceServicePeer { export interface WorkspaceServicePeer {
scriptName: string; scriptName: string;
@@ -81,25 +81,13 @@ interface BuildServiceProxyUrlOptions {
} }
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null { function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
if (options.serviceProxyPublicBaseUrl) { return projectServiceProxyUrls({
return buildPublicScriptProxyUrl({
projectSlug: options.projectSlug, projectSlug: options.projectSlug,
branchName: options.branchName, branchName: options.branchName,
scriptName: options.scriptName, scriptName: options.scriptName,
daemonPort: options.daemonPort,
publicBaseUrl: options.serviceProxyPublicBaseUrl, publicBaseUrl: options.serviceProxyPublicBaseUrl,
}); }).proxyUrl;
}
if (options.daemonPort === null || options.daemonPort === undefined) {
return null;
}
const hostname = buildScriptHostname({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
});
return `http://${hostname}:${options.daemonPort}`;
} }
function isLoopbackListenHost(host: string | null | undefined): boolean { function isLoopbackListenHost(host: string | null | undefined): boolean {
@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
branchName: "feature-peer-env", branchName: "feature-peer-env",
scriptName, scriptName,
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}), }),
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
const apiEnv = readEnvFile(apiEnvPath); const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath); const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); "http://api--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/)); expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/)); expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL); expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("PORT"); expect(apiEnv).not.toHaveProperty("PORT");
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); expect(webEnv.PASEO_SERVICE_API_URL).toBe(
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); "http://api--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
@@ -487,14 +495,14 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
expect(Number.isInteger(webPort)).toBe(true); expect(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.feature-peer-env.repo.localhost", hostname: "api--feature-peer-env--repo.localhost",
port: apiPort, port: apiPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
scriptName: "api", scriptName: "api",
}, },
{ {
hostname: "web.feature-peer-env.repo.localhost", hostname: "web--feature-peer-env--repo.localhost",
port: webPort, port: webPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767", "http://api--feature-socket-service--repo.localhost:6767",
); );
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe( expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
createTerminalCalls[0]?.env?.PASEO_PORT, createTerminalCalls[0]?.env?.PASEO_PORT,
); );
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767", "http://api--feature-socket-service--repo.localhost:6767",
); );
} }
@@ -450,7 +450,7 @@ describe("runAsyncWorktreeBootstrap", () => {
String(plannedAppServerPort), String(plannedAppServerPort),
); );
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"http://app-server.feature-socket-service.repo.localhost:6767", "http://app-server--feature-socket-service--repo.localhost:6767",
); );
} }
@@ -497,7 +497,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service", branchName: "feature-socket-service",
scriptName: "web", scriptName: "web",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-exit", branchName: "feature-script-exit",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun", branchName: "feature-script-rerun",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun", branchName: "feature-script-rerun",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-existing-terminal", branchName: "feature-script-existing-terminal",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-terminal-exit", branchName: "feature-script-terminal-exit",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate", branchName: "feature-script-duplicate",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate", branchName: "feature-script-duplicate",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}), }),
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service", branchName: "feature-socket-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(result.scriptName).toBe("api"); expect(result.scriptName).toBe("api");
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.feature-socket-service.repo.localhost", hostname: "api--feature-socket-service--repo.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -813,6 +813,60 @@ describe("runAsyncWorktreeBootstrap", () => {
}); });
}); });
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
api: {
type: "service",
command: "npm run api",
},
"app-server": {
type: "service",
command: "npm run app",
},
},
"add public service script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-public-service",
scriptName: "api",
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
expect(result.hostname).toBe("api--feature-public-service--repo.localhost");
expect(
routeStore.getRouteEntry("api--feature-public-service--repo.services.example.com"),
).toMatchObject({
hostname: "api--feature-public-service--repo.localhost",
publicHostname: "api--feature-public-service--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
workspaceId: repoDir,
scriptName: "api",
});
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"https://app-server--feature-public-service--repo.services.example.com",
);
});
it("refreshes a stopped service port on respawn and updates the route", async () => { it("refreshes a stopped service port on respawn and updates the route", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "worker", scriptName: "worker",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
lifecycle: "stopped", lifecycle: "stopped",
exitCode: 0, exitCode: 0,
}); });
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull(); expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toBeNull();
const secondResult = await spawnWorkspaceScript({ const secondResult = await spawnWorkspaceScript({
repoRoot: repoDir, repoRoot: repoDir,
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(secondPort).not.toBe(firstPort); expect(secondPort).not.toBe(firstPort);
expect(secondPort).toEqual(expect.any(Number)); expect(secondPort).toEqual(expect.any(Number));
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort)); expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({ expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
hostname: "api.feature-respawn-service.repo.localhost", hostname: "api--feature-respawn-service--repo.localhost",
port: secondPort, port: secondPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-before-rename", branchName: "feature-before-rename",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
const updateRoutesForBranchChange = createBranchChangeRouteHandler({ const updateRoutesForBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged: () => {}, onRoutesChanged: () => {},
}); });
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename"); updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([ expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
expect.objectContaining({ expect.objectContaining({
hostname: "api.feature-after-rename.repo.localhost", hostname: "api--feature-after-rename--repo.localhost",
scriptName: "api", scriptName: "api",
}), }),
]); ]);
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service", branchName: "feature-collision-service",
scriptName: "app-server", scriptName: "app-server",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}), }),
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(0); expect(createTerminalCalls).toHaveLength(0);
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([]);
expect( expect(
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"), routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
).toBeNull(); ).toBeNull();
writeFileSync( writeFileSync(
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service", branchName: "feature-collision-service",
scriptName: "app-server", scriptName: "app-server",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}); });
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
scriptName: "web", scriptName: "web",
daemonPort: 6767, daemonPort: 6767,
daemonListenHost: "100.64.0.20", daemonListenHost: "100.64.0.20",
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}); });
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0"); expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://web.feature-remote-service.repo.localhost:6767", "http://web--feature-remote-service--repo.localhost:6767",
); );
}); });
}); });
@@ -2,7 +2,6 @@ import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import { import {
getScriptConfigs, getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
@@ -17,7 +16,7 @@ import {
type WorktreeSetupCommandResult, type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv, type WorktreeRuntimeEnv,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js"; import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
import { import {
@@ -700,7 +699,7 @@ interface SpawnWorkspaceScriptOptions {
daemonPort?: number | null; daemonPort?: number | null;
daemonListenHost?: string | null; daemonListenHost?: string | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
@@ -724,7 +723,7 @@ async function setupServiceScriptRoute(params: {
daemonListenHost: string | null | undefined; daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl: string | null | undefined; serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>; existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
}): Promise<ServiceScriptSetupResult> { }): Promise<ServiceScriptSetupResult> {
const { const {
scriptConfigs, scriptConfigs,
@@ -737,17 +736,8 @@ async function setupServiceScriptRoute(params: {
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
existingRuntimeEntry, existingRuntimeEntry,
routeStore, serviceProxy,
} = params; } = params;
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
const publicHostname = serviceProxyPublicBaseUrl
? buildPublicScriptHostname({
projectSlug,
branchName,
scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl,
})
: null;
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
for (const [configuredScriptName, scriptConfig] of scriptConfigs) { for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
@@ -794,16 +784,15 @@ async function setupServiceScriptRoute(params: {
peers, peers,
}); });
routeStore.registerRoute({ const registeredRoute = serviceProxy.registerWorkspaceService({
hostname,
publicHostname,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
port, port,
workspaceId, workspaceId,
projectSlug, projectSlug,
branchName,
scriptName, scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
}); });
return { hostname, port, env }; return { hostname: registeredRoute.hostname, port, env };
} }
async function acquireWorkspaceScriptTerminal(params: { async function acquireWorkspaceScriptTerminal(params: {
@@ -843,7 +832,7 @@ export async function spawnWorkspaceScript(
daemonPort, daemonPort,
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
routeStore, serviceProxy,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
logger, logger,
@@ -886,7 +875,7 @@ export async function spawnWorkspaceScript(
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
existingRuntimeEntry, existingRuntimeEntry,
routeStore, serviceProxy,
}); });
hostname = serviceSetup.hostname; hostname = serviceSetup.hostname;
port = serviceSetup.port; port = serviceSetup.port;
@@ -923,7 +912,7 @@ export async function spawnWorkspaceScript(
disposeLifecycleListeners = null; disposeLifecycleListeners = null;
if (input.removeRoute && hostname) { if (input.removeRoute && hostname) {
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName }); serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
} }
runtimeStore.set({ runtimeStore.set({
workspaceId, workspaceId,
@@ -991,7 +980,7 @@ export async function spawnWorkspaceScript(
} catch (error) { } catch (error) {
disposeLifecycleListeners?.(); disposeLifecycleListeners?.();
if (routeRegistered && hostname) { if (routeRegistered && hostname) {
routeStore.removeRoute(hostname); serviceProxy.removeServiceRoutesByHostnames([hostname]);
} }
if (runtimeRegistered) { if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName }); runtimeStore.remove({ workspaceId, scriptName });
@@ -1014,12 +1003,12 @@ export async function spawnWorkspaceScript(
export function teardownWorktreeScripts(options: { export function teardownWorktreeScripts(options: {
hostnames: string[]; hostnames: string[];
routeStore: ScriptRouteStore; serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
logger: Logger; logger: Logger;
}): void { }): void {
const { hostnames, routeStore, logger } = options; const { hostnames, serviceProxy, logger } = options;
serviceProxy.removeServiceRoutesByHostnames(hostnames);
for (const hostname of hostnames) { for (const hostname of hostnames) {
routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed script proxy route"); logger.info({ hostname }, "Removed script proxy route");
} }
} }
@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
sessionLogger: createLogger(), sessionLogger: createLogger(),
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord: async () => {}, archiveWorkspaceRecord: async () => {},
scriptRouteStore: null, serviceProxy: null,
scriptRuntimeStore: null, scriptRuntimeStore: null,
getDaemonTcpPort: null, getDaemonTcpPort: null,
getDaemonTcpHost: null, getDaemonTcpHost: null,
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
sessionLogger: createLogger(), sessionLogger: createLogger(),
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord: async () => {}, archiveWorkspaceRecord: async () => {},
scriptRouteStore: null, serviceProxy: null,
scriptRuntimeStore: null, scriptRuntimeStore: null,
getDaemonTcpPort: null, getDaemonTcpPort: null,
getDaemonTcpHost: null, getDaemonTcpHost: null,
@@ -20,7 +20,7 @@ import {
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
@@ -103,7 +103,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null; serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null; getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null; getDaemonTcpHost: (() => string | null) | null;
@@ -13,7 +13,7 @@ describe("buildScriptHostname", () => {
branchName: null, branchName: null,
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
}); });
it("omits the branch label for main and master", () => { it("omits the branch label for main and master", () => {
@@ -23,14 +23,14 @@ describe("buildScriptHostname", () => {
branchName: "main", branchName: "main",
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
expect( expect(
buildScriptHostname({ buildScriptHostname({
projectSlug: "paseo", projectSlug: "paseo",
branchName: "master", branchName: "master",
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
}); });
it("builds non-default branch hostnames with script, branch, and project labels", () => { it("builds non-default branch hostnames with script, branch, and project labels", () => {
@@ -40,7 +40,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth", branchName: "feature-auth",
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.feature-auth.paseo.localhost"); ).toBe("web--feature-auth--paseo.localhost");
}); });
it("slugifies script, default branch project, and non-default branch labels", () => { it("slugifies script, default branch project, and non-default branch labels", () => {
@@ -50,7 +50,7 @@ describe("buildScriptHostname", () => {
branchName: "Feature/Auth Flow", branchName: "Feature/Auth Flow",
scriptName: "Web/API @ Dev", scriptName: "Web/API @ Dev",
}), }),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost"); ).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
}); });
it("accepts already slugified labels because slugify is idempotent", () => { it("accepts already slugified labels because slugify is idempotent", () => {
@@ -60,7 +60,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth-flow", branchName: "feature-auth-flow",
scriptName: "web-api-dev", scriptName: "web-api-dev",
}), }),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost"); ).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
}); });
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => { it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
@@ -70,7 +70,7 @@ describe("buildScriptHostname", () => {
branchName: "***", branchName: "***",
scriptName: "---", scriptName: "---",
}), }),
).toBe("untitled.untitled.untitled.localhost"); ).toBe("untitled--untitled--untitled.localhost");
}); });
}); });
+9 -48
View File
@@ -1,6 +1,10 @@
import { slugify } from "./worktree.js"; import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildPublicServiceProxyUrl,
} from "../server/service-proxy.js";
const MAX_DNS_LABEL_LENGTH = 63; // Compatibility boundary for older tests/imports; new service proxy code owns hostname rules.
interface BuildScriptHostnameOptions { interface BuildScriptHostnameOptions {
projectSlug: string; projectSlug: string;
@@ -12,64 +16,21 @@ interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
publicBaseUrl: string; 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({ export function buildScriptHostname({
projectSlug, projectSlug,
branchName, branchName,
scriptName, scriptName,
}: BuildScriptHostnameOptions): string { }: BuildScriptHostnameOptions): string {
const serviceHostnameLabel = toHostnameLabel(scriptName); return buildLocalServiceHostname({ projectSlug, branchName, scriptName });
const projectHostnameLabel = toHostnameLabel(projectSlug);
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (isDefaultBranch) {
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
}
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
} }
export function buildPublicScriptHostname({ export function buildPublicScriptHostname({
publicBaseUrl, publicBaseUrl,
...script ...script
}: BuildPublicScriptHostnameOptions): string { }: BuildPublicScriptHostnameOptions): string {
const base = new URL(publicBaseUrl); return buildPublicServiceHostname({ publicBaseUrl, ...script });
return `${toPublicServiceLabel(script)}.${base.hostname}`;
} }
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string { export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
const base = new URL(options.publicBaseUrl); return buildPublicServiceProxyUrl(options);
const hostname = buildPublicScriptHostname(options);
const port = base.port ? `:${base.port}` : "";
return `${base.protocol}//${hostname}${port}`;
} }
@@ -97,12 +97,15 @@
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
"type": "boolean" "type": "boolean"
}, },
"listen": { "listen": {
"description": "Optional service-only listener address. Presence enables the standalone service proxy listener.",
"type": "string" "type": "string"
}, },
"publicBaseUrl": { "publicBaseUrl": {
"description": "Optional public base URL for service host aliases.",
"type": "string", "type": "string",
"format": "uri" "format": "uri"
} }
+5 -5
View File
@@ -110,10 +110,10 @@ Omit `port` to let Paseo auto-assign one. Bind your process to `$PASEO_PORT` rat
Every service is reachable through the daemon at a deterministic hostname: Every service is reachable through the daemon at a deterministic hostname:
``` ```
http://<script>.<branch>.<project>.localhost:<daemon-port> http://<script>--<branch>--<project>.localhost:<daemon-port>
# on the default branch, the branch label is dropped: # on the default branch, the branch label is dropped:
http://<script>.<project>.localhost:<daemon-port> http://<script>--<project>.localhost:<daemon-port>
``` ```
`*.localhost` resolves to `127.0.0.1` on modern systems, so these URLs work out of the box. The proxy supports WebSocket upgrades. `*.localhost` resolves to `127.0.0.1` on modern systems, so these URLs work out of the box. The proxy supports WebSocket upgrades.
@@ -124,11 +124,11 @@ Services launched from the same workspace see each other's ports and proxy URLs.
``` ```
PASEO_PORT=3000 # this service's port PASEO_PORT=3000 # this service's port
PASEO_URL=http://web.my-app.localhost:6767 # this service's proxy URL PASEO_URL=http://web--my-app.localhost:6767 # this service's proxy URL
PASEO_SERVICE_API_PORT=51732 PASEO_SERVICE_API_PORT=51732
PASEO_SERVICE_API_URL=http://api.my-app.localhost:6767 PASEO_SERVICE_API_URL=http://api--my-app.localhost:6767
PASEO_SERVICE_WEB_PORT=3000 PASEO_SERVICE_WEB_PORT=3000
PASEO_SERVICE_WEB_URL=http://web.my-app.localhost:6767 PASEO_SERVICE_WEB_URL=http://web--my-app.localhost:6767
``` ```
Script names are upper-cased and non-alphanumerics become `_`. Point your frontend at `$PASEO_SERVICE_API_URL` instead of hard-coding a port. Script names are upper-cased and non-alphanumerics become `_`. Point your frontend at `$PASEO_SERVICE_API_URL` instead of hard-coding a port.