<!-- 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 |
||
|---|---|---|
| .. | ||
| deploy | ||
| scripts | ||
| src | ||
| README.md | ||
| package.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
README.md
Paperclip Tailscale HTTPS broker
Least-privilege host broker that manages only Paperclip-owned, tailnet-only, same-number HTTPS-to-loopback listeners for managed branch runtimes.
It exists so the Paperclip app/agent account never gains Tailscale operator authority (see PAP-16989) while still getting automatic trusted HTTPS previews per branch runtime. Design: PAP-17049 plan; security contract: PAP-17050 threat-model verdict.
Runtime services opt in explicitly; existing services and the primary :443
route are unchanged:
{
"port": { "type": "auto", "envKey": "PORT" },
"expose": {
"type": "tailscale_https",
"hostname": "auto",
"publicPort": "same",
"includePaperclipViteHmr": true,
"failurePolicy": "fail_closed"
}
}
What it can and cannot do
Supported operations (over a Unix socket, one runtime-service at a time):
list— the caller's own exposures (never returns lease handles).reserve— atomically reserve an app/HMR pair before either backend binds, returning an unguessable, short-lived lease handle bound to the caller, runtime ID, ports, purposes, and generation.expose— redeem that reservation only after/procproves both listeners are loopback-only and owned by the configured managed-runtime UID.remove— remove the caller's own listeners, proven by exact lease handle.
Hard-denied, deny-by-default: Funnel, certificates, Tailscale Services,
serve reset / set-config, path handlers, arbitrary targets, non-loopback or
wildcard/dual-stack backends, port 443, privileged/reserved ports, ports
outside the dedicated runtime range, unknown fields, and removal of any mapping
not matching an exact registry + lease + live Serve entry. The primary
:443 → 127.0.0.1:3100 route is verified structurally before and after every
mutation and is never modified.
The socket transport reads Linux SO_PEERCRED before admission, admits at most
8 concurrent sockets per resolved UID, and reserves 4 of its 32 global slots
for the configured Paperclip service UID. Connection deadlines destroy the
socket so timed-out peers cannot retain kernel-level connection slots. Missing
or invalid native credentials fail closed; socket permissions are not used as
a substitute identity.
One-time host installation (paperclip-dev)
These steps require root and must be run by CloudOps/host owner, not the Paperclip agent account. They install the broker as a dedicated Tailscale-operator service account distinct from the Paperclip app account.
-
Preconditions. Tailscale is installed and up on the node, the node has an HTTPS-capable trusted cert (MagicDNS + HTTPS enabled), and the existing
:443 → 127.0.0.1:3100Serve mapping is present. -
Create the dedicated operator account and socket group.
sudo useradd --system --home /var/lib/paperclip-tailscale-broker \ --shell /usr/sbin/nologin paperclip-tsbroker sudo groupadd --system paperclip-tsbroker-sock # The Paperclip *app* service account must have this as its PRIMARY group so # its SO_PEERCRED gid matches the socket group (supplemental membership is # intentionally NOT accepted). sudo usermod -g paperclip-tsbroker-sock <paperclip-app-account> -
Grant Tailscale operator authority to the broker account only.
sudo tailscale set --operator=paperclip-tsbrokerDo not grant
--operatorto the Paperclip app/agent account (that grant was explicitly rejected in PAP-16989). -
Create state directories (not writable by the Paperclip app). The packaged unit creates these automatically; for a manual install use:
sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0750 /run/paperclip-tailscale-broker sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0700 /var/lib/paperclip-tailscale-broker sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0700 /var/log/paperclip-tailscale-brokerThe broker refuses to start if the registry path's parent is group/other writable.
-
Build, install the package under
/opt/paperclip, and install the packaged systemd unit. The unit'sExecStart(and the doctor command below) run the build output from/opt/paperclip/packages/tailscale-https-broker/dist, so copy it there explicitly. The Linux build requires a C compiler and Node.js headers to compile the dependency-free N-APISO_PEERCREDaddon. The output is self-contained (Node builtins plus the compiled addon; nonode_modulesneeded).pnpm --filter @paperclipai/tailscale-https-broker build sudo install -d -m 0755 /opt/paperclip/packages/tailscale-https-broker sudo cp -r packages/tailscale-https-broker/dist \ /opt/paperclip/packages/tailscale-https-broker/ sudo install -D -m 0644 \ packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service \ /etc/systemd/system/paperclip-tailscale-https-broker.service sudo install -d -m 0750 /etc/paperclip sudoedit /etc/paperclip/tailscale-https-broker.envThe packaged unit is equivalent to:
[Unit] Description=Paperclip Tailscale HTTPS broker After=tailscaled.service Requires=tailscaled.service [Service] Type=simple User=paperclip-tsbroker # Socket must end up 0660 paperclip-tsbroker:paperclip-tsbroker-sock. Set the group here and # the broker chmods the socket to 0660 on bind. Group=paperclip-tsbroker-sock EnvironmentFile=/etc/paperclip/tailscale-https-broker.env ExecStart=/usr/bin/node /opt/paperclip/packages/tailscale-https-broker/dist/main.js Restart=on-failure NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=/run/paperclip-tailscale-broker /var/lib/paperclip-tailscale-broker /var/log/paperclip-tailscale-broker [Install] WantedBy=multi-user.targetPut the
BROKER_*values from the table below in the environment file. SetPAPERCLIP_TAILSCALE_BROKER_SOCKET=/run/paperclip-tailscale-broker/broker.sockon the Paperclip service only if overriding its default.Environment variables (defaults in
src/config.ts):Var Required Default Meaning BROKER_NODE_IDENTITYyes — hostname + boot id; a change forces quarantine + operator reconciliation BROKER_SERVICE_UIDyes — UID of the Paperclip app account allowed to connect BROKER_SERVICE_GIDyes — GID of the dedicated socket group (caller's primary GID) BROKER_RUNTIME_UIDyes — UID that owns Paperclip-managed runtime processes (normally the Paperclip app service account); only its loopback listeners are eligible BROKER_TAILSCALE_BINno /usr/bin/tailscaleabsolute path to the Tailscale CLI BROKER_SOCKET_PATHno /run/paperclip-tailscale-broker/broker.sockUnix socket path BROKER_REGISTRY_PATHno /var/lib/paperclip-tailscale-broker/registry.jsonroot-owned 0600ownership registryBROKER_AUDIT_PATHno /var/log/paperclip-tailscale-broker/audit.logappend-only security audit log BROKER_PROTECTED_PORTSno (empty) comma/space separated ports the broker must never create, remove, or reclaim — even when its own registry holds a valid lease for them (see below) BROKER_PROTECTED_PORTS— operator-declared preservation (PAP-17285)The long-standing "unknown/manual entries are never modified" invariant is provenance-blind: it protects only entries the broker has no lease for. It therefore could not protect the
42000/52000mappings, because the broker had itself created them for a canary lane that was later retired — so its registry still called them owned, while operators had reclassified them as must-preserve after failing to attribute them to any live lane. Both views were internally consistent, they disagreed, and a fully authorized, shape-valid,:443-preserving removal destroyed them with no guard able to object.A protected port is an operator assertion that outranks the broker's own ownership record. Enforcement is fail-closed and layered: refused during argv construction, denied in
reserve/expose/removewithprotected_port, excluded from the allocatable allowlist so no lane can acquire one, and asserted byte-unchanged across every before/after snapshot (protected_entry_violation). A malformed list makes the broker refuse to start rather than silently protect nothing;443is rejected because the primary route already has a stronger, non-optional invariant.BROKER_PROTECTED_PORTS=42000,52000Confirm it took effect before trusting it —
--doctorechoes the parsed set:sudo -u paperclip-tsbroker \ env $(cat /etc/paperclip/tailscale-https-broker.env | xargs) \ node /opt/paperclip/packages/tailscale-https-broker/dist/main.js --doctor -
Preflight (read-only, no mutation).
sudo -u paperclip-tsbroker \ BROKER_NODE_IDENTITY=$(hostname) BROKER_SERVICE_UID=... BROKER_SERVICE_GID=... BROKER_RUNTIME_UID=... \ node /opt/paperclip/packages/tailscale-https-broker/dist/main.js --doctorVerifies: supported Tailscale CLI version, Serve status is readable, the primary
:443route is intact, the registry path is safe, and prints the node identity. Exit 0 = ready. It never mutates Serve state. -
Enable.
sudo systemctl daemon-reload && sudo systemctl enable --now paperclip-tailscale-https-broker. Confirm the socket is0660 paperclip-tsbroker:paperclip-tsbroker-sock.
Upgrade
Deploy new package output to
/opt/paperclip/packages/tailscale-https-broker/dist, then
sudo systemctl restart paperclip-tailscale-https-broker. On
restart the broker re-reads its root-owned registry and adopts only exact-lease
matches; a changed BROKER_NODE_IDENTITY (host reimage / boot-id change) forces
quarantine and operator reconciliation rather than silently re-adopting.
Uninstall / rollback / opt-out
Rollback disables new exposure and removes only broker-owned listeners; it never resets Serve or changes the primary route.
- Disable the exposure flag on the project runtime (Paperclip stops requesting
expose). Existing previews drain on runtime stop. - Drain owned listeners: stop each managed runtime so Paperclip issues
removefor its own leases (proven by handle). sudo systemctl disable --now paperclip-tailscale-https-broker.- Optional cleanup: remove the state dirs and
sudo tailscale set --operator=to drop the operator grant. Do not runtailscale serve reset— remove only the specific per-port Serve entries if any remain.
Recovery
If a mutation fails partway, the broker removes only the exact listeners it
applied; if exact cleanup cannot be proven it quarantines the affected ports and
reports cleanup_pending (partial app+HMR exposure is never reported healthy).
Quarantined ports are not reused until an operator clears them. The append-only
audit log at BROKER_AUDIT_PATH records every allow/deny and mutation outcome
(peer UID/GID/PID, operation, runtime UUID, ports, decision reason, before/after
state digests, quarantine/recovery) with lease handles and raw CLI output
redacted.
Tests
pnpm --filter @paperclipai/tailscale-https-broker test # 72 tests
pnpm --filter @paperclipai/tailscale-https-broker typecheck
pnpm --filter @paperclipai/tailscale-https-broker build