Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.3. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/vitest-dev/vitest/releases">vitest's releases</a>.</em></p> <blockquote> <h2>v5.0.3</h2> <h3> 🐞 Bug Fixes</h3> <ul> <li>Isolate <code>result.status</code> between <code>repeats</code> runs - by <a href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a>, <strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11218">vitest-dev/vitest#11218</a> <a href="https://github.com/vitest-dev/vitest/commit/5dbebe9e3"><!-- raw HTML omitted -->(5dbeb)<!-- raw HTML omitted --></a></li> <li>Don't print an interceptor warning in browser mode - by <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11377">vitest-dev/vitest#11377</a> <a href="https://github.com/vitest-dev/vitest/commit/15cc006aa"><!-- raw HTML omitted -->(15cc0)<!-- raw HTML omitted --></a></li> <li>Don't retry when <code>test.fails</code> expectedly failed - by <a href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a>, <strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11219">vitest-dev/vitest#11219</a> <a href="https://github.com/vitest-dev/vitest/commit/b24585f08"><!-- raw HTML omitted -->(b2458)<!-- raw HTML omitted --></a></li> <li>Scope cache key generators to projects - by <a href="https://github.com/ecoyoung"><code>@ecoyoung</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11281">vitest-dev/vitest#11281</a> and <a href="https://redirect.github.com/vitest-dev/vitest/issues/11301">vitest-dev/vitest#11301</a> <a href="https://github.com/vitest-dev/vitest/commit/92ba7fc1d"><!-- raw HTML omitted -->(92ba7)<!-- raw HTML omitted --></a></li> <li><strong>browser</strong>: <ul> <li>Delay server <code>listen</code> until tests start running - by <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11366">vitest-dev/vitest#11366</a> <a href="https://github.com/vitest-dev/vitest/commit/7d8ed3e9b"><!-- raw HTML omitted -->(7d8ed)<!-- raw HTML omitted --></a></li> <li>Check mock path boundaries - by <a href="https://github.com/saryn17"><code>@saryn17</code></a>, <strong>Ryosei Sato</strong> and <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11361">vitest-dev/vitest#11361</a> and <a href="https://redirect.github.com/vitest-dev/vitest/issues/11362">vitest-dev/vitest#11362</a> <a href="https://github.com/vitest-dev/vitest/commit/1c3888bce"><!-- raw HTML omitted -->(1c388)<!-- raw HTML omitted --></a></li> <li>Keep config of browser-consumed environments - by <a href="https://github.com/kasperpeulen"><code>@kasperpeulen</code></a> and <strong>Claude</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11378">vitest-dev/vitest#11378</a> <a href="https://github.com/vitest-dev/vitest/commit/aafc0996f"><!-- raw HTML omitted -->(aafc0)<!-- raw HTML omitted --></a></li> <li>Ignore page crash while cancelling - by <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11386">vitest-dev/vitest#11386</a> <a href="https://github.com/vitest-dev/vitest/commit/7c36748fa"><!-- raw HTML omitted -->(7c367)<!-- raw HTML omitted --></a></li> <li><code>toMatchScreenshot</code> uses wrong reference on retried tests - by <a href="https://github.com/macarie"><code>@macarie</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11393">vitest-dev/vitest#11393</a> <a href="https://github.com/vitest-dev/vitest/commit/c22aba992"><!-- raw HTML omitted -->(c22ab)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>cache</strong>: <ul> <li>Revalidate imports of cached modules - by <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11381">vitest-dev/vitest#11381</a> <a href="https://github.com/vitest-dev/vitest/commit/38f98855f"><!-- raw HTML omitted -->(38f98)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>deps</strong>: <ul> <li>Pin <code>why-is-node-running</code> to <code>3.2.1</code> to avoid users running into <code>ERR_PNPM_TRUST_DOWNGRADE</code> - by <a href="https://github.com/AriPerkkio"><code>@AriPerkkio</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11403">vitest-dev/vitest#11403</a> <a href="https://github.com/vitest-dev/vitest/commit/f6c9a4977"><!-- raw HTML omitted -->(f6c9a)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>expect</strong>: <ul> <li>Pass current equality testers to <code>expect.extend</code> asymmetric matchers - by <a href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a>, <strong>Hiroshi Ogawa</strong> and <strong>Claude</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11401">vitest-dev/vitest#11401</a> <a href="https://github.com/vitest-dev/vitest/commit/3e794a96b"><!-- raw HTML omitted -->(3e794)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>jsdom</strong>: <ul> <li>Support Blob on jsdom 30.1 - by <a href="https://github.com/sheremet-va"><code>@sheremet-va</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11379">vitest-dev/vitest#11379</a> <a href="https://github.com/vitest-dev/vitest/commit/6c49b7197"><!-- raw HTML omitted -->(6c49b)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>pool</strong>: <ul> <li>Preserve unique pool ids when <code>groupOrder</code> is set - by <a href="https://github.com/mtorp"><code>@mtorp</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11392">vitest-dev/vitest#11392</a> <a href="https://github.com/vitest-dev/vitest/commit/50312ebb4"><!-- raw HTML omitted -->(50312)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>ui</strong>: <ul> <li>Split-pane handle overlapping iframe - by <a href="https://github.com/macarie"><code>@macarie</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11221">vitest-dev/vitest#11221</a> <a href="https://github.com/vitest-dev/vitest/commit/f91db0dfd"><!-- raw HTML omitted -->(f91db)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>vitest</strong>: <ul> <li>Remove root temp dir on close - by <a href="https://github.com/abhinav-phi"><code>@abhinav-phi</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11248">vitest-dev/vitest#11248</a> <a href="https://github.com/vitest-dev/vitest/commit/7c7119cf7"><!-- raw HTML omitted -->(7c711)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>vm</strong>: <ul> <li>Do not optimize deps from index.html - by <a href="https://github.com/ezefernandezyf"><code>@ezefernandezyf</code></a>, <strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11329">vitest-dev/vitest#11329</a> and <a href="https://redirect.github.com/vitest-dev/vitest/issues/11360">vitest-dev/vitest#11360</a> <a href="https://github.com/vitest-dev/vitest/commit/caf2887de"><!-- raw HTML omitted -->(caf28)<!-- raw HTML omitted --></a></li> <li>Don't reuse scripts across vite environments - by <a href="https://github.com/MO2k4"><code>@MO2k4</code></a>, <strong>Martin Oehlert</strong> and <strong>Claude</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11395">vitest-dev/vitest#11395</a> <a href="https://github.com/vitest-dev/vitest/commit/346d3896b"><!-- raw HTML omitted -->(346d3)<!-- raw HTML omitted --></a></li> </ul> </li> </ul> <h5> <a href="https://github.com/vitest-dev/vitest/compare/v5.0.2...v5.0.3">View changes on GitHub</a></h5> <h2>v5.0.2</h2> <h3> 🐞 Bug Fixes</h3> <ul> <li>Bind <code>process</code> in case global is overwritten - by <a href="https://github.com/AriPerkkio"><code>@AriPerkkio</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11343">vitest-dev/vitest#11343</a> <a href="https://github.com/vitest-dev/vitest/commit/0b79231ad"><!-- raw HTML omitted -->(0b792)<!-- raw HTML omitted --></a></li> <li><strong>detect-async-leaks</strong>: <ul> <li>Ignore <code>process.stdio</code> handles - by <a href="https://github.com/AriPerkkio"><code>@AriPerkkio</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11333">vitest-dev/vitest#11333</a> <a href="https://github.com/vitest-dev/vitest/commit/0fd6b9790"><!-- raw HTML omitted -->(0fd6b)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>expect</strong>: <ul> <li>Fix <code>toMatchObject</code> with asymmetric matchers - by <a href="https://github.com/ShreeBohara"><code>@ShreeBohara</code></a>, <strong>Claude Opus 5</strong>, <a href="https://github.com/hi-ogawa"><code>@hi-ogawa</code></a>, <strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-5)</strong> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11100">vitest-dev/vitest#11100</a> <a href="https://github.com/vitest-dev/vitest/commit/42523289e"><!-- raw HTML omitted -->(42523)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>jsdom</strong>: <ul> <li>Fix <code>Request</code> with <code>Blob</code> body on jsdom 28+ - by <a href="https://github.com/harshit-d3v"><code>@harshit-d3v</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11295">vitest-dev/vitest#11295</a> <a href="https://github.com/vitest-dev/vitest/commit/d1c3ecc93"><!-- raw HTML omitted -->(d1c3e)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>reporter</strong>: <ul> <li><code>agent</code> to respect <code>--silent</code> - by <a href="https://github.com/Raj4478"><code>@Raj4478</code></a> and <a href="https://github.com/AriPerkkio"><code>@AriPerkkio</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11271">vitest-dev/vitest#11271</a> <a href="https://github.com/vitest-dev/vitest/commit/5b95efb6d"><!-- raw HTML omitted -->(5b95e)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>reporters</strong>: <ul> <li>Handle concurrent <code>createReport</code> calls - by <a href="https://github.com/7rulnik"><code>@7rulnik</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11278">vitest-dev/vitest#11278</a> <a href="https://github.com/vitest-dev/vitest/commit/e8e556ff7"><!-- raw HTML omitted -->(e8e55)<!-- raw HTML omitted --></a></li> <li><code>hanging-process</code> to use ESM entrypoint - by <a href="https://github.com/AriPerkkio"><code>@AriPerkkio</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11316">vitest-dev/vitest#11316</a> <a href="https://github.com/vitest-dev/vitest/commit/4e91e5668"><!-- raw HTML omitted -->(4e91e)<!-- raw HTML omitted --></a></li> </ul> </li> <li><strong>spy</strong>: <ul> <li>Fix stack overflow when spying <code>Set.prototype.add</code> - by <a href="https://github.com/fengmk2"><code>@fengmk2</code></a> in <a href="https://redirect.github.com/vitest-dev/vitest/issues/11299">vitest-dev/vitest#11299</a> <a href="https://github.com/vitest-dev/vitest/commit/a0a939653"><!-- raw HTML omitted -->(a0a93)<!-- raw HTML omitted --></a></li> </ul> </li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/vitest-dev/vitest/commit/33cadea62e8763c455c7fca38d9ab1dda87c5f75"><code>33cadea</code></a> chore: release v5.0.3 (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11409">#11409</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/346d3896b65c3c907174447a035807342799f346"><code>346d389</code></a> fix(vm): don't reuse scripts across vite environments (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11395">#11395</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/f6c9a4977ad3363f796a737834572e54c6ad5c18"><code>f6c9a49</code></a> fix(deps): pin <code>why-is-node-running</code> to <code>3.2.1</code> to avoid users running into `...</li> <li><a href="https://github.com/vitest-dev/vitest/commit/062c75d8b63519211d951d8293ea81b5a9e3c124"><code>062c75d</code></a> chore: fix standalone docs build, update exports maps (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11394">#11394</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/caf2887dee8987a60118d53933f6e9cabd6b3e2a"><code>caf2887</code></a> fix(vm): do not optimize deps from index.html (fix <a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11329">#11329</a>) (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11360">#11360</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/50312ebb4eca6a98f6d0b2b61d5d9d38cbbabcef"><code>50312eb</code></a> fix(pool): preserve unique pool ids when <code>groupOrder</code> is set (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11392">#11392</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/7c36748fad1eae9687312f2f7ceadce6ec88b5df"><code>7c36748</code></a> fix(browser): ignore page crash while cancelling (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11386">#11386</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/92ba7fc1df16a4fa5bbee3f198c582fbd56689d8"><code>92ba7fc</code></a> fix: scope cache key generators to projects (fix <a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11281">#11281</a>) (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11301">#11301</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/38f98855fa9cd7fd376afb84094eba0fda256a74"><code>38f9885</code></a> fix(cache): revalidate imports of cached modules (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11381">#11381</a>)</li> <li><a href="https://github.com/vitest-dev/vitest/commit/b24585f08f2ea267746a2d6ca0e43edcbb29726f"><code>b24585f</code></a> fix: don't retry when <code>test.fails</code> expectedly failed (<a href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11219">#11219</a>)</li> <li>Additional commits viewable in <a href="https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest">compare view</a></li> </ul> </details> <br /> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Priya Raman <priya.raman@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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