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.serviceon 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).
| Item | Value |
|---|---|
| Service | paperclip.service (system unit, User=tazpod — the operator user, managed shim as ExecStart) |
| Listen | 0.0.0.0:3100 — reachable on the private network (the operator’s VPN), authenticated + private |
| Data | PAPERCLIP_HOME=/workspace/.paperclip on the pet volume, owned by the operator user (so a container rebuild does not rotate instances/default/secrets/master.key) |
| Payload | managed 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) |
| Runtime | system Node 24 (NodeSource) for the app and the pi CLI; the operator’s shell keeps using nvm’s Node |
| Provisioning | Ansible 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 databasepaperclip, declared intazlab-k8s(PostgresCluster.spec.users); pg_hbarows (in this order, before the generic10/8rules):hostssl paperclip paperclip 0.0.0.0/0 md5,hostssl paperclip paperclip ::/0 md5, then user-scopedhost all paperclip 0.0.0.0/0 rejectandhost all paperclip ::/0 reject.pg_hbamatches (DATABASE, USER, ADDRESS) — the reject rows must readall 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=requireagainst 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 paperclipininit-sql.yaml), otherwise migrations fail withpermission 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, mode0644, ownertazpod) whose credential helper echoes$GITHUB_TOKEN; the git config itself holds no secret and no remote URL carries one (git remote -vshows a plainhttps://github.com/...). The variable is not off disk, though:GITHUB_TOKEN(rendered fromgopass cluster/github/token) sits in cleartext in/etc/paperclip/paperclip.env(0600, owned bytazpod), and the unit’sEnvironmentFile=exports that file into the process environment, which every agent the service spawns inherits. Verified withtr '\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; ProtectHomeisnohere — pi writes sessions under~/.pi, so it cannot be read-only — and the sandbox isProtectSystem=strictplusReadWritePaths=/workspace /home/tazpod;NoNewPrivilegesandPrivateDevicesstay enabled, so agents have no sudo from inside the service (sudofails 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, notdeepseek-v4.1-flash). The adapter validates the value against the model listpiitself reports and fails the run withConfigured 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 readagent runtime-stateafterwards. - board tokens are stored per API base: repeat
--api-baseon every later CLI call, otherwise the CLI talks to its default base and answers401 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
- Parent hub: tazpod
- Org chart: TazLab Agent Company — the agents this service runs and what each one owns
- Container detail: Proxmox LXC Runtime
- CRISP:
paperclip-lxc-deployment(RESEARCH, DESIGN, PLAN, REVIEW, retrospective)