mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
<!-- Simplified Technical English (ASD-STE100). --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip starts and supervises managed runtime services for a project's execution workspaces, so an agent's branch can be previewed while it works > - Those services only listen on plain loopback HTTP. A person on another device, or on a phone, cannot open the preview > - A Tailscale HTTPS mapping solves this, but `tailscale serve` needs host privileges that the Paperclip server process must not hold > - This pull request adds the foundation only: a separate least-privilege host broker, the shared exposure contract, and the database columns that hold exposure state > - Nothing calls the broker yet, so there is no behavior change. The benefit is that the privileged surface is small, reviewable, and isolated before any lifecycle code depends on it ## Linked Issues or Issue Description No public GitHub issue exists. The change follows the feature request template. **Subsystem affected** Managed workspace runtime services, the shared type and validator package, and the database schema. **Problem or motivation** A managed runtime service binds to loopback only. There is no supported way to reach that preview from another device. Adding HTTPS directly to the server would mean the server process runs `tailscale serve`, which needs privileges far wider than the task requires. A compromised or buggy server could then map any port to the tailnet. **Proposed solution** Split the privileged work into a separate broker process with a narrow protocol, and define one shared contract that the server, the UI, the runtime, and the broker all read. Land this foundation first, with no caller, so the privileged code can be reviewed on its own. **Alternatives considered** - Call `tailscale serve` from the server process. This was rejected because it gives the server unrestricted mapping authority. - Use `sudo` for single `tailscale` commands. This was rejected because the argument list is the only guard, and it is easy to widen by accident. - Use a generic reverse proxy. This was rejected because it does not remove the need for a privileged Tailscale mapping step. **Roadmap alignment** This supports the existing managed workspace runtime capability. It adds no new product surface on its own. **Additional context** The broker is the security boundary of the feature, so it is deliberately the first slice. Three later pull requests build on it: the server exposure lifecycle, the runtime lease and recovery integration, and the leased-port mediator. ## What Changed - Add the `@paperclipai/tailscale-https-broker` workspace package. The broker listens on a unix socket, authorizes each peer with `SO_PEERCRED`, and answers a small request protocol. - Restrict what the broker will map. It accepts only same-number HTTPS-to-loopback pairs inside the Paperclip port range, refuses protected ports, and confirms that the loopback port belongs to a Paperclip-owned listener. - Parse every request with a strict JSON reader that rejects duplicate keys, prototype keys, and unknown fields. - Write an append-only audit record for each broker decision. - Add the shared exposure contract in `@paperclipai/shared`: the `RuntimeExposureConfig`, `RuntimeExposureState`, and `RuntimeExposureStatus` types, their zod validators, the app and HMR port rules, and the loopback-bind helpers. - Persist exposure state on `workspace_runtime_services` with the new `exposure` column, plus the server-private `exposure_handle` and `backend_url` columns that are never serialized to API clients. - Add the `execution_workspace_runtime_leases` table that the later lease slice uses. - Extend the runtime read-model test fixture for the three new columns. ## Verification Focused checks, all run on this branch: - `pnpm --filter @paperclipai/tailscale-https-broker test` — 12 files, 82 tests pass. This covers peer credentials, port policy, protected ports, the serve config writer, the strict JSON reader, argv parsing, and the socket server. - `pnpm --filter @paperclipai/tailscale-https-broker typecheck` — clean. - `npx vitest run --root packages/shared src/runtime-exposure src/validators/runtime-exposure.test.ts` — 3 files, 40 tests pass. - `pnpm --filter @paperclipai/db typecheck` — runs `check:migrations` first. Migration numbering and migration safety both pass. - `pnpm --filter @paperclipai/shared typecheck` — clean. - `pnpm --filter @paperclipai/ui typecheck` — clean. - `npx vitest run --root server src/services/workspace-runtime-read-model.test.ts` — 3 tests pass. - `npx tsc --noEmit -p server/tsconfig.json` — 139 errors, which is exactly the count on `master` before this branch. All 139 come from the unbuilt `@paperclipai/plugin-sdk` package. To confirm the exposure state is inert, start a managed runtime service as usual. The new columns stay null and the service behaves as it does today. ## Risks - Migration risk is low. Both migrations only add a table and three nullable columns. No column is backfilled and no existing column changes. The migration safety check passes. - Behavior risk is low. No code path calls the broker in this pull request, and the shared exposure fields are optional. - The broker is privileged, so it is the real risk surface. It is mitigated by peer-credential authorization, a fixed port range, a protected-port deny list, same-number pair enforcement, listener-ownership checks, strict JSON parsing, and an audit trail. Reviewers should read `packages/tailscale-https-broker/src/authorization.ts` and `src/port-policy.ts` closely. - The broker requires a `tailscale` version floor, which its README records. An older host CLI makes the broker refuse to start rather than map incorrectly. - `pnpm-lock.yaml` changes because a new workspace package is added. The diff is the new importer block, plus one duplicate `tinyexec` entry that pnpm removed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. ## Model Used Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking, with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
/**
|
|
* PAP-17285 regression coverage.
|
|
*
|
|
* Two Serve mappings on `42000/52000` that operators had declared must-preserve
|
|
* were destroyed by a fully authorized managed removal. Reconstructed cause: the
|
|
* broker had itself created those mappings for a since-retired canary lane, so
|
|
* its registry still held an `exposed` lease for them. Every existing guard
|
|
* therefore passed — the peer was authorized, the handle matched, the entries
|
|
* were shape-valid same-number loopback listeners, `:443` was untouched, and the
|
|
* before/after diff saw changes only on the lease's own ports. The pre-existing
|
|
* "unknown/manual entries are never modified" invariant never applied, because
|
|
* the entries were never unknown *to the broker*.
|
|
*
|
|
* These tests pin both halves of the repair:
|
|
* - a genuinely unrelated unknown/manual pair survives every lifecycle path,
|
|
* including the failed/compensated ones (the pre-existing guarantee), and
|
|
* - an operator-protected pair survives even when a valid lease names it (the
|
|
* new guarantee), with a negative control proving the guard is what does it.
|
|
*/
|
|
import { mkdtempSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { MemoryAuditSink } from "./audit.js";
|
|
import { BrokerCore, type CliResult, type ListenerOwnership } from "./broker-core.js";
|
|
import { buildExposeArgv, buildRemoveArgv } from "./argv.js";
|
|
import { defaultIsAllowedPort, parseProtectedPorts } from "./port-policy.js";
|
|
import { changedProtectedPorts, parseServeStatus } from "./serve-config.js";
|
|
import { saveRegistry } from "./registry.js";
|
|
import type { BrokerRequest, PeerCredentials } from "./types.js";
|
|
|
|
const HOST = "paperclip-dev.tail29c1aa.ts.net";
|
|
const BIN = "/usr/bin/tailscale";
|
|
const RUNTIME_A = "2af79bb1-ecc5-4410-8438-091be135a921";
|
|
const PEER: PeerCredentials = { uid: 999, gid: 987, pid: 4242 };
|
|
|
|
/** The pair the incident lost. Same-number loopback, so shape-indistinguishable. */
|
|
const PROTECTED_APP = 42000;
|
|
const PROTECTED_HMR = 52000;
|
|
|
|
/**
|
|
* An unrelated unknown/manual pair the broker never created. Deliberately NOT
|
|
* same-number (`42500 -> 5432`) so it is genuinely unknown by shape, which is
|
|
* the population the original invariant protects.
|
|
*/
|
|
const MANUAL_APP = 42500;
|
|
const MANUAL_HMR = 52500;
|
|
const MANUAL_APP_TARGET = "http://127.0.0.1:5432";
|
|
const MANUAL_HMR_TARGET = "http://127.0.0.1:5433";
|
|
|
|
class FakeTailscale {
|
|
ports = new Map<number, string>([
|
|
[443, "http://127.0.0.1:3100"],
|
|
[MANUAL_APP, MANUAL_APP_TARGET],
|
|
[MANUAL_HMR, MANUAL_HMR_TARGET],
|
|
[PROTECTED_APP, `http://127.0.0.1:${PROTECTED_APP}`],
|
|
[PROTECTED_HMR, `http://127.0.0.1:${PROTECTED_HMR}`],
|
|
]);
|
|
/** Funnel stays null for the whole suite; the broker has no Funnel verb. */
|
|
funnel: unknown = null;
|
|
failExposePort: number | null = null;
|
|
/** Simulate a CLI that clobbers an unrelated port as a side effect. */
|
|
strayOnRemove: number | null = null;
|
|
exposeCalls = 0;
|
|
removeCalls = 0;
|
|
removedPorts: number[] = [];
|
|
|
|
run = (argv: string[]): CliResult => {
|
|
const [, sub, a2, a3] = argv;
|
|
if (sub === "serve" && a2 === "status") {
|
|
return { code: 0, stdout: this.statusJson(), stderr: "", timedOut: false };
|
|
}
|
|
if (sub === "serve" && a2 === "--bg") {
|
|
this.exposeCalls += 1;
|
|
const port = Number(a3.replace("--https=", ""));
|
|
if (this.failExposePort === port) {
|
|
return { code: 1, stdout: "", stderr: "boom", timedOut: false };
|
|
}
|
|
this.ports.set(port, `http://127.0.0.1:${port}`);
|
|
return { code: 0, stdout: "", stderr: "", timedOut: false };
|
|
}
|
|
if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") {
|
|
this.removeCalls += 1;
|
|
const port = Number(a2.replace("--https=", ""));
|
|
this.removedPorts.push(port);
|
|
this.ports.delete(port);
|
|
if (this.strayOnRemove !== null) this.ports.delete(this.strayOnRemove);
|
|
return { code: 0, stdout: "", stderr: "", timedOut: false };
|
|
}
|
|
return { code: 2, stdout: "", stderr: "unknown", timedOut: false };
|
|
};
|
|
|
|
private statusJson(): string {
|
|
const TCP: Record<string, unknown> = {};
|
|
const Web: Record<string, unknown> = {};
|
|
for (const [port, proxy] of this.ports) {
|
|
TCP[String(port)] = { HTTPS: true };
|
|
Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } };
|
|
}
|
|
return JSON.stringify({ TCP, Web, AllowFunnel: this.funnel });
|
|
}
|
|
|
|
/** Exactly the entries that must never move, as a comparable snapshot. */
|
|
preservedSnapshot() {
|
|
return {
|
|
manualApp: this.ports.get(MANUAL_APP),
|
|
manualHmr: this.ports.get(MANUAL_HMR),
|
|
protectedApp: this.ports.get(PROTECTED_APP),
|
|
protectedHmr: this.ports.get(PROTECTED_HMR),
|
|
primary: this.ports.get(443),
|
|
funnel: this.funnel,
|
|
};
|
|
}
|
|
}
|
|
|
|
const PRESERVED_INTACT = {
|
|
manualApp: MANUAL_APP_TARGET,
|
|
manualHmr: MANUAL_HMR_TARGET,
|
|
protectedApp: `http://127.0.0.1:${PROTECTED_APP}`,
|
|
protectedHmr: `http://127.0.0.1:${PROTECTED_HMR}`,
|
|
primary: "http://127.0.0.1:3100",
|
|
funnel: null,
|
|
};
|
|
|
|
function makeCore(
|
|
fake: FakeTailscale,
|
|
registryPath: string,
|
|
protectedPorts: readonly number[] = [PROTECTED_APP, PROTECTED_HMR],
|
|
ownership: (port: number) => ListenerOwnership = () => ({
|
|
present: true,
|
|
loopbackOnly: true,
|
|
ownerUidMatches: true,
|
|
inodes: ["5001"],
|
|
}),
|
|
) {
|
|
const audit = new MemoryAuditSink();
|
|
const core = new BrokerCore({
|
|
tailscaleBinPath: BIN,
|
|
registryPath,
|
|
auditSink: audit,
|
|
peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) },
|
|
nodeIdentity: "node-1",
|
|
isAllowedPort: (port) => defaultIsAllowedPort(port) && !protectedPorts.includes(port),
|
|
protectedPorts,
|
|
deps: {
|
|
runTailscale: fake.run,
|
|
verifyListenerOwnership: ownership,
|
|
nowIso: () => "2026-08-14T00:00:00.000Z",
|
|
},
|
|
});
|
|
return { core, audit };
|
|
}
|
|
|
|
/**
|
|
* Plant the exact registry state that caused the incident: a live `exposed`
|
|
* lease the broker itself issued for the now-retired lane, still naming the
|
|
* ports operators later declared must-preserve.
|
|
*/
|
|
function plantRetiredLaneLease(registryPath: string, ports: number[]) {
|
|
const handle = "retired-lane-handle-000000000000";
|
|
saveRegistry(registryPath, {
|
|
version: 1,
|
|
nodeIdentity: "node-1",
|
|
generationCounter: 7,
|
|
leases: [{
|
|
handle,
|
|
runtimeId: RUNTIME_A,
|
|
peerUid: PEER.uid,
|
|
peerGid: PEER.gid,
|
|
ports,
|
|
purposes: ports.map((_, index) => (index === 0 ? "app" : "vite_hmr")),
|
|
state: "exposed",
|
|
generation: 7,
|
|
createdAtIso: "2026-08-11T12:07:18.000Z",
|
|
expiresAtIso: null,
|
|
}],
|
|
quarantinedPorts: [],
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
const reserveReq = (ports: number[], runtimeId = RUNTIME_A): BrokerRequest => ({
|
|
op: "reserve",
|
|
requestId: "req-r",
|
|
runtimeId,
|
|
listeners: ports.map((port, index) => ({
|
|
purpose: index === 0 ? "app" : "vite_hmr",
|
|
port,
|
|
})),
|
|
});
|
|
|
|
let registryPath: string;
|
|
beforeEach(() => {
|
|
registryPath = join(mkdtempSync(join(tmpdir(), "broker-protected-")), "registry.json");
|
|
});
|
|
|
|
describe("operator-protected ports (PAP-17285)", () => {
|
|
it("refuses to remove a protected pair even when a valid broker lease names it", async () => {
|
|
// The incident, reproduced: the lease is real, the handle matches, the peer
|
|
// is authorized, and the entries are shape-valid same-number listeners. The
|
|
// ONLY thing that can save them is the operator declaration.
|
|
const fake = new FakeTailscale();
|
|
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
|
|
const { core, audit } = makeCore(fake, registryPath);
|
|
|
|
const res = await core.handle(
|
|
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
|
|
PEER,
|
|
);
|
|
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) expect(res.code).toBe("protected_port");
|
|
// Fail-closed: denial precedes every Serve read and mutation.
|
|
expect(fake.removeCalls).toBe(0);
|
|
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
|
const denial = audit.events.find((event) => event.decision === "deny");
|
|
expect(denial?.reasonCode).toBe("protected_port");
|
|
expect(denial?.op).toBe("remove");
|
|
});
|
|
|
|
it("NEGATIVE CONTROL: the identical removal succeeds when the ports are not protected", async () => {
|
|
// Proves the assertion above is carried by the new guard and not by some
|
|
// unrelated precondition — without this, that test could pass vacuously.
|
|
const fake = new FakeTailscale();
|
|
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
|
|
const { core } = makeCore(fake, registryPath, []); // no protected ports
|
|
|
|
const res = await core.handle(
|
|
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
|
|
PEER,
|
|
);
|
|
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok && res.op === "remove") {
|
|
expect(res.removedPorts).toEqual([PROTECTED_APP, PROTECTED_HMR]);
|
|
}
|
|
// This is precisely the production loss, reproduced on demand.
|
|
expect(fake.ports.has(PROTECTED_APP)).toBe(false);
|
|
expect(fake.ports.has(PROTECTED_HMR)).toBe(false);
|
|
// Even here the unrelated unknown/manual pair and the primary are untouched.
|
|
expect(fake.ports.get(MANUAL_APP)).toBe(MANUAL_APP_TARGET);
|
|
expect(fake.ports.get(MANUAL_HMR)).toBe(MANUAL_HMR_TARGET);
|
|
expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100");
|
|
expect(fake.funnel).toBeNull();
|
|
});
|
|
|
|
it("refuses to reserve or expose a protected port, so no lane can acquire one", async () => {
|
|
const fake = new FakeTailscale();
|
|
const { core } = makeCore(fake, registryPath);
|
|
|
|
const reserved = await core.handle(reserveReq([PROTECTED_APP, PROTECTED_HMR]), PEER);
|
|
expect(reserved.ok).toBe(false);
|
|
if (!reserved.ok) expect(reserved.code).toBe("protected_port");
|
|
|
|
// Also unreachable via a lease forged straight into the registry.
|
|
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP]);
|
|
const exposed = await core.handle(
|
|
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle },
|
|
PEER,
|
|
);
|
|
expect(exposed.ok).toBe(false);
|
|
if (!exposed.ok) expect(exposed.code).toBe("protected_port");
|
|
|
|
expect(fake.exposeCalls).toBe(0);
|
|
expect(fake.removeCalls).toBe(0);
|
|
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
|
});
|
|
|
|
it("preserves both pairs across a full healthy reserve/expose/remove lifecycle", async () => {
|
|
const fake = new FakeTailscale();
|
|
const { core } = makeCore(fake, registryPath);
|
|
const lanePorts = [42010, 52010];
|
|
|
|
const reserved = await core.handle(reserveReq(lanePorts), PEER);
|
|
expect(reserved.ok).toBe(true);
|
|
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
|
|
|
const exposed = await core.handle(
|
|
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
|
PEER,
|
|
);
|
|
expect(exposed.ok).toBe(true);
|
|
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
|
|
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
|
|
|
const removed = await core.handle(
|
|
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle: reserved.handle },
|
|
PEER,
|
|
);
|
|
expect(removed.ok).toBe(true);
|
|
expect(fake.ports.has(42010)).toBe(false);
|
|
expect(fake.ports.has(52010)).toBe(false);
|
|
// The whole point: only the lane's own ports moved.
|
|
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
|
});
|
|
|
|
it("preserves both pairs when expose fails midway and is compensated", async () => {
|
|
const fake = new FakeTailscale();
|
|
fake.failExposePort = 52010; // second port fails, first is already applied
|
|
const { core, audit } = makeCore(fake, registryPath);
|
|
|
|
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
|
|
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
|
const exposed = await core.handle(
|
|
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
|
PEER,
|
|
);
|
|
|
|
expect(exposed.ok).toBe(false);
|
|
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
|
|
// Compensation rolled back the partial application...
|
|
expect(fake.ports.has(42010)).toBe(false);
|
|
// ...and touched nothing it did not apply.
|
|
expect(fake.removedPorts).toEqual([42010]);
|
|
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
|
|
|
// Compensation is a Serve mutation, so it must leave a durable record
|
|
// (req #6). Before this change it emitted none at all.
|
|
const compensation = audit.events.find((event) => event.recovery === "cleanup" && event.op === "expose");
|
|
expect(compensation?.reason).toBe("expose compensated");
|
|
});
|
|
|
|
it("detects and quarantines when compensation collaterally changes an unrelated entry", async () => {
|
|
// Requirement #3: any unrelated Serve change must be DETECTED. This path
|
|
// previously trusted the rollback exit code and re-read nothing.
|
|
const fake = new FakeTailscale();
|
|
fake.failExposePort = 52010;
|
|
fake.strayOnRemove = MANUAL_APP; // rollback clobbers an unknown/manual entry
|
|
const { core, audit } = makeCore(fake, registryPath);
|
|
|
|
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
|
|
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
|
const exposed = await core.handle(
|
|
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
|
PEER,
|
|
);
|
|
|
|
// The original failure is still the reported error — never masked.
|
|
expect(exposed.ok).toBe(false);
|
|
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
|
|
|
|
// The collateral damage is detected, recorded, and the port quarantined so
|
|
// it is never silently reused. Recoverability preserved, loss surfaced.
|
|
const denial = audit.events.find((event) => event.reason.startsWith("expose compensation unverified"));
|
|
expect(denial).toBeDefined();
|
|
expect(denial?.reason).toContain(`unexpected_serve_diff:${MANUAL_APP}`);
|
|
expect(denial?.recovery).toBe("quarantine");
|
|
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
expect(registry.quarantinedPorts).toContain(42010);
|
|
});
|
|
|
|
it("detects a protected entry that DISAPPEARS, not just one that is retargeted", () => {
|
|
// The incident was a deletion. `entryDigest(undefined)` is the `"absent"`
|
|
// sentinel precisely so removal is as loud as retargeting.
|
|
const withBoth = parseServeStatus(JSON.parse(new FakeTailscale().run([BIN, "serve", "status", "--json"]).stdout));
|
|
const missing = new FakeTailscale();
|
|
missing.ports.delete(PROTECTED_APP);
|
|
const withoutOne = parseServeStatus(JSON.parse(missing.run([BIN, "serve", "status", "--json"]).stdout));
|
|
|
|
expect(changedProtectedPorts(withBoth, withoutOne, [PROTECTED_APP, PROTECTED_HMR])).toEqual([PROTECTED_APP]);
|
|
expect(changedProtectedPorts(withBoth, withBoth, [PROTECTED_APP, PROTECTED_HMR])).toEqual([]);
|
|
});
|
|
|
|
it("refuses to build a mutating argv for a protected port", () => {
|
|
const guarded = [PROTECTED_APP, PROTECTED_HMR];
|
|
expect(() => buildExposeArgv(BIN, PROTECTED_APP, guarded)).toThrow(/operator-protected/);
|
|
expect(() => buildRemoveArgv(BIN, PROTECTED_HMR, guarded)).toThrow(/operator-protected/);
|
|
// Unprotected ports in the dedicated range still build exactly as before.
|
|
expect(buildRemoveArgv(BIN, 42010, guarded)).toEqual([BIN, "serve", "--https=42010", "off"]);
|
|
expect(buildExposeArgv(BIN, 42010, guarded)).toEqual([
|
|
BIN, "serve", "--bg", "--https=42010", "http://127.0.0.1:42010",
|
|
]);
|
|
});
|
|
|
|
it("parses BROKER_PROTECTED_PORTS fail-closed", () => {
|
|
expect(parseProtectedPorts(undefined)).toEqual([]);
|
|
expect(parseProtectedPorts("")).toEqual([]);
|
|
expect(parseProtectedPorts("52000,42000")).toEqual([42000, 52000]);
|
|
expect(parseProtectedPorts("42000 52000")).toEqual([42000, 52000]);
|
|
expect(parseProtectedPorts("42000,42000")).toEqual([42000]);
|
|
// A malformed list must stop the broker, not silently protect nothing.
|
|
expect(() => parseProtectedPorts("42000,abc")).toThrow(/non-numeric/);
|
|
expect(() => parseProtectedPorts("70000")).toThrow(/out-of-range/);
|
|
expect(() => parseProtectedPorts("443")).toThrow(/must not list 443/);
|
|
});
|
|
});
|