Skip to content

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.sh automates 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.


  • Bastille installed and configured (/usr/local/etc/bastille/bastille.conf)
  • A FreeBSD release bootstrap available (bastille bootstrap 15.0-RELEASE)
  • .env populated — especially AGENT_NAME, WORKER_JAIL_IP, ZAI_API_KEY
  • ZFS pool zroot with prefix clawdie-runtime (set in bastille.conf)
  • warden0 bridge interface exists (ifconfig warden0 to verify)
  • PostgreSQL running with {agent}_brain database already created (run just setup-db — uses jail or host based on DB_RUNTIME)
  • 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 pass exposure: infra/ansible/playbooks/host-pf-baseline.yaml (jails_ssh_expose_via_pf)

These three values are the most common source of pain:

Terminal window
grep -E 'AGENT_NAME|WORKER_JAIL_IP|AGENT_SUBNET_BASE' .env

Expected:

AGENT_NAME=clawdie
AGENT_SUBNET_BASE=10.0.1
WORKER_JAIL_IP=10.0.0.2

Snag: 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.


Terminal window
grep 'bastille_network_loopback\|bastille_network_shared' /usr/local/etc/bastille/bastille.conf

Snag: 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.


Terminal window
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 warden0

Note: warden0 is passed explicitly — do not omit it (see snag above).

Verify it started:

Terminal window
sudo bastille list
sudo bastille cmd "${JAIL}" hostname

Terminal window
sudo bastille pkg "${JAIL}" install -y \
node24 npm-node24 \
bash git-lite \
python311 python312 uv \
sudo tmux jq \
postgresql18-client \
ripgrep ca_root_nss

Install the Rust toolchain via rustup under /opt/clawdie (needed for native modules like SWC and tree-sitter):

Terminal window
sudo bastille cmd "${JAIL}" mkdir -p /opt/clawdie/tmp /opt/clawdie/rustup /opt/clawdie/cargo
sudo bastille cmd "${JAIL}" fetch -o /opt/clawdie/tmp/rustup-init.sh https://sh.rustup.rs
sudo 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 stable

Persist the env for root inside the jail:

Terminal window
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'

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

Terminal window
AGENT_NAME=clawdie
JAIL_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:

Terminal window
sudo bastille cmd clawdie-controlplane ls /usr/home/clawdie/clawdie-ai/

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.

Terminal window
sudo zfs set \
mountpoint=/usr/local/bastille/jails/clawdie-controlplane/root/opt/clawdie/npm-global \
zroot/clawdie-runtime/shared/npm-global

ZFS auto-mounts it. Do not add it to fstab — that causes double-mount errors at next boot.

Verify from inside the jail:

Terminal window
sudo bastille cmd clawdie-controlplane ls /opt/clawdie/npm-global/

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:

Terminal window
sudo bastille cmd clawdie-controlplane npm config set prefix /opt/clawdie/npm-global
sudo bastille cmd clawdie-controlplane npm config get prefix # confirm: /opt/clawdie/npm-global

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:

Terminal window
sudo bastille cmd clawdie-controlplane \
npm install -g /opt/clawdie/npm-global/lib/node_modules/@mariozechner/pi-coding-agent

Verify:

Terminal window
sudo bastille cmd clawdie-controlplane /opt/clawdie/npm-global/bin/pi --version

Copy settings and auth from the host user — do not create these from scratch:

Terminal window
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.json

Symlink skills:

Terminal window
sudo bastille cmd clawdie-controlplane \
ln -sf /usr/home/clawdie/clawdie-ai/.agent/skills /root/.pi/agent/skills

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.

Terminal window
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/clawdie inside 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:

Terminal window
sudo bastille cmd clawdie-controlplane sysrc clawdie_enable=AUTO
sudo bastille cmd clawdie-controlplane service clawdie onestart

Check status:

Terminal window
sudo bastille cmd clawdie-controlplane service clawdie status
sudo bastille cmd clawdie-controlplane tail -20 /usr/home/clawdie/clawdie-ai/logs/clawdie.log

Look for: Telegram bot connected and Clawdie running (trigger: @Clawdie).


If you need to start over:

Snag: bastille destroy fails if filesystems are mounted. Unmount manually first:

Terminal window
# Unmount nullfs home
sudo 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 destroy
sudo bastille destroy clawdie-controlplane
# If bastille destroy still complains, force:
sudo zfs destroy -r zroot/clawdie-runtime/clawdie-controlplane
sudo rm -rf /usr/local/bastille/jails/clawdie-controlplane

After recreating, update the ZFS npm-global mountpoint (step 6) — it still points at the old jail path.


#SymptomRoot causeFix
1bastille create fails: interface not foundbastille.conf has clawdie0 which doesn’t existPass warden0 explicitly
2Agent starts at wrong IPWORKER_JAIL_IP=10.0.0.101 stale in .envSet to 10.0.0.2
3bastille fails: symbolic linkThin jails: /homeusr/homeMount target must use usr/home/
4npm installs land in /usr/local/binFresh jail npm prefix is /usr/localnpm config set prefix /opt/clawdie/npm-global first
5npm install -g @badlogic/pi-agent → 404Package is not on npm registryInstall from host’s pre-built copy
6node dist/setup/index.js not foundSetup is not compiled to distUse npx tsx setup/index.ts
7bastille destroy failsMounts still activeUnmount nullfs + ZFS dataset manually first
8ZFS npm-global not visible in new jailMountpoint still points at old jailzfs set mountpoint=... to new path
9Service script references a hardcoded namesetup-controlplane-jail.sh step 5 is staleUse --step service via tsx (step 10 above)