Files
PaperClipAI/packages/tailscale-https-broker/src/broker-core.test.ts
Dotta f4802b1bbc feat(runtime-exposure): least-privilege Tailscale HTTPS broker, shared contract, and persisted exposure state (#11524)
<!-- 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
2026-08-17 05:54:12 -04:00

594 lines
26 KiB
TypeScript

import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MemoryAuditSink } from "./audit.js";
import { BrokerCore, type CliResult, type ListenerOwnership } from "./broker-core.js";
import { defaultIsAllowedPort } from "./port-policy.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 RUNTIME_B = "3108ef8e-5ed0-41d9-b561-6b41c41b8545";
const PEER: PeerCredentials = { uid: 999, gid: 987, pid: 4242 };
/** In-memory tailscale serve fake driven by exact argv vectors. */
class FakeTailscale {
ports = new Map<number, string>([[443, "http://127.0.0.1:3100"]]);
failExposePort: number | null = null;
failRemove = false;
sideEffectOnExpose: number | null = null;
retargetPrimaryOnExpose = false;
exposeCalls = 0;
removeCalls = 0;
statusCalls = 0;
run = (argv: string[]): CliResult => {
const [, sub, a2, a3] = argv;
if (sub === "serve" && a2 === "status") {
this.statusCalls += 1;
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}`);
if (this.sideEffectOnExpose) this.ports.set(this.sideEffectOnExpose, `http://127.0.0.1:${this.sideEffectOnExpose}`);
if (this.retargetPrimaryOnExpose) this.ports.set(443, "http://127.0.0.1:9999");
return { code: 0, stdout: "", stderr: "", timedOut: false };
}
if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") {
this.removeCalls += 1;
if (this.failRemove) return { code: 1, stdout: "", stderr: "no", timedOut: false };
this.ports.delete(Number(a2.replace("--https=", "")));
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 });
}
}
function makeCore(
fake: FakeTailscale,
registryPath: string,
ownership: (port: number) => ListenerOwnership = () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: ["5001"],
}),
nowIso: () => string = () => "2026-08-11T00:00:00.000Z",
) {
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: defaultIsAllowedPort,
deps: {
runTailscale: fake.run,
verifyListenerOwnership: ownership,
nowIso,
},
});
return { core, audit };
}
const reserveReq = (runtimeId = RUNTIME_A, port = 42010): BrokerRequest => ({
op: "reserve",
requestId: "req-e",
runtimeId,
listeners: [{ purpose: "app", port }],
});
async function reserveAndExpose(core: BrokerCore, runtimeId = RUNTIME_A, port = 42010, peer = PEER) {
const reserved = await core.handle(reserveReq(runtimeId, port), peer);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
return await core.handle({
op: "expose",
requestId: "req-x",
runtimeId,
handle: reserved.handle,
}, peer);
}
let dir: string;
let registryPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "broker-test-"));
registryPath = join(dir, "registry.json");
});
afterEach(() => {
// tmp dir is left for the OS to reap; tests use unique dirs.
});
describe("expose", () => {
it("reserves before bind, then exposes a same-number loopback listener and persists a lease", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const reserved = await core.handle(reserveReq(), PEER);
expect(reserved.ok).toBe(true);
expect(fake.ports.has(42010)).toBe(false);
const beforeList = await core.handle({ op: "list", requestId: "before-list" }, PEER);
expect(beforeList).toMatchObject({ ok: true, listeners: [] });
if (!reserved.ok || reserved.op !== "reserve") throw new Error("expected reserve ok");
const res = await core.handle({ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle }, PEER);
expect(res.ok).toBe(true);
if (!res.ok || res.op !== "expose") throw new Error("expected expose ok");
expect(res.publicPorts).toEqual([42010]);
expect(res.handle).toMatch(/^[A-Za-z0-9_-]{16,}$/);
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
expect(registry.leases[0].ports).toEqual([42010]);
expect(registry.leases[0].state).toBe("exposed");
// Handle is persisted server-side but never appears in list output.
});
it("is idempotent: re-exposing the same port does not double-apply", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const firstReservation = await core.handle(reserveReq(), PEER);
if (!firstReservation.ok || firstReservation.op !== "reserve") throw new Error("reserve failed");
await core.handle({ op: "expose", requestId: "first", runtimeId: RUNTIME_A, handle: firstReservation.handle }, PEER);
const before = fake.exposeCalls;
const repeatedReservation = await core.handle(reserveReq(), PEER);
expect(repeatedReservation).toMatchObject({ ok: true, handle: firstReservation.handle });
const res = await core.handle({ op: "expose", requestId: "second", runtimeId: RUNTIME_A, handle: firstReservation.handle }, PEER);
expect(res.ok).toBe(true);
expect(fake.exposeCalls).toBe(before); // no additional CLI mutation
});
it("rejects and releases an expired reservation before any Serve mutation", async () => {
const fake = new FakeTailscale();
let now = "2026-08-11T00:00:00.000Z";
const { core } = makeCore(fake, registryPath, undefined, () => now);
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
now = "2026-08-11T00:06:00.000Z";
const result = await core.handle({
op: "expose",
requestId: "expired",
runtimeId: RUNTIME_A,
handle: reserved.handle,
}, PEER);
expect(result).toMatchObject({ ok: false, code: "reservation_expired" });
expect(fake.exposeCalls).toBe(0);
expect(JSON.parse(readFileSync(registryPath, "utf8")).leases).toEqual([]);
});
it("rejects an unauthorized peer without mutating serve", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const res = await core.handle(reserveReq(), { uid: 1000, gid: 987, pid: 1 });
expect(res.ok).toBe(false);
if (res.ok) throw new Error("unreachable");
expect(res.code).toBe("unauthorized_peer");
expect(fake.ports.has(42010)).toBe(false);
});
it("rejects a port outside the dedicated allowlist", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const res = await core.handle(reserveReq(RUNTIME_A, 8080), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("port_not_allowlisted");
});
it("names which listener predicate failed, and every one of them still denies (SSRF guard)", async () => {
// One code per predicate, so a denial is attributable from the client reply
// alone: `safeMessage()` returns the bare code and the discriminating reason
// reaches only the root-owned audit file, which the calling account cannot
// read. Distinguishing them relaxes nothing — all three deny below.
for (const { ownership, code } of [
{ ownership: { present: false, loopbackOnly: true, ownerUidMatches: true, inodes: [] }, code: "listener_absent" },
{ ownership: { present: true, loopbackOnly: false, ownerUidMatches: true, inodes: ["5001"] }, code: "listener_not_loopback" },
{ ownership: { present: true, loopbackOnly: true, ownerUidMatches: false, inodes: ["5001"] }, code: "listener_ownership_mismatch" },
]) {
const fake = new FakeTailscale();
const { core, audit } = makeCore(fake, registryPath, () => ownership);
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe(code);
// Fail-closed: the denial precedes every Serve read and mutation, so the
// requested port is never published and the primary route is untouched.
expect(fake.exposeCalls).toBe(0);
expect(fake.removeCalls).toBe(0);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
// The discriminating detail must still reach the audit trail.
const denial = audit.events.find((event) => event.decision === "deny");
expect(denial?.reasonCode).toBe(code);
expect(denial?.op).toBe("expose");
}
});
it("denies a listener that is present and correctly owned but cannot be named", async () => {
// Present-but-unattributable is not permission. Without a socket identity
// the broker cannot prove the socket it publishes is the socket it verified,
// so it must refuse rather than fall through to "the predicates passed".
const fake = new FakeTailscale();
const { core, audit } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [],
}));
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_unattributable");
expect(fake.exposeCalls).toBe(0);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
expect(audit.events.find((event) => event.decision === "deny")?.reasonCode).toBe(
"listener_unattributable",
);
});
it("denies before publishing when the listener is substituted during the Serve status read", async () => {
// The window Greptile found: pre-flight verifies the listener, then
// `readServe()` runs a `tailscale` subprocess. A process under the same
// managed-runtime UID can close the verified socket and take the port during
// that subprocess. The three boolean predicates cannot see it — the new
// process satisfies all of them — so only the socket identity changes.
// Publishing then maps tailnet HTTPS at a service that was never authorized.
const fake = new FakeTailscale();
// Armed relative to the start of expose, because reserve already reads Serve
// status once. The swap therefore lands on expose's own status subprocess —
// after its pre-flight verification, before it publishes anything.
let statusReadsBeforeExpose = 0;
const { core, audit } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [fake.statusCalls > statusReadsBeforeExpose ? "9999" : "5001"],
}));
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
statusReadsBeforeExpose = fake.statusCalls;
const res = await core.handle(
{ op: "expose", requestId: "req-sub", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_substituted");
// Fail closed *before* the mutation: the substituted service is never
// published, so only the pre-existing primary route remains.
expect(fake.exposeCalls).toBe(0);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
expect(audit.events.find((event) => event.decision === "deny")?.reasonCode).toBe(
"listener_substituted",
);
});
it("withdraws the mapping when substitution happens after the port is published", async () => {
// If the swap lands after the mutation instead, the post-publication re-proof
// denies inside the try, so compensation removes what was applied rather
// than leaving a substituted service reachable over HTTPS.
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [fake.exposeCalls >= 1 ? "9999" : "5001"],
}));
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_substituted");
// The port was published, then withdrawn by compensation.
expect(fake.exposeCalls).toBeGreaterThan(0);
expect(fake.removeCalls).toBeGreaterThan(0);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
});
it("withdraws an idempotently-skipped mapping whose listener was substituted", async () => {
// The gap a skipped mapping opens: on a re-expose the port already carries
// our exact same-number entry, so the apply loop skips it and it never
// enters `appliedPorts`. Denying alone would leave that pre-existing mapping
// active and now pointing at the replacement service, so a rejected request
// would still publish something unauthorized.
const fake = new FakeTailscale();
// Swap armed relative to the start of the *second* expose, so it lands after
// that request's pre-flight verification — the same window as before, but on
// a port the apply loop will skip instead of publish.
let statusReadsBeforeReExpose = Number.POSITIVE_INFINITY;
const { core } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [fake.statusCalls > statusReadsBeforeReExpose ? "9999" : "5001"],
}));
// First expose publishes the port for real.
const first = await reserveAndExpose(core);
expect(first.ok).toBe(true);
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
const exposeCallsAfterFirst = fake.exposeCalls;
// Re-expose the same port. The mapping already matches, so the apply loop
// takes the idempotent path and publishes nothing.
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
statusReadsBeforeReExpose = fake.statusCalls;
const second = await core.handle(
{ op: "expose", requestId: "req-again", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(second.ok).toBe(false);
if (!second.ok) expect(second.code).toBe("listener_substituted");
expect(fake.exposeCalls).toBe(exposeCallsAfterFirst); // nothing re-published
// The substituted mapping must not survive the rejected request.
expect(fake.removeCalls).toBeGreaterThan(0);
expect(fake.ports.has(42010)).toBe(false);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
});
it("still withdraws a substituted idempotent mapping when a reserved lease published nothing", async () => {
// A `reserved` lease has published nothing, so a substitution detected on it
// must deny without withdrawing anything: there is no mapping of ours to
// remove, and removing by port number would delete someone else's.
const fake = new FakeTailscale();
let statusReadsBeforeExpose = Number.POSITIVE_INFINITY;
const { core } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [fake.statusCalls > statusReadsBeforeExpose ? "9999" : "5001"],
}));
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
statusReadsBeforeExpose = fake.statusCalls;
const res = await core.handle(
{ op: "expose", requestId: "req-res", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_substituted");
// Nothing of ours was published, so nothing is published now.
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
});
it("withdraws a substituted idempotent mapping without depending on a readable Serve status", async () => {
// The withdrawal decision must not hinge on a live status read. If it did, a
// status read that fails at exactly the wrong moment would make the broker
// return `listener_substituted` while leaving the replacement service
// exposed. Provenance comes from the `exposed` lease instead, so the remove
// is still issued.
const fake = new FakeTailscale();
let statusReadsBeforeReExpose = Number.POSITIVE_INFINITY;
const { core } = makeCore(fake, registryPath, () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: [fake.statusCalls > statusReadsBeforeReExpose ? "9999" : "5001"],
}));
const first = await reserveAndExpose(core);
expect(first.ok).toBe(true);
const removeCallsAfterFirst = fake.removeCalls;
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
statusReadsBeforeReExpose = fake.statusCalls;
const second = await core.handle(
{ op: "expose", requestId: "req-noread", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(second.ok).toBe(false);
if (!second.ok) expect(second.code).toBe("listener_substituted");
// The remove was issued for the substituted port even though this request
// published nothing itself.
expect(fake.removeCalls).toBeGreaterThan(removeCallsAfterFirst);
expect(fake.ports.has(42010)).toBe(false);
});
it("withdraws a published mapping when the listener disappears entirely", async () => {
// Absent is not "unchanged". A closed listener leaves the mapping pointing at
// nothing, which a later process on that port would inherit, so it must be
// withdrawn like any other substitution.
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath, () => ({
present: fake.exposeCalls === 0,
loopbackOnly: true,
ownerUidMatches: true,
inodes: fake.exposeCalls === 0 ? ["5001"] : [],
}));
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_substituted");
expect(fake.removeCalls).toBeGreaterThan(0);
expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]);
});
it("denies an absent listener without consuming the reservation or touching Serve", async () => {
// A denial is pure: it neither publishes a mapping nor burns the lease. No
// production caller retries an exposure today (deliberately — a retry needs
// its own reproduced requirement and wiring); this pins the broker-side
// state purity that any such caller, or a plain operator re-run, relies on.
const fake = new FakeTailscale();
let bound = false;
const { core } = makeCore(fake, registryPath, () => ({
present: bound,
loopbackOnly: true,
ownerUidMatches: true,
inodes: bound ? ["5001"] : [],
}));
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
const denied = await core.handle(
{ op: "expose", requestId: "req-x1", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(denied.ok).toBe(false);
if (!denied.ok) expect(denied.code).toBe("listener_absent");
expect(fake.exposeCalls).toBe(0);
expect(fake.ports.has(42010)).toBe(false);
// Once the child is actually bound, the very same reservation still exposes.
bound = true;
const exposed = await core.handle(
{ op: "expose", requestId: "req-x2", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(exposed.ok).toBe(true);
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
});
it("never touches a pre-existing manual mapping on the target port", async () => {
const fake = new FakeTailscale();
fake.ports.set(42010, "http://127.0.0.1:5432"); // manual/unrelated service
const { core } = makeCore(fake, registryPath);
const res = await core.handle(reserveReq(), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("manual_mapping_present");
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:5432"); // unchanged
});
it("fails closed and preserves :443 if a mutation retargets the primary route", async () => {
const fake = new FakeTailscale();
fake.retargetPrimaryOnExpose = true;
const { core } = makeCore(fake, registryPath);
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("primary_route_violation");
});
it("compensates and quarantines on an unexpected serve diff", async () => {
const fake = new FakeTailscale();
fake.sideEffectOnExpose = 42011; // an unexpected extra entry appears
fake.failRemove = true; // compensation cannot be proven -> quarantine
const { core } = makeCore(fake, registryPath);
const res = await reserveAndExpose(core);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("unexpected_serve_diff");
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
expect(registry.quarantinedPorts).toContain(42010);
// A later expose of the quarantined port is refused.
const fake2 = new FakeTailscale();
const { core: core2 } = makeCore(fake2, registryPath);
const res2 = await core2.handle(reserveReq(), PEER);
expect(res2.ok).toBe(false);
if (!res2.ok) expect(res2.code).toBe("quarantined");
});
});
describe("remove", () => {
async function exposeAndGetHandle(core: BrokerCore, port = 42010, runtime = RUNTIME_A) {
const res = await reserveAndExpose(core, runtime, port);
if (!res.ok || res.op !== "expose") throw new Error("expose failed");
return res.handle;
}
it("removes only the owned listener with an exact lease match", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const handle = await exposeAndGetHandle(core);
const res = await core.handle(
{ op: "remove", requestId: "req-r", runtimeId: RUNTIME_A, handle },
PEER,
);
expect(res.ok).toBe(true);
if (res.ok && res.op === "remove") expect(res.removedPorts).toEqual([42010]);
expect(fake.ports.has(42010)).toBe(false);
expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100"); // primary intact
});
it("rejects a random handle and a cross-runtime removal", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const handle = await exposeAndGetHandle(core);
const bad = await core.handle(
{ op: "remove", requestId: "r", runtimeId: RUNTIME_A, handle: "random-handle-not-real-xxxx" },
PEER,
);
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.code).toBe("invalid_handle");
const cross = await core.handle(
{ op: "remove", requestId: "r", runtimeId: RUNTIME_B, handle },
PEER,
);
expect(cross.ok).toBe(false);
if (!cross.ok) expect(cross.code).toBe("listener_ownership_mismatch");
expect(fake.ports.has(42010)).toBe(true); // still there
});
it("releases an unexposed reservation without mutating Serve", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const reserved = await core.handle(reserveReq(), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
const res = await core.handle(
{ op: "remove", requestId: "req-r", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(res).toMatchObject({ ok: true, removedPorts: [] });
expect(fake.exposeCalls).toBe(0);
expect(fake.removeCalls).toBe(0);
});
});
describe("list", () => {
it("returns caller-owned ports and never lease handles", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
await reserveAndExpose(core);
const res = await core.handle({ op: "list", requestId: "req-l" }, PEER);
expect(res.ok).toBe(true);
if (res.ok && res.op === "list") {
expect(res.listeners).toEqual([{ runtimeId: RUNTIME_A, port: 42010, purpose: "app" }]);
expect(JSON.stringify(res)).not.toMatch(/handle/);
}
});
});
describe("node identity", () => {
it("quarantines when the persisted node identity no longer matches", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
await reserveAndExpose(core);
// Rebuild a core with a different node identity against the same registry.
const audit = new MemoryAuditSink();
const core2 = new BrokerCore({
tailscaleBinPath: BIN,
registryPath,
auditSink: audit,
peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) },
nodeIdentity: "node-CHANGED",
isAllowedPort: defaultIsAllowedPort,
deps: {
runTailscale: fake.run,
verifyListenerOwnership: () => ({ present: true, loopbackOnly: true, ownerUidMatches: true, inodes: ["5001"] }),
nowIso: () => "2026-08-11T00:00:00.000Z",
},
});
const res = await core2.handle(reserveReq(RUNTIME_B, 42011), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("quarantined");
});
});