rig/docs/plans/2026-07-17-host-installs-box.md
dan-claude-bot f3cfa7c358 feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.

rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.

- Convergent: box's installer is a no-op once box is installed, so re-running
  bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
  pointer) when curl or the network is missing — box is the host EXTRA, so a
  failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
  it claims never installs box on a half-built host.

The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.

Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).

Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00

9.7 KiB

rig bootstrap host=yes installs box + runs setup-host — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: When rig bootstrap converges a VM-host machine (trait host=yes — roles dev, staging, workstation, and custom --host yes), it should install the box CLI globally and run box's own setup-host automatically, instead of printing next: install the box CLI and run 'box setup-host'. This turns a documented to-do into a real, convergent install step, completing rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: the host class installs box + rig users).

Why this shape: a host=yes box exists to run guest boxes; a bootstrap that hardens the OS and joins the tailnet but then hands the operator a manual checklist has left the machine half-built. The box CLI is the host's reason for existing, so bootstrap finishes the job. But rig must finish it the same way rig users apply already assumes it happens — by delegating to box, never by touching Incus itself.

The design law this respects

commands/users-apply.sh carries the law verbatim: "rig NEVER installs Incus: box's setup-host owns the daemon and its group." Two tools converging one daemon is drift by construction, and box is the single owner. This change does not weaken that law — it fulfils it. rig does not apt-get install incus, does not configure the daemon, does not create the incus group. It runs box's own global installer as root; box installs Incus via its setup-host. rig delegates; box owns.

The symmetry is the point: users-apply dies on a host=yes box whose incus group is absent, pointing at box setup-host. After this change, an ordinary rig bootstrap dev is what makes that group exist — so the two commands now close the loop instead of one pointing at a manual step the other assumed.

Architecture

One block appended to commands/bootstrap.sh, after the role-marker write; no new files, no new flags. It keys off the existing HOST trait (so every host=yes shape — preset or --host yes override — gets it, and --host no opts a laptop out with zero role logic). The install runs box's raw install.sh piped to bash with BOX_YES=1 in the environment (non-interactive and keeps setup-host). Source is pinnable via BOX_REPO / BOX_REF (default heavy-duty/box@main).

Tech Stack: bash only, shellcheck, existing ci.yml (globstar shellcheck + bash test/cli.sh) — no workflow change needed; the new code is a .sh file already covered by both jobs.

Non-Goals

  • No Incus install by rig, no daemon config, no incus group creation — box's setup-host owns all of it. rig runs box's installer; box owns Incus.
  • No guest provisioning — minting boxes is box's job (box new).
  • No tailnet for guests — rig#12's constraint: the host joins the tailnet (done above in the tailscale block); guest boxes never do. box does not join the tailnet, so nothing here needs to enforce it.
  • No credentials on the host — rig#12's constraint: box is creds-free, so the delegation introduces none.

Global Constraints

  • #!/usr/bin/env bash + set -euo pipefail; log prefix rig-bootstrap: via the existing log / warn / die helpers.
  • Runs only as root, only after the full bootstrap (root check, sshd hardening, tailnet join + tag verification, role-marker write) has succeeded — so a box that failed to become what it claims never installs box.
  • Never aborts the bootstrap. box is the host extra; the OS + tailnet core is already done and asserted. A missing curl, a dead network, or a box installer that errors is a warn with a manual-command pointer, never a die.
  • Convergent: box's installer is a no-op once box is installed, so a re-run changes nothing.
  • shellcheck-clean exactly as CI runs it (shopt -s globstar; shellcheck -x bin/* **/*.sh); bash test/cli.sh green as non-root.
  • Keep the diff minimal — no drive-by refactors.

Behavior contract (in file order, commands/bootstrap.sh)

After the role-marker write (install -m 0644 "$MARKER_TMP" "$MARKER"), before the closing log "done — role …":

  1. Guard: if [ "$HOST" = "yes" ]; then — the exact guard line, no && (distinguishing it from the /dev/kvm advisory if [ "$HOST" = "yes" ] && … up in the guards section).
  2. Pin points: BOX_REPO="${BOX_REPO:-heavy-duty/box}", BOX_REF="${BOX_REF:-main}", and a BOX_INSTALL_URL built from them; a BOX_MANUAL string (curl -fsSL … | BOX_YES=1 bash) reused in every pointer.
  3. Opt-out first: RIG_SKIP_BOX_INSTALL=1log the skip with the manual pointer, do nothing else. (Rehearsals in containers, offline boxes, hand-managed hosts.)
  4. No-curl: command -v curl absent → warn with the manual pointer, skip.
  5. Install: log the intent (naming the pinned BOX_REPO@BOX_REF and that box owns Incus), then if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 bash;:
    • success → log box installed + host set up, pointer to box new;
    • failure (no network, installer error — the pipe fails under pipefail) → warn with the manual pointer. Never die.

