Controlplane Jail Install
Step-by-step guide for creating the agent controlplane jail from scratch on a running host. Written after a live session so all snags are documented.
Script:
docs/internal/scripts/setup-controlplane-jail.shautomates steps 1–3. The remaining steps (npm prefix, pi, service) must be run manually for now — the script’s step 5 is stale (hardcoded service name, wrong rc.d path). Use the manual commands below.
Prerequisites
Section titled “Prerequisites”- Bastille installed and configured (
/usr/local/etc/bastille/bastille.conf) - A FreeBSD release bootstrap available (
bastille bootstrap 15.0-RELEASE) .envpopulated — especiallyAGENT_NAME,WORKER_JAIL_IP,ZAI_API_KEY- ZFS pool
zrootwith prefixclawdie-runtime(set inbastille.conf) warden0bridge interface exists (ifconfig warden0to verify)- PostgreSQL running with
{agent}_braindatabase already created (runjust setup-db— uses jail or host based onDB_RUNTIME)
SSH ports (host vs jails)
Section titled “SSH ports (host vs jails)”- Host SSH stays on port
22. - If you choose to expose jail SSH via PF, use predictable host ports derived
from the jail IP last octet:
.2 → :2222,.3 → :2223,.4 → :2224,.5 → :2225,.6 → :2226. - Ansible path:
- enable
sshd+ operator key inside jails:infra/ansible/playbooks/jails-ssh-baseline.yaml - enable PF
rdr passexposure:infra/ansible/playbooks/host-pf-baseline.yaml(jails_ssh_expose_via_pf)
- enable
1. Verify .env values
Section titled “1. Verify .env values”These three values are the most common source of pain:
grep -E 'AGENT_NAME|WORKER_JAIL_IP|AGENT_SUBNET_BASE' .envExpected:
AGENT_NAME=clawdieAGENT_SUBNET_BASE=10.0.1WORKER_JAIL_IP=10.0.0.2Snag: WORKER_JAIL_IP was previously 10.0.0.101 (a stale value from an
earlier layout). The controlplane jail always lives at .2. Fix it before
continuing.
2. Check bastille.conf network interface
Section titled “2. Check bastille.conf network interface”grep 'bastille_network_loopback\|bastille_network_shared' /usr/local/etc/bastille/bastille.confSnag: If bastille_network_loopback references a stale interface name (e.g.
clawdie0), it will not exist on this host. When you run bastille create
without an explicit interface it tries to use that stale name and fails. Always
pass warden0 explicitly in step 3.
3. Create the jail
Section titled “3. Create the jail”JAIL="clawdie-controlplane"FREEBSD_REL=$(freebsd-version -u | cut -d- -f1,2) # e.g. 15.0-RELEASE
sudo bastille create "${JAIL}" "${FREEBSD_REL}" 10.0.0.2 warden0Note: warden0 is passed explicitly — do not omit it (see snag above).
Verify it started:
sudo bastille listsudo bastille cmd "${JAIL}" hostname4. Install packages
Section titled “4. Install packages”sudo bastille pkg "${JAIL}" install -y \ node24 npm-node24 \ bash git-lite \ python311 python312 uv \ sudo tmux jq \ postgresql18-client \ ripgrep ca_root_nssInstall the Rust toolchain via rustup under /opt/clawdie (needed for native
modules like SWC and tree-sitter):
sudo bastille cmd "${JAIL}" mkdir -p /opt/clawdie/tmp /opt/clawdie/rustup /opt/clawdie/cargosudo bastille cmd "${JAIL}" fetch -o /opt/clawdie/tmp/rustup-init.sh https://sh.rustup.rssudo bastille cmd "${JAIL}" env RUSTUP_HOME=/opt/clawdie/rustup CARGO_HOME=/opt/clawdie/cargo \ sh /opt/clawdie/tmp/rustup-init.sh -y --profile minimal --default-toolchain stablePersist the env for root inside the jail:
sudo bastille cmd "${JAIL}" sh -c 'printf "%s\n" \ "export RUSTUP_HOME=/opt/clawdie/rustup" \ "export CARGO_HOME=/opt/clawdie/cargo" \ "export PATH=/opt/clawdie/cargo/bin:\\$PATH" >> /root/.profile'5. Mount agent home (nullfs)
Section titled “5. Mount agent home (nullfs)”Snag: Thin jails symlink /home → usr/home. The nullfs fstab entry must
target the real path (usr/home/clawdie), not the symlink (home/clawdie).
Bastille will refuse to start the jail with:
mount.fstab: /root/home is a symbolic link
AGENT_NAME=clawdieJAIL_ROOT="/usr/local/bastille/jails/clawdie-controlplane/root"MOUNT_TARGET="${JAIL_ROOT}/usr/home/${AGENT_NAME}"FSTAB="/usr/local/bastille/jails/clawdie-controlplane/fstab"
sudo mkdir -p "${MOUNT_TARGET}"echo "/home/${AGENT_NAME} ${MOUNT_TARGET} nullfs rw 0 0" | sudo tee -a "${FSTAB}"sudo mount -t nullfs "/home/${AGENT_NAME}" "${MOUNT_TARGET}"Verify:
sudo bastille cmd clawdie-controlplane ls /usr/home/clawdie/clawdie-ai/6. ZFS npm-global dataset
Section titled “6. ZFS npm-global dataset”The dataset zroot/clawdie-runtime/shared/npm-global holds globally-installed
npm packages and is shared across jail rebuilds. Its ZFS mountpoint property
must point at the new jail’s /opt/clawdie/npm-global — update it whenever
you recreate the jail.
sudo zfs set \ mountpoint=/usr/local/bastille/jails/clawdie-controlplane/root/opt/clawdie/npm-global \ zroot/clawdie-runtime/shared/npm-globalZFS auto-mounts it. Do not add it to fstab — that causes double-mount errors at next boot.
Verify from inside the jail:
sudo bastille cmd clawdie-controlplane ls /opt/clawdie/npm-global/7. Set npm prefix
Section titled “7. Set npm prefix”Snag: A fresh FreeBSD jail defaults npm prefix to /usr/local. If you
npm install -g anything without setting the prefix first it lands in
/usr/local/bin — outside the shared ZFS dataset. Set it before installing
anything globally:
sudo bastille cmd clawdie-controlplane npm config set prefix /opt/clawdie/npm-globalsudo bastille cmd clawdie-controlplane npm config get prefix # confirm: /opt/clawdie/npm-global8. Install pi
Section titled “8. Install pi”Snag: @badlogic/pi-agent does not exist on the npm registry (404). The
package is @mariozechner/pi-coding-agent and lives in the local monorepo at
/home/clawdie/pi/. The monorepo requires a multi-package build sequence
(tui → ai → agent → coding-agent) — do not try to build it from scratch
inside the jail.
Instead, install from the already-built copy in the host user’s npm-global:
sudo bastille cmd clawdie-controlplane \ npm install -g /opt/clawdie/npm-global/lib/node_modules/@mariozechner/pi-coding-agentVerify:
sudo bastille cmd clawdie-controlplane /opt/clawdie/npm-global/bin/pi --version9. Configure pi
Section titled “9. Configure pi”Copy settings and auth from the host user — do not create these from scratch:
sudo bastille cmd clawdie-controlplane mkdir -p /root/.pi/agent
sudo bastille cmd clawdie-controlplane \ cp /usr/home/clawdie/.pi/agent/settings.json /root/.pi/agent/settings.json
sudo bastille cmd clawdie-controlplane \ cp /usr/home/clawdie/.pi/agent/auth.json /root/.pi/agent/auth.jsonSymlink skills:
sudo bastille cmd clawdie-controlplane \ ln -sf /usr/home/clawdie/clawdie-ai/.agent/skills /root/.pi/agent/skills10. Install and start the service
Section titled “10. Install and start the service”Snag: dist/setup/index.js does not exist — the setup scripts are not
compiled into dist/. Run via tsx from the source tree. Also, there is no
--step jails in setup/index.ts — the jails step only exists in
setup/install.ts. Use --step service which does exist.
sudo bastille cmd clawdie-controlplane \ sh -c 'cd /usr/home/clawdie/clawdie-ai && npx tsx setup/index.ts -- --step service'This:
- Compiles TypeScript (
tsc) - Writes
run-clawdie.sh - Installs
/usr/local/etc/rc.d/clawdieinside the jail
Note: run-clawdie.sh drops privileges to the agent user and sets HOME to
/home/${AGENT_NAME} to avoid root-owned npm/vite artifacts.
Enable autostart and start:
sudo bastille cmd clawdie-controlplane sysrc clawdie_enable=AUTOsudo bastille cmd clawdie-controlplane service clawdie onestartCheck status:
sudo bastille cmd clawdie-controlplane service clawdie statussudo bastille cmd clawdie-controlplane tail -20 /usr/home/clawdie/clawdie-ai/logs/clawdie.logLook for: Telegram bot connected and Clawdie running (trigger: @Clawdie).
Destroying and recreating the jail
Section titled “Destroying and recreating the jail”If you need to start over:
Snag: bastille destroy fails if filesystems are mounted. Unmount manually
first:
# Unmount nullfs homesudo umount /usr/local/bastille/jails/clawdie-controlplane/root/usr/home/clawdie
# Unmount ZFS npm-global (ZFS, not umount)sudo zfs unmount zroot/clawdie-runtime/shared/npm-global
# Now destroysudo bastille destroy clawdie-controlplane
# If bastille destroy still complains, force:sudo zfs destroy -r zroot/clawdie-runtime/clawdie-controlplanesudo rm -rf /usr/local/bastille/jails/clawdie-controlplaneAfter recreating, update the ZFS npm-global mountpoint (step 6) — it still points at the old jail path.
Snag summary
Section titled “Snag summary”| # | Symptom | Root cause | Fix |
|---|---|---|---|
| 1 | bastille create fails: interface not found | bastille.conf has clawdie0 which doesn’t exist | Pass warden0 explicitly |
| 2 | Agent starts at wrong IP | WORKER_JAIL_IP=10.0.0.101 stale in .env | Set to 10.0.0.2 |
| 3 | bastille fails: symbolic link | Thin jails: /home → usr/home | Mount target must use usr/home/ |
| 4 | npm installs land in /usr/local/bin | Fresh jail npm prefix is /usr/local | npm config set prefix /opt/clawdie/npm-global first |
| 5 | npm install -g @badlogic/pi-agent → 404 | Package is not on npm registry | Install from host’s pre-built copy |
| 6 | node dist/setup/index.js not found | Setup is not compiled to dist | Use npx tsx setup/index.ts |
| 7 | bastille destroy fails | Mounts still active | Unmount nullfs + ZFS dataset manually first |
| 8 | ZFS npm-global not visible in new jail | Mountpoint still points at old jail | zfs set mountpoint=... to new path |
| 9 | Service script references a hardcoded name | setup-controlplane-jail.sh step 5 is stale | Use --step service via tsx (step 10 above) |