Files
PaperClipAI/packages/tailscale-https-broker/src/serve-config.ts
T
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

184 lines
7.0 KiB
TypeScript

/**
* Strict parser + invariant checks for `tailscale serve status --json` output.
*
* The primary route `:443 -> http://127.0.0.1:3100` is a protected invariant
* verified before AND after every mutation, and unknown/manual entries are
* never modified (PAP-17050 verdict requirement #3 + invariants). Parsing fails
* closed: any ambiguity, parse error, or unexpected shape is an error, never a
* best-effort guess.
*/
import { assertCanonicalPort } from "./integers.js";
import { PROTECTED_PRIMARY_PORT, PROTECTED_PRIMARY_TARGET } from "./types.js";
export interface ServeHandler {
path: string;
proxy: string | null;
}
export interface ServeEntry {
port: number;
https: boolean;
handlers: ServeHandler[];
}
export interface ParsedServe {
/** Normalized entries keyed by port. */
entries: Map<number, ServeEntry>;
}
export class ServeParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ServeParseError";
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Extract the trailing `:port` of a `host:port` Web key. */
function portFromWebKey(key: string): number {
const idx = key.lastIndexOf(":");
if (idx <= 0 || idx === key.length - 1) {
throw new ServeParseError(`malformed Web key: ${JSON.stringify(key)}`);
}
const portText = key.slice(idx + 1);
if (!/^[0-9]+$/.test(portText)) {
throw new ServeParseError(`non-numeric port in Web key: ${JSON.stringify(key)}`);
}
return assertCanonicalPort(Number(portText));
}
/**
* Parse serve status JSON into a normalized, port-keyed model. Tolerates extra
* top-level fields (Tailscale evolves its schema) but strictly validates the
* `TCP` and `Web` shapes it depends on.
*/
export function parseServeStatus(json: unknown): ParsedServe {
if (!isObject(json)) {
throw new ServeParseError("serve status must be a JSON object");
}
const entries = new Map<number, ServeEntry>();
const tcp = json.TCP;
const httpsPorts = new Set<number>();
if (tcp !== undefined && tcp !== null) {
if (!isObject(tcp)) throw new ServeParseError("TCP must be an object");
for (const [portKey, value] of Object.entries(tcp)) {
if (!/^[0-9]+$/.test(portKey)) {
throw new ServeParseError(`non-numeric TCP port: ${portKey}`);
}
const port = assertCanonicalPort(Number(portKey));
if (!isObject(value)) throw new ServeParseError(`TCP[${portKey}] must be an object`);
if (value.HTTPS === true) httpsPorts.add(port);
if (!entries.has(port)) entries.set(port, { port, https: value.HTTPS === true, handlers: [] });
else entries.get(port)!.https = value.HTTPS === true;
}
}
const web = json.Web;
if (web !== undefined && web !== null) {
if (!isObject(web)) throw new ServeParseError("Web must be an object");
for (const [hostKey, value] of Object.entries(web)) {
const port = portFromWebKey(hostKey);
if (!isObject(value)) throw new ServeParseError(`Web[${hostKey}] must be an object`);
const handlersRaw = value.Handlers;
const handlers: ServeHandler[] = [];
if (handlersRaw !== undefined && handlersRaw !== null) {
if (!isObject(handlersRaw)) throw new ServeParseError(`Handlers for ${hostKey} must be an object`);
for (const [path, handler] of Object.entries(handlersRaw)) {
if (!isObject(handler)) throw new ServeParseError(`handler ${path} must be an object`);
const proxy = handler.Proxy;
if (proxy !== undefined && typeof proxy !== "string") {
throw new ServeParseError(`handler ${path} Proxy must be a string`);
}
handlers.push({ path, proxy: typeof proxy === "string" ? proxy : null });
}
}
const existing = entries.get(port);
if (existing) {
existing.handlers.push(...handlers);
if (httpsPorts.has(port)) existing.https = true;
} else {
entries.set(port, { port, https: httpsPorts.has(port), handlers });
}
}
}
return { entries };
}
/** Stable, order-independent digest of one entry, for before/after comparison. */
export function entryDigest(entry: ServeEntry | undefined): string {
if (!entry) return "absent";
const handlers = [...entry.handlers]
.map((h) => `${h.path}=>${h.proxy ?? ""}`)
.sort();
return JSON.stringify({ port: entry.port, https: entry.https, handlers });
}
/** True when the given entry is exactly a same-number HTTPS->loopback listener. */
export function isSameNumberLoopbackEntry(entry: ServeEntry | undefined, port: number): boolean {
if (!entry || entry.port !== port || !entry.https) return false;
if (entry.handlers.length !== 1) return false;
const [handler] = entry.handlers;
return handler.path === "/" && handler.proxy === `http://127.0.0.1:${port}`;
}
/**
* Assert the protected primary `:443 -> http://127.0.0.1:3100` route is present
* and exactly as expected. Throws otherwise. Called before and after mutation.
*/
export function assertPrimaryIntact(parsed: ParsedServe): void {
const entry = parsed.entries.get(PROTECTED_PRIMARY_PORT);
if (!entry) {
throw new ServeParseError("protected primary :443 route is missing");
}
if (!entry.https) {
throw new ServeParseError("protected primary :443 route is not HTTPS");
}
const root = entry.handlers.find((h) => h.path === "/");
if (!root || root.proxy !== PROTECTED_PRIMARY_TARGET) {
throw new ServeParseError("protected primary :443 route target changed");
}
}
/** Digest of the protected :443 entry, for exact before/after equality. */
export function primaryDigest(parsed: ParsedServe): string {
return entryDigest(parsed.entries.get(PROTECTED_PRIMARY_PORT));
}
/**
* Protected ports whose entry digest differs between two snapshots (PAP-17285).
* Empty is the only healthy result for any broker mutation.
*
* `entryDigest(undefined)` is the sentinel `"absent"`, so a protected entry that
* *disappears* changes this digest exactly as loudly as one that is retargeted.
* That is precisely the failure that went undetected on `42000/52000`: the
* entries were deleted rather than modified, and the only snapshot comparison
* that ran (`changedPorts` vs the intended lease ports) treated their removal as
* the intended effect of the operation.
*/
export function changedProtectedPorts(
before: ParsedServe,
after: ParsedServe,
protectedPorts: readonly number[],
): number[] {
return [...new Set(protectedPorts)]
.filter((port) => entryDigest(before.entries.get(port)) !== entryDigest(after.entries.get(port)))
.sort((a, b) => a - b);
}
/** Ports whose entry digest differs between two snapshots. */
export function changedPorts(before: ParsedServe, after: ParsedServe): number[] {
const ports = new Set<number>([...before.entries.keys(), ...after.entries.keys()]);
const changed: number[] = [];
for (const port of ports) {
if (entryDigest(before.entries.get(port)) !== entryDigest(after.entries.get(port))) {
changed.push(port);
}
}
return changed.sort((a, b) => a - b);
}