BOX_YES=1 in the environment (not a flag) is load-bearing: it makes box's installer non-interactive and keeps setup-host, so the Incus stack is actually built rather than the CLI merely dropped on PATH. Running as root, box installs globally to /opt/box + /usr/local/bin.

The old if [ "$HOST" = "yes" ]; then log "next: install the box CLI …" block is replaced by this — the message it printed is now the thing rig does.


The box#71 dependency (ordering / correctness)

The global, world-readable install path — box under /opt/box with a /usr/local/bin shim readable by every non-root user — depends on box PR #71. Until #71 merges, box's root install lands in /root, and non-root users (the dev box's human operator; any box-role rig user) cannot reach it. So this rig step is correct once box#71 is merged; before then it installs box for root but not for the humans who need it.

This is a comment in bootstrap.sh and is called out in the PR body. The rig step itself is right and ships now (it is convergent and delegates correctly); the effective multi-user outcome is gated on box#71. No rig code changes when #71 lands — box's installer changes where it writes.


Test Plan

Harness (bash test/cli.sh, non-root, network-free)

The install itself needs root, the network, and a real host — none of which the harness can fabricate — so, exactly like the effective-tag refusals and the runner repo guard, the shipped script is proven by grepping its load-bearing pieces:

  • Guarded on host=yes — the exact guard line (grep -qxE) belongs to the box block alone.
  • Runs box's installer non-interactivelyBOX_YES=1 bash present.
  • Pinnable, default heavy-duty/box@mainBOX_REPO:-heavy-duty/box.
  • Opt-out honoredRIG_SKIP_BOX_INSTALL present.
  • rig never apt-installs incus — a negative grep (grep -nE 'apt-get install.* incus' exits 1 = pass), so the design law cannot silently erode.
  • Ordering — box install (BOX_YES=1 bash) sits after the role-marker write (install -m 0644 "$MARKER_TMP"); compare line numbers, same idiom as the visudo -c / sshd -t ordering asserts, defaults fail closed.
  • Skip/failure keeps a manual pointerprepare Incus present.
  • Existing bootstrap tests unchanged: dev / staging parse and refuse non-root (they reach the root check long before the box block); unknown roles still exit 2.

CI

Unchanged ci.yml covers the edits (globstar shellcheck -x + bash test/cli.sh). No workflow change.

Rehearsal (manual, out of harness — the effective-state proof)

Unit tests stop at the script's text; only a real host proves the daemon came up. On a pristine Debian host=yes box (real hardware with /dev/kvm, or a nested-virt VM):

  1. rig bootstrap dev --hostname dev-rehearsal with a real single-use tag:local key → hardened sshd drop-in, tailnet join as dev-rehearsal, role marker host=yes, then box installed and setup-host run.
  2. Assert effective state, not file existence:
    • incus info returns the daemon's info (setup-host built the stack);
    • getent group incus exists;
    • as a fresh user added to the incus group (not root): box templates lists templates and incus list works — the world-readable path (box#71) is what makes this succeed for a non-root human.
  3. Convergence: a second rig bootstrap dev run reports box already installed and changes nothing; incus info unchanged.
  4. Opt-out: RIG_SKIP_BOX_INSTALL=1 rig bootstrap dev skips the install and logs the manual pointer; nothing Incus-related is touched.
  5. box#71 gate: before box#71 merges, step 2's fresh incus-group user check fails (box lives in /root); after it merges, it passes with no rig change. This is the acceptance line that must be re-run once box#71 lands.

The staging shape is the same box block; rehearsing dev exercises it.

Notes

  • rig#12's two hard constraints hold and are commented at the step: the host joins the tailnet (guests never do — box doesn't join, fine); no credentials on the host (box is creds-free, fine).
  • The users-apply "absent incus group on host=yes → die pointing at setup-host" path stays valid as the fallback for the opt-out / failed-install cases; ordinary bootstrap now makes that group exist, closing the loop.