Paperclip on the TazPod container (CT106) Detail

HISTORICAL — 2026-10-19. Paperclip was decommissioned and uninstalled on this date; this page is kept as a record of the experiment.

Level 3 (Detail) — the operator UI / agent control plane running inside tazpod-proxmox, how it is provisioned, how it authenticates to the database, and how to operate it.

MOVED — 2026-09-26. The agent company no longer runs here. It was carried to a dedicated, unprivileged container, CT108 paperclip (192.168.1.208), whose service account, gopass store and egress chokepoint are its own; paperclip.service on CT106 was stopped and disabled at the same time, because two schedulers against one database is the failure the design forbids. The live page is Paperclip: the dedicated agent container (CT108).

This page is kept as the record of the CT106 era: the deployment as it was built on 2026-09-25, the two traps in the CLI flow, and the findings that were only visible from inside the operator environment. Read it as history — the paths, the unit and the agent env below describe CT106.

Concept

Paperclip (self-hosted agent control plane, npm paperclipai) ran natively — no Docker — inside the TazPod LXC on the tazlab node, because that container is the operator environment where the agents run. It was designed and built in CRISP project paperclip-lxc-deployment (2026-09-25).

ItemValue
Servicepaperclip.service (system unit, User=tazpod — the operator user, managed shim as ExecStart)
Listen0.0.0.0:3100 — reachable on the private network (the operator’s VPN), authenticated + private
DataPAPERCLIP_HOME=/workspace/.paperclip on the pet volume, owned by the operator user (so a container rebuild does not rotate instances/default/secrets/master.key)
Payloadmanaged CLI install pinned to paperclip_version, store under $PAPERCLIP_HOME/cli (= /workspace/.paperclip/cli), shim in /home/tazpod/.local/bin/paperclipai (the old paperclip account and /home/paperclip no longer exist)
Runtimesystem Node 24 (NodeSource) for the app and the pi CLI; the operator’s shell keeps using nvm’s Node
ProvisioningAnsible layer layer5-paperclip in tazpod/runtimes/lxc (idempotent; runs from create.sh)

Database access

The application cannot present a TLS client certificate: its DB layer hands the URL straight to postgres.js and never builds an ssl option, and the driver ignores sslcert/sslcpy/sslrootcert-style parameters. The identity is therefore a password over TLS:

  • user paperclip, owner of the database paperclip, declared in tazlab-k8s (PostgresCluster.spec.users);
  • pg_hba rows (in this order, before the generic 10/8 rules): hostssl paperclip paperclip 0.0.0.0/0 md5, hostssl paperclip paperclip ::/0 md5, then user-scoped host all paperclip 0.0.0.0/0 reject and host all paperclip ::/0 reject. pg_hba matches (DATABASE, USER, ADDRESS) — the reject rows must read all paperclip, or the user stays authenticatable to every other database through the generic rules (that mistake was caught by the build’s own smoke test);
  • the connection string uses sslmode=require against the pinned LAN NodePort (192.168.1.211:32227); the server certificate carries only its in-cluster name, so hostname verification is not available from here (TD-085);
  • the schema is owned by the role (GRANT ALL ON SCHEMA public + ALTER SCHEMA public OWNER TO paperclip in init-sql.yaml), otherwise migrations fail with permission denied for schema public — the same pattern the other databases in this cluster use.

Verified server-side: pg_stat_ssl shows ssl = t with no client_dn for the paperclip application backends.

Secrets

gopass holds infra/paperclip/{db-password,better-auth-secret,tool-action-signing-secret,agent-jwt-secret}. The layer reads them inside the role (delegate_to: localhost, no_log: true) and renders them into /etc/paperclip/paperclip.env (0600, owned by the service user). They never travel through -e, and never through Ansible’s task environment: — that materialises the values in the argv of the become wrapper (sudo … DATABASE_URL=…), visible in ps; every task sources the env file instead.

Rotation: rotate the password in the cluster (PGO user secret) → update gopass → re-run the layer → the restart paperclip handler bounces the unit. Manual by design, tracked as TD-084.

Agent environment (why the service runs as the operator user)

Paperclip replaced the tmux-launched agents, so the service runs as tazpod: the agents it spawns (via the pi_local adapter, on OpenCode Go with deepseek-v4.1-flash) inherit the operator environment — the working copies of the repositories under /workspace, the git identity, the SSH keys and gopass. That is a deliberate trade recorded in the project’s design: the web app is now as privileged as the operator shell, acceptable because a single operator is the only source of instructions.

