mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user