paperclip/packages/tailscale-https-broker
Nicky Leach 38d8f37172
fix(build): enforce Node 24 across Paperclip (#11792)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip runs across the CLI, server, adapters, plugins, CI, and
container images.
> - These surfaces declared different Node.js versions from 20 through
24.
> - A newer `@types/node` major can expose APIs that the supported
runtime does not provide.
> - Node.js 20 is no longer a suitable project baseline, and Node.js 24
is the current LTS line.
> - This pull request sets Node.js 24.11.0 as one repository-wide
baseline, adds a drift check, and gives users actionable startup
guidance when their runtime is too old.
> - The benefit is one clear runtime contract for development, release,
installation, and published packages.

## Linked Issues or Issue Description

Refs #2734

Refs #11727

Refs #739

## What Changed

- Require Node.js 24.11.0 or newer in all 42 package manifests and
runtime checks.
- Use Node.js 24 in GitHub Actions, Docker images, smoke images, sandbox
setup, portable installs, and esbuild targets.
- Align every direct `@types/node` declaration on `^24.0.0`.
- Prevent Dependabot from opening major `@types/node` upgrades without a
matching runtime decision.
- Add `.nvmrc` and a CI policy check for Node version drift.
- Update ACP version gates, tests, and user documentation for the new
minimum.
- Print a non-blocking warning on CLI and server startup when Node is
unsupported, with remediation through a version manager or the
documented downloaded `install.sh` workflow.
- Deduplicate that warning when `paperclipai run` boots the CLI and
server in the same process.

## Verification

- `node scripts/check-node-version-policy.mjs`
- `node --check scripts/check-node-version-policy.mjs`
- `node --check cli/esbuild.config.mjs`
- `node --check scripts/generate-npm-package-json.mjs`
- `bash -n scripts/install.sh scripts/test-install-sh-docker.sh
scripts/e2e-install-lifecycle.sh`
- Parsed all 42 package manifests and confirmed `engines.node` is
`>=24.11.0`.
- `git diff --check`
- `vitest run
packages/adapter-utils/src/sandbox-install-command.test.ts` passed with
3 tests.
- `vitest run cli/src/node-version.test.ts` passed with 4 tests.
- Directly exercised the shared warning helper for unsupported-version
messaging and same-process deduplication.
- The focused exe.dev suite could not resolve the locally unbuilt plugin
SDK from this isolated worktree. A full offline workspace install was
also blocked because the package-manager signature verifier requires
registry access. The full suite was not run locally; draft CI performs a
clean install and evaluates the wider impact.

## Risks

- This is a breaking runtime change for users, plugins, and deployments
that still use Node.js 20 or 22.
- Published workspace packages will now produce an engine warning or
failure in strict package managers on older Node.js releases.
- Node.js 24 can reveal dependency, native module, Playwright, or agent
CLI compatibility issues in CI.
- The bootstrap installer now installs Node.js 24 when the current
runtime is older than 24.11.0.
- The portable sandbox fallback is pinned to Node.js 24.11.0 and depends
on that upstream tarball remaining available.
- Unsupported runtimes continue booting after a warning, so a later
incompatibility can still fail at its point of use.
- The CLI and server share the warning policy through the published
`@paperclipai/shared` package; packaging checks must keep that subpath
export available.
- This PR does not commit `pnpm-lock.yaml` because repository policy
assigns lockfile generation to CI.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex based on GPT-5. The exact deployment ID and context
window are not exposed in this session. Reasoning, repository tools,
shell execution, and GitHub tools were enabled.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-21 10:17:52 -07:00
..
deploy
scripts
src
README.md
package.json fix(build): enforce Node 24 across Paperclip (#11792) 2026-08-21 10:17:52 -07:00
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 /proc proves 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.

  1. 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:3100 Serve mapping is present.

  2. 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>
    
  3. Grant Tailscale operator authority to the broker account only.

    sudo tailscale set --operator=paperclip-tsbroker
    

    Do not grant --operator to the Paperclip app/agent account (that grant was explicitly rejected in PAP-16989).

  4. 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-broker
    

    The broker refuses to start if the registry path's parent is group/other writable.

  5. Build, install the package under /opt/paperclip, and install the packaged systemd unit. The unit's ExecStart (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-API SO_PEERCRED addon. The output is self-contained (Node builtins plus the compiled addon; no node_modules needed).

    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.env
    

    The 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.target
    

    Put the BROKER_* values from the table below in the environment file. Set PAPERCLIP_TAILSCALE_BROKER_SOCKET=/run/paperclip-tailscale-broker/broker.sock on the Paperclip service only if overriding its default.

    Environment variables (defaults in src/config.ts):

    Var Required Default Meaning
    BROKER_NODE_IDENTITY yes hostname + boot id; a change forces quarantine + operator reconciliation
    BROKER_SERVICE_UID yes UID of the Paperclip app account allowed to connect
    BROKER_SERVICE_GID yes GID of the dedicated socket group (caller's primary GID)
    BROKER_RUNTIME_UID yes UID that owns Paperclip-managed runtime processes (normally the Paperclip app service account); only its loopback listeners are eligible
    BROKER_TAILSCALE_BIN no /usr/bin/tailscale absolute path to the Tailscale CLI
    BROKER_SOCKET_PATH no /run/paperclip-tailscale-broker/broker.sock Unix socket path
    BROKER_REGISTRY_PATH no /var/lib/paperclip-tailscale-broker/registry.json root-owned 0600 ownership registry
    BROKER_AUDIT_PATH no /var/log/paperclip-tailscale-broker/audit.log append-only security audit log
    BROKER_PROTECTED_PORTS no (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/52000 mappings, 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/remove with protected_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; 443 is rejected because the primary route already has a stronger, non-optional invariant.

    BROKER_PROTECTED_PORTS=42000,52000
    

    Confirm it took effect before trusting it — --doctor echoes 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
    
  6. 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 --doctor
    

    Verifies: supported Tailscale CLI version, Serve status is readable, the primary :443 route is intact, the registry path is safe, and prints the node identity. Exit 0 = ready. It never mutates Serve state.

  7. Enable. sudo systemctl daemon-reload && sudo systemctl enable --now paperclip-tailscale-https-broker. Confirm the socket is 0660 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.

  1. Disable the exposure flag on the project runtime (Paperclip stops requesting expose). Existing previews drain on runtime stop.
  2. Drain owned listeners: stop each managed runtime so Paperclip issues remove for its own leases (proven by handle).
  3. sudo systemctl disable --now paperclip-tailscale-https-broker.
  4. Optional cleanup: remove the state dirs and sudo tailscale set --operator= to drop the operator grant. Do not run tailscale 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