feat(runtime): default managed worktree runtimes to Tailscale HTTPS (PAP-17158)

HTTPS was opt-in, so the UI could still advertise `http://paperclip-dev:<port>`
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.
This commit is contained in:
Dotta authored and Dotta committed 2026-08-16 19:26:28 +00:00
1 parent df4b46b137
commit 3ded2a0ed5
4 files changed
+419 -52

No files matched your search

@@ -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 }),
@@ -35,27 +35,50 @@ export interface AllocateExposurePortPairInput {
* cleanup, or already reserved by other live runtimes this cycle.
*/
reserved?: ReadonlySet<number>;
/**
* 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<ExposurePortPair> {
const reserved = input.reserved ?? new Set<number>();
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<ExposurePortPair | null> => {
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 ` +
@@ -43,7 +43,46 @@ function createBroker() {
return { broker, calls };
}
function startInput() {
function installDeps(overrides: {
broker: BrokerClient;
probeHealth?: () => Promise<boolean>;
isBrokerAvailable?: () => Promise<boolean>;
isPortAvailable?: (port: number) => Promise<boolean>;
}) {
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<string, unknown> | null;
port?: Record<string, unknown> | 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);
});
+170 -18
View File
@@ -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<string>;
isPortAvailable: (port: number) => Promise<boolean>;
/**
* 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<boolean>;
};
async function isLoopbackPortAvailable(port: number): Promise<boolean> {
@@ -238,14 +247,28 @@ async function isLoopbackPortAvailable(port: number): Promise<boolean> {
});
}
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<string, unknown>): 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<string, unknown>;
serviceName: string;
command: string | null;
}): Promise<ResolvedRuntimeServiceExposure | null> {
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<string, unknown>[],
): Promise<boolean> {
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