From 3ded2a0ed5ea391cde60273387a41017b3cb080a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 16:09:11 +0000 Subject: [PATCH] feat(runtime): default managed worktree runtimes to Tailscale HTTPS (PAP-17158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPS was opt-in, so the UI could still advertise `http://paperclip-dev:` whenever a project template or UI caller forgot the `expose` block. The start path now decides for itself: deliberate opt-out wins, then an explicit opt-in, then the automatic default for an eligible Paperclip-managed worktree runtime on a broker-capable host, then nothing. `PAPERCLIP_MANAGED_RUNTIME_HTTPS` gates the default (`auto` | `off` | `force`). Under `auto` the default is skipped when the broker socket is absent, so an install without the host broker keeps working; an *explicit* opt-in is never gated that way, because a missing broker must fail the start rather than quietly downgrade a requested HTTPS preview to HTTP. Two things the real pre-feature config forced out into the open: - The persisted Paperclip App template pins `port: 45439`, which the broker's dedicated allowlist can never publish. So an exposed runtime now treats a configured or previously used port as a *preference*: kept when it is already an allowlisted app port with a free HMR companion (stable across restarts and deploys), relocated when it is not. - The cross-workspace port-conflict guard checked the *pinned* port even when exposure had relocated the service off it. That rejected a legacy `45439` service purely because the pre-backfill instance still held 45439 — i.e. it would have blocked the one workspace this feature exists to upgrade. It now guards the port the service actually binds. Covered: default on a legacy no-exposure service incl. port relocation, port preservation inside the range, opt-out preserved on HTTP, unmanaged service untouched, broker-absent skip, explicit opt-in still failing loudly without a broker, and repeat-start idempotence landing on the same port pair. --- .../runtime-exposure/port-pair.test.ts | 44 ++++ .../services/runtime-exposure/port-pair.ts | 43 +++- .../workspace-runtime-exposure.test.ts | 196 +++++++++++++++--- server/src/services/workspace-runtime.ts | 188 +++++++++++++++-- 4 files changed, 419 insertions(+), 52 deletions(-) diff --git a/server/src/services/runtime-exposure/port-pair.test.ts b/server/src/services/runtime-exposure/port-pair.test.ts index b95ad662809..eabe9223d19 100644 --- a/server/src/services/runtime-exposure/port-pair.test.ts +++ b/server/src/services/runtime-exposure/port-pair.test.ts @@ -44,6 +44,50 @@ describe("allocateExposurePortPair", () => { expect(pair.appPort).toBe(RUNTIME_EXPOSURE_APP_PORT_MIN + 2); }); + describe("preferredAppPort (keep existing runtime ports when safe)", () => { + it("keeps a preferred in-range port instead of restarting the scan", async () => { + const pair = await allocateExposurePortPair({ + isPortAvailable: async () => true, + preferredAppPort: 42_500, + }); + expect(pair.appPort).toBe(42_500); + expect(pair.hmrPort).toBe(42_500 + RUNTIME_EXPOSURE_HMR_PORT_OFFSET); + }); + + it("ignores a legacy pinned port outside the dedicated range", async () => { + // 45439 is the pre-feature Paperclip App port; the broker can never + // publish it, so the allocator must relocate rather than fail. + const pair = await allocateExposurePortPair({ + isPortAvailable: async () => true, + preferredAppPort: 45_439, + }); + expect(pair.appPort).toBe(RUNTIME_EXPOSURE_APP_PORT_MIN); + }); + + it("falls back to the scan when the preferred port is busy or reserved", async () => { + const busy = await allocateExposurePortPair({ + isPortAvailable: async (port) => port !== 42_500, + preferredAppPort: 42_500, + }); + expect(busy.appPort).toBe(RUNTIME_EXPOSURE_APP_PORT_MIN); + + const reservedCompanion = await allocateExposurePortPair({ + isPortAvailable: async () => true, + reserved: new Set([42_500 + RUNTIME_EXPOSURE_HMR_PORT_OFFSET]), + preferredAppPort: 42_500, + }); + expect(reservedCompanion.appPort).toBe(RUNTIME_EXPOSURE_APP_PORT_MIN); + }); + + it("ignores a preferred HMR-range port (never an app port)", async () => { + const pair = await allocateExposurePortPair({ + isPortAvailable: async () => true, + preferredAppPort: RUNTIME_EXPOSURE_APP_PORT_MIN + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + }); + expect(pair.appPort).toBe(RUNTIME_EXPOSURE_APP_PORT_MIN); + }); + }); + it("throws when the dedicated range is exhausted", async () => { await expect( allocateExposurePortPair({ isPortAvailable: async () => false }), diff --git a/server/src/services/runtime-exposure/port-pair.ts b/server/src/services/runtime-exposure/port-pair.ts index db6324a3006..244224c9f75 100644 --- a/server/src/services/runtime-exposure/port-pair.ts +++ b/server/src/services/runtime-exposure/port-pair.ts @@ -35,27 +35,50 @@ export interface AllocateExposurePortPairInput { * cleanup, or already reserved by other live runtimes this cycle. */ reserved?: ReadonlySet; + /** + * The port this runtime is already using, if any. Tried first so a restart or + * a backfilled service keeps its port instead of drifting across the range on + * every deploy ("keep existing runtime ports when safe", PAP-17158). + * + * Only honored when it is genuinely safe: the port must be an allowlisted app + * port, unreserved, and free together with its HMR companion. A legacy pinned + * port outside the dedicated range (the Paperclip App template's 45439) can + * never be published by the broker, so it is ignored here rather than making + * the caller special-case it. + */ + preferredAppPort?: number | null; } /** - * Scan the dedicated app-port range in ascending order and return the first - * app port whose HMR companion is also free and unreserved. Throws when the - * range is exhausted so the caller fails closed rather than binding an - * out-of-range port. + * Return an app port whose HMR companion is also free and unreserved: the + * preferred port when it is eligible, otherwise the first such port scanning the + * dedicated range in ascending order. Throws when the range is exhausted so the + * caller fails closed rather than binding an out-of-range port. */ export async function allocateExposurePortPair( input: AllocateExposurePortPairInput, ): Promise { const reserved = input.reserved ?? new Set(); - for (let appPort = RUNTIME_EXPOSURE_APP_PORT_MIN; appPort <= RUNTIME_EXPOSURE_APP_PORT_MAX; appPort += 1) { - if (reserved.has(appPort)) continue; + + const claimIfFree = async (appPort: number): Promise => { + if (reserved.has(appPort)) return null; + if (!isRuntimeExposureAppPort(appPort)) return null; const hmrPort = deriveViteHmrPort(appPort); - if (reserved.has(hmrPort)) continue; - if (!isRuntimeExposureAppPort(appPort)) continue; // defensive; range guarantees it + if (reserved.has(hmrPort)) return null; // Probe the app port first; short-circuit before probing the companion. - if (!(await input.isPortAvailable(appPort))) continue; - if (!(await input.isPortAvailable(hmrPort))) continue; + if (!(await input.isPortAvailable(appPort))) return null; + if (!(await input.isPortAvailable(hmrPort))) return null; return { appPort, hmrPort }; + }; + + if (input.preferredAppPort != null) { + const preferred = await claimIfFree(input.preferredAppPort); + if (preferred) return preferred; + } + + for (let appPort = RUNTIME_EXPOSURE_APP_PORT_MIN; appPort <= RUNTIME_EXPOSURE_APP_PORT_MAX; appPort += 1) { + const pair = await claimIfFree(appPort); + if (pair) return pair; } throw new Error( `no free app/HMR port pair available in the dedicated runtime exposure range ` + diff --git a/server/src/services/workspace-runtime-exposure.test.ts b/server/src/services/workspace-runtime-exposure.test.ts index 518a5a5768f..c49a979ff45 100644 --- a/server/src/services/workspace-runtime-exposure.test.ts +++ b/server/src/services/workspace-runtime-exposure.test.ts @@ -43,7 +43,46 @@ function createBroker() { return { broker, calls }; } -function startInput() { +function installDeps(overrides: { + broker: BrokerClient; + probeHealth?: () => Promise; + isBrokerAvailable?: () => Promise; + isPortAvailable?: (port: number) => Promise; +}) { + setWorkspaceRuntimeExposureDepsForTests({ + broker: overrides.broker, + isPortAvailable: overrides.isPortAvailable ?? (async () => true), + isBrokerAvailable: overrides.isBrokerAvailable ?? (async () => true), + resolveHostname: async () => "runner.tail123.ts.net", + probeHealth: overrides.probeHealth ?? (async () => true), + now: () => "2026-08-11T00:00:00.000Z", + }); +} + +const DECLARED_EXPOSE = { + type: "tailscale_https", + hostname: "auto", + publicPort: "same", + includePaperclipViteHmr: true, + failurePolicy: "fail_closed", +} as const; + +/** + * The pre-feature Paperclip App project template, verbatim: a hard-coded HTTP + * `urlTemplate`, a pinned port outside the broker's dedicated range, and no + * exposure declaration at all. + */ +const LEGACY_HTTP_EXPOSE = { + type: "url", + urlTemplate: "http://paperclip-dev:{{port}}", +} as const; + +function startInput(options?: { + serviceName?: string; + expose?: Record | null; + port?: Record | number; +}) { + const expose = options?.expose === undefined ? DECLARED_EXPOSE : options.expose; return { invocationId: "runtime-exposure-test", actor: { id: null, name: "Paperclip", companyId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" }, @@ -66,17 +105,11 @@ function startInput() { config: { workspaceRuntime: { services: [{ - name: "preview", + name: options?.serviceName ?? "preview", command: serviceCommand(), - port: { type: "auto", envKey: "PORT" }, + port: options?.port ?? { type: "auto", envKey: "PORT" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 5 }, - expose: { - type: "tailscale_https", - hostname: "auto", - publicPort: "same", - includePaperclipViteHmr: true, - failurePolicy: "fail_closed", - }, + ...(expose ? { expose } : {}), }], }, }, @@ -87,13 +120,7 @@ function startInput() { describe("workspace runtime tailscale_https lifecycle", () => { it("reserves before spawn, exposes after backend readiness, and removes on stop", async () => { const { broker, calls } = createBroker(); - setWorkspaceRuntimeExposureDepsForTests({ - broker, - isPortAvailable: async () => true, - resolveHostname: async () => "runner.tail123.ts.net", - probeHealth: async () => true, - now: () => "2026-08-11T00:00:00.000Z", - }); + installDeps({ broker }); const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput()); expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]); @@ -110,15 +137,136 @@ describe("workspace runtime tailscale_https lifecycle", () => { it("fails closed and removes the mapping when external HTTPS validation fails", async () => { const { broker, calls } = createBroker(); - setWorkspaceRuntimeExposureDepsForTests({ - broker, - isPortAvailable: async () => true, - resolveHostname: async () => "runner.tail123.ts.net", - probeHealth: async () => false, - now: () => "2026-08-11T00:00:00.000Z", - }); + installDeps({ broker, probeHealth: async () => false }); await expect(startRuntimeServicesForWorkspaceControl(startInput())).rejects.toThrow(/HTTPS exposure failed/); expect(calls).toEqual(["reserve", "expose", "remove"]); }, 15_000); }); + +describe("automatic tailscale_https default for managed worktree runtimes", () => { + it("defaults a legacy paperclip-dev service with no exposure block, relocating its pinned port", async () => { + const { broker, calls } = createBroker(); + installDeps({ broker }); + + // Exactly the persisted pre-feature shape: pinned 45439 + HTTP urlTemplate. + const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: LEGACY_HTTP_EXPOSE, + port: 45_439, + })); + + expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]); + // 45439 is outside the broker allowlist, so the pinned port cannot be kept. + expect(runtime.port).not.toBe(45_439); + expect(runtime.port).toBeGreaterThanOrEqual(42_000); + expect(runtime.port).toBeLessThanOrEqual(42_999); + // The canonical URL is the verified HTTPS origin; HTTP is never retained. + expect(runtime.url).toBe(`https://runner.tail123.ts.net:${runtime.port}`); + expect(runtime.url).not.toContain("http://"); + expect(runtime.exposure?.state).toBe("ready"); + }, 15_000); + + it("keeps an existing runtime port that is already inside the dedicated range", async () => { + const { broker } = createBroker(); + installDeps({ broker }); + + const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: LEGACY_HTTP_EXPOSE, + port: 42_500, + })); + + expect(runtime.port).toBe(42_500); + expect(runtime.url).toBe("https://runner.tail123.ts.net:42500"); + }, 15_000); + + it("preserves a deliberate opt-out and leaves the service on plain HTTP", async () => { + const { broker, calls } = createBroker(); + installDeps({ broker }); + + const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: { ...LEGACY_HTTP_EXPOSE, tailscaleHttps: false }, + port: { type: "auto", envKey: "PORT" }, + })); + + expect(calls).toEqual([]); + expect(runtime.exposure ?? null).toBeNull(); + expect(runtime.url).toBe(`http://paperclip-dev:${runtime.port}`); + }, 15_000); + + it("leaves an unmanaged/custom service untouched", async () => { + const { broker, calls } = createBroker(); + installDeps({ broker }); + + const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "preview", + expose: LEGACY_HTTP_EXPOSE, + port: { type: "auto", envKey: "PORT" }, + })); + + expect(calls).toEqual([]); + expect(runtime.exposure ?? null).toBeNull(); + expect(runtime.url).toBe(`http://paperclip-dev:${runtime.port}`); + }, 15_000); + + it("does not default when the host broker is unavailable", async () => { + const { broker, calls } = createBroker(); + installDeps({ broker, isBrokerAvailable: async () => false }); + + const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: LEGACY_HTTP_EXPOSE, + port: { type: "auto", envKey: "PORT" }, + })); + + expect(calls).toEqual([]); + expect(runtime.exposure ?? null).toBeNull(); + }, 15_000); + + it("still honors an explicit opt-in when the broker socket is missing, failing loudly", async () => { + // A missing broker must never silently downgrade a requested HTTPS preview. + const { broker, calls } = createBroker(); + const failing: BrokerClient = { + ...broker, + async reserve() { + calls.push("reserve"); + throw new Error("ENOENT: broker socket missing"); + }, + }; + installDeps({ broker: failing, isBrokerAvailable: async () => false }); + + await expect( + startRuntimeServicesForWorkspaceControl(startInput({ serviceName: "paperclip-dev" })), + ).rejects.toThrow(); + expect(calls).toEqual(["reserve"]); + }, 15_000); + + it("is idempotent across repeated starts: the same port pair is reserved again", async () => { + const { broker, calls } = createBroker(); + installDeps({ broker }); + + const [first] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: LEGACY_HTTP_EXPOSE, + port: 45_439, + })); + const firstPort = first.port; + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + runtimeServiceId: first.id, + }); + calls.length = 0; + + const [second] = await startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + expose: LEGACY_HTTP_EXPOSE, + port: 45_439, + })); + + expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]); + expect(second.port).toBe(firstPort); + expect(second.url).toBe(`https://runner.tail123.ts.net:${firstPort}`); + }, 25_000); +}); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 4dd0fd15aeb..0a1d1489ecc 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -10,8 +10,10 @@ import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issueComments, issues, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, listWorkspaceServiceCommandDefinitions, - parseRuntimeExposureConfig, + readRuntimeExposureIntent, + resolveDeclaredRuntimeExposureConfig, type RuntimeExposureConfigInput, type RuntimeExposureStatus, type GitWorktreeBranchAncestryVerdict, @@ -225,6 +227,13 @@ class RuntimeServicePortBindCollision extends Error { export type WorkspaceRuntimeExposureDeps = ExposureManagerDeps & { resolveHostname: () => Promise; isPortAvailable: (port: number) => Promise; + /** + * Whether this host can actually broker HTTPS exposures right now. Gating the + * automatic default on broker availability is what keeps a Paperclip install + * without the host broker from failing every managed runtime start closed. + * An explicit opt-in still bypasses this and fails loudly. + */ + isBrokerAvailable: () => Promise; }; async function isLoopbackPortAvailable(port: number): Promise { @@ -238,14 +247,28 @@ async function isLoopbackPortAvailable(port: number): Promise { }); } +function resolveTailscaleBrokerSocketPath(): string { + return process.env.PAPERCLIP_TAILSCALE_BROKER_SOCKET?.trim() || DEFAULT_TAILSCALE_BROKER_SOCKET; +} + function defaultWorkspaceRuntimeExposureDeps(): WorkspaceRuntimeExposureDeps { - const broker: BrokerClient = new UnixBrokerClient({ - socketPath: process.env.PAPERCLIP_TAILSCALE_BROKER_SOCKET?.trim() || DEFAULT_TAILSCALE_BROKER_SOCKET, - }); + const socketPath = resolveTailscaleBrokerSocketPath(); + const broker: BrokerClient = new UnixBrokerClient({ socketPath }); return { broker, resolveHostname: () => resolveTailscaleDnsName(), isPortAvailable: isLoopbackPortAvailable, + isBrokerAvailable: async () => { + try { + // Presence of the socket, not a probe request: availability is checked + // on every managed start, and an unauthenticated connect storm against + // the broker would be its own problem. + const stats = await fs.stat(socketPath); + return stats.isSocket(); + } catch { + return false; + } + }, probeHealth: async (url) => { try { const response = await fetch(url, { @@ -268,10 +291,113 @@ export function setWorkspaceRuntimeExposureDepsForTests(deps: WorkspaceRuntimeEx workspaceRuntimeExposureDeps = deps; } -function configuredRuntimeExposure(service: Record): RuntimeExposureConfigInput | null { - const expose = parseObject(service.expose); - if (asString(expose.type, "") !== "tailscale_https") return null; - return parseRuntimeExposureConfig(expose); +/** + * Deployment-level switch for the automatic default (PAP-17158). + * + * - `auto` (default): eligible Paperclip-managed worktree runtimes get + * `tailscale_https` without any project template or UI caller supplying an + * exposure block, provided the host broker is available. + * - `off`: no automatic default. Explicit opt-ins still work. + * - `force`: default even when the broker socket is missing, so a + * misconfigured host fails closed and loudly instead of silently serving + * plain HTTP. Intended for deployments that require HTTPS previews. + */ +export type ManagedRuntimeHttpsMode = "auto" | "off" | "force"; + +export function resolveManagedRuntimeHttpsMode(): ManagedRuntimeHttpsMode { + const raw = process.env.PAPERCLIP_MANAGED_RUNTIME_HTTPS?.trim().toLowerCase(); + if (raw === "off" || raw === "false" || raw === "0") return "off"; + if (raw === "force") return "force"; + return "auto"; +} + +/** + * Whether a service would be defaulted to HTTPS if it declared nothing. + * + * Intentionally narrow: only the Paperclip-managed dev runtime. Unmanaged and + * custom external services are left exactly as they are, because the broker + * only publishes allowlisted loopback ports it can prove Paperclip owns and we + * do not want to relocate a service somebody else addresses by port. + * + * A *pinned* port is still a candidate. The pre-feature Paperclip App template + * hard-codes `port: 45439`, which the broker's dedicated allowlist can never + * publish, so defaulting it to HTTPS necessarily relocates it into the + * dedicated range. "Keep existing runtime ports when safe" is honored one layer + * down, by preferring the current port when it already *is* an allowlisted app + * port with a free HMR companion. + */ +function isManagedHttpsDefaultCandidate(input: { + serviceName: string; + command: string | null; +}): boolean { + return isPaperclipDevRuntimeService(input); +} + +export type ResolvedRuntimeServiceExposure = { + config: RuntimeExposureConfigInput; + /** + * `declared` — the project template or UI caller asked for HTTPS. + * `default` — the server applied the automatic default (PAP-17158). + * + * The distinction matters for port handling: a declared opt-in on a pinned + * port is an operator misconfiguration and fails loudly, while the automatic + * default is allowed to relocate a legacy pinned port into the dedicated + * exposure range. + */ + origin: "declared" | "default"; +}; + +/** + * Resolve the exposure config for one runtime service start. + * + * Precedence: deliberate opt-out → explicit opt-in → automatic default for + * eligible managed runtimes → none. + */ +async function resolveRuntimeServiceExposure(input: { + service: Record; + serviceName: string; + command: string | null; +}): Promise { + const expose = parseObject(input.service.expose); + const intent = readRuntimeExposureIntent(expose); + if (intent === "disabled") return null; + // An explicit opt-in is honored verbatim and is never gated on broker + // availability: the operator asked for HTTPS, so a missing broker must fail + // the start rather than silently downgrade it to HTTP. + if (intent === "enabled") { + const declared = resolveDeclaredRuntimeExposureConfig(expose); + return declared ? { config: declared, origin: "declared" } : null; + } + + const mode = resolveManagedRuntimeHttpsMode(); + if (mode === "off") return null; + if (!isManagedHttpsDefaultCandidate({ serviceName: input.serviceName, command: input.command })) { + return null; + } + if (mode !== "force" && !(await workspaceRuntimeExposureDeps.isBrokerAvailable())) return null; + return { config: DEFAULT_TAILSCALE_HTTPS_EXPOSURE, origin: "default" }; +} + +/** + * Whether any entry in a start batch will take the HTTPS exposure path. + * + * Reads the service name and command straight off the raw config entry rather + * than resolving the full reuse identity: templates never rewrite a service + * name, and the substrings `isPaperclipDevRuntimeService` matches survive + * rendering, so this agrees with the per-service decision made during spawn. + */ +async function anyRuntimeServiceUsesHttpsExposure( + services: Record[], +): Promise { + for (const service of services) { + const resolved = await resolveRuntimeServiceExposure({ + service, + serviceName: asString(service.name, "service"), + command: asString(service.command, ""), + }); + if (resolved) return true; + } + return false; } type ProcessOutputCapture = { @@ -3832,13 +3958,17 @@ async function allocateAndReserveExposure(input: { companyId: string; runtimeId: string; config: RuntimeExposureConfigInput; + /** Port this runtime already used, preserved when it is still safe to use. */ + preferredAppPort?: number | null; }): Promise<{ appPort: number; status: RuntimeExposureStatus; handle: string }> { const reserved = await collectReservedExposurePorts(input.db, input.companyId); const retryable = new Set(["reservation_conflict", "manual_mapping_present", "quarantined"]); + let preferredAppPort = input.preferredAppPort ?? null; while (true) { const pair = await allocateExposurePortPair({ isPortAvailable: workspaceRuntimeExposureDeps.isPortAvailable, reserved, + preferredAppPort, }); const result = await reserveExposure(workspaceRuntimeExposureDeps, { runtimeId: input.runtimeId, @@ -3853,6 +3983,9 @@ async function allocateAndReserveExposure(input: { } reserved.add(pair.appPort); reserved.add(pair.hmrPort); + // The preference lost its race with a conflicting/manual/quarantined + // mapping; drop it so the retry scans instead of re-offering the same port. + preferredAppPort = null; } } @@ -4623,11 +4756,13 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint; const explicitPort = identity.explicitPort; const identityPort = identity.identityPort; + const resolvedExposure = await resolveRuntimeServiceExposure({ + service: input.service, + serviceName, + command, + }); + const exposureConfig = resolvedExposure?.config ?? null; const portType = asString(portConfig.type, ""); - const exposureConfig = configuredRuntimeExposure(input.service); - if (exposureConfig && portType !== "auto") { - throw new Error(`Runtime service "${serviceName}" must use port.type=auto with tailscale_https exposure`); - } const canAllocateFixedPort = Boolean( !exposureConfig && input.allowFixedPortFallback @@ -4646,12 +4781,22 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P scopeId: input.scopeId, }); const runtimeId = input.runtimeServiceId ?? stoppedReuseCandidate?.id ?? randomUUID(); + // An exposed runtime always takes its port from the dedicated broker range, so + // a configured or previously used port is a *preference*, not a constraint. It + // is honored when it is already an allowlisted app port whose HMR companion is + // free — that keeps a restart on the same port and keeps a backfilled service + // stable across deploys — and quietly relocated when it is not, which is the + // only way a legacy pinned port (the Paperclip App template's 45439) can be + // published at all. If the backend then fails to listen where we allocated, + // the broker's /proc ownership proof refuses the mapping and the start fails + // closed; it never falls back to HTTP. const reservedExposure = exposureConfig ? await allocateAndReserveExposure({ db: input.db, companyId: input.agent.companyId, runtimeId, config: exposureConfig, + preferredAppPort: stoppedReuseCandidate?.port ?? (explicitPort > 0 ? explicitPort : null), }) : null; let port: number | null = reservedExposure?.appPort ?? null; @@ -4818,11 +4963,18 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P }; } } - if (port) { - const ownerPid = await readLocalServicePortOwner(port); + // A pinned port is only worth a conflict check when the service will actually + // bind it. Under HTTPS exposure the port comes from the broker's dedicated + // range instead, and both ports in that pair were already probed free before + // the lease was taken — so checking the pinned port here would reject a + // legacy `port: 45439` service purely because the pre-backfill instance still + // holds 45439, which is exactly the workspace this feature has to upgrade. + const conflictPort = reservedExposure ? null : port; + if (conflictPort) { + const ownerPid = await readLocalServicePortOwner(conflictPort); if (ownerPid) { if (canAllocateFixedPort || portType === "auto") { - throw new RuntimeServicePortBindCollision(port); + throw new RuntimeServicePortBindCollision(conflictPort); } const ownerCwd = await readLocalServiceProcessCwd(ownerPid); const ownerIsInWorkspace = ownerCwd @@ -4831,11 +4983,11 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P const ownerDescription = ownerCwd ? `pid ${ownerPid} (cwd: ${ownerCwd})` : `pid ${ownerPid} (cwd unavailable)`; if (ownerIsInWorkspace === false) { throw new Error( - `Runtime service "${serviceName}" could not start because port ${port} has a cross-workspace port conflict with ${ownerDescription}; requested workspace: ${serviceCwd}. Stop the other service or configure a different port.`, + `Runtime service "${serviceName}" could not start because port ${conflictPort} has a cross-workspace port conflict with ${ownerDescription}; requested workspace: ${serviceCwd}. Stop the other service or configure a different port.`, ); } throw new Error( - `Runtime service "${serviceName}" could not start because port ${port} is already in use by ${ownerDescription}`, + `Runtime service "${serviceName}" could not start because port ${conflictPort} is already in use by ${ownerDescription}`, ); } } @@ -5762,7 +5914,7 @@ export async function startRuntimeServicesForWorkspaceControl( const invocationId = input.invocationId ?? randomUUID(); const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input); const provisionCoordinator = createRuntimeProvisionCoordinator(); - const hasHttpsExposure = rawServices.some((service) => configuredRuntimeExposure(service) !== null); + const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices); if ( rawServices.length === 0