Consequences worth knowing:

  • repositories are writable by the agents (they edit the working copies directly, exactly as the tmux agents did); the delivery path stays the same: commit → push to GitHub master → Flux reconciles (Flux watches the remote, never the local checkout);
  • push credentials come from /etc/paperclip/gitconfig (GIT_CONFIG_GLOBAL, mode 0644, owner tazpod) whose credential helper echoes $GITHUB_TOKEN; the git config itself holds no secret and no remote URL carries one (git remote -v shows a plain https://github.com/...). The variable is not off disk, though: GITHUB_TOKEN (rendered from gopass cluster/github/token) sits in cleartext in /etc/paperclip/paperclip.env (0600, owned by tazpod), and the unit’s EnvironmentFile= exports that file into the process environment, which every agent the service spawns inherits. Verified with tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value paperclip)/environ | cut -d= -f1 | grep GITHUB_TOKEN. The credential-helper design is sound (nothing secret in the git config or the remote URL); the claim “no token on disk” was false;
  • ProtectHome is no here — pi writes sessions under ~/.pi, so it cannot be read-only — and the sandbox is ProtectSystem=strict plus ReadWritePaths=/workspace /home/tazpod; NoNewPrivileges and PrivateDevices stay enabled, so agents have no sudo from inside the service (sudo fails with “The ’no new privileges’ flag is set”).

Blast radius, stated plainly. ReadWritePaths=/workspace grants write access to the whole pet volume, not only the repository checkouts: it includes the Paperclip state itself under /workspace/.paperclip (the master key at instances/default/secrets/master.key, the per-agent briefs, the run database). An agent can therefore rewrite its own secret material and its own instructions, and GITHUB_TOKEN is readable by every spawned agent. That is the deliberate “the app is the operator” trade, but it must not be mistaken for a sandbox.

Creating agents and projects when the wizard is not enough

The onboarding wizard’s first-agent step offers only Claude and Codex (its own docs page has exactly those two tabs), and its connection test fails here because neither CLI is installed. That is not a misconfiguration — the shipped build knows 13 adapter types, pi_local among them (AGENT_ADAPTER_TYPES), and adapterConfig is a free-form record. The wizard simply does not expose them.

Create the agent through the CLI instead (board login first: paperclipai auth login --no-browser --api-base http://192.168.1.206:3100, then approve the printed URL in the browser as the logged-in board user):

paperclipai agent create --api-base http://192.168.1.206:3100 \
  --company-id <companyId> \
  --payload-json '{"name":"CEO","role":"ceo","adapterType":"pi_local",
                   "adapterConfig":{"provider":"opencode-go","model":"opencode-go/deepseek-v4.1-flash"}}'

Two traps, both learned the hard way:

  • the model identifier must carry the provider prefix (opencode-go/deepseek-v4.1-flash, not deepseek-v4.1-flash). The adapter validates the value against the model list pi itself reports and fails the run with Configured Pi model is unavailable: …. The failure appears only when the agent first runs, not when the agent is saved — so always trigger one heartbeat and read agent runtime-state afterwards.
  • board tokens are stored per API base: repeat --api-base on every later CLI call, otherwise the CLI talks to its default base and answers 401 Board authentication required.

Projects are created with the same pattern (project create --name … --description … --lead-agent-id <agentId>), which is also how the six TazLab projects were laid out (Cluster & Infra, TazPod, Vault & Secrets, Wiki & Docs, Blog, Teaching). The initial layout put all six under the CEO; the leads have since been redistributed, so paperclipai project list --company-id 52903938-b9f2-4acd-addc-be5a550de47b is authoritative and the current mapping is in the agent company org chart.

Proof of a working agent (first heartbeat, 2026-09-25):

{"lastRunStatus":"succeeded","totalInputTokens":45193,"totalOutputTokens":10933,
 "totalCostCents":1,
 "sessionId":"/home/tazpod/.pi/paperclips/2026-09-25T23-06-09-300Z-199c98d9-….jsonl"}

The session file sits under the operator’s home (/home/tazpod/.pi/paperclips/), not the service state directory — a direct consequence of running the service as tazpod, and a useful place to look when an agent run misbehaves. ~/.pi is a symlink into the pet volume, so those transcripts and the synced skills survive a create.sh rebuild; the mechanism and paths are on the Proxmox LXC Runtime page.

Unit hardening notes

As it stands in the live unit (systemctl cat paperclip): ProtectSystem=strict, ProtectHome=no with ReadWritePaths=/workspace /home/tazpod, PrivateTmp, PrivateDevices, empty CapabilityBoundingSet/AmbientCapabilities, UMask=0022, RestrictNamespaces, RestrictSUIDSGID, LockPersonality, memory/CPU bounds, and RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK — libuv enumerates the network interfaces through a netlink socket, so without AF_NETLINK the process dies at startup with uv_interface_addresses returned Unknown system error 97. MemoryDenyWriteExecute is deliberately not set (V8/JIT) and neither is SystemCallFilter (the Node syscall surface).

Operations

# state and health
systemctl status paperclip
curl -fsS http://192.168.1.206:3100/api/health

# logs
journalctl -u paperclip -f

# as the service user (env comes from the env file). The operator shell already runs as
# `tazpod`, the only service user that exists; `sudo -u ...` is not available under
# NoNewPrivileges.
bash -lc 'set -a; . /etc/paperclip/paperclip.env; set +a; paperclipai doctor --yes'
bash -lc 'set -a; . /etc/paperclip/paperclip.env; set +a; paperclipai db:backup'

# re-provision (idempotent): run from the workstation, or locally with -c local
ansible-playbook -i 'tazpod,' -c local /workspace/tazpod/runtimes/lxc/ansible/tazpod-baseline.yml

Updates are owned by the layer: bump paperclip_version in the layer defaults → re-run → the version-guarded install runs → the handler restarts the unit. The CLI’s own paperclipai update only knows its user service, so a hand-written system unit would not be restarted by it.

See Also