Skip to content

0.7.0

Choose a tag to compare

@github-actions github-actions released this 19 Jul 15:47
· 411 commits to main since this release
fb7fc84

Added

  • The installer defaults to the latest release, and releases publish
    themselves
    (#83) — curl | bash used to hand out whatever main was at
    that second: the 0.6.0 release was a bookmark, not a package, and two
    operators "on 0.6.0" could be running different trees. install.sh now
    resolves the latest release tag by following GitHub's releases/latest
    redirect (one HEAD request — no API, no token, no rate-limit pain) and
    downloads that tag's tarball; a failed resolution refuses loudly, naming
    BOX_REF as the way out — it never hangs and never silently falls back to
    main. A set BOX_REF is tried as a tag first, then as a branch, so one
    knob yields three channels: default = latest release, BOX_REF=0.6.0 =
    pinned, BOX_REF=main = dev. A new release.yml (on a bare X.Y.Z tag
    push — the 0.6.0 tag set the no-v precedent) asserts the tag names the
    tree's own VERSION (a mismatch fails loudly and creates nothing) and
    publishes the GitHub release with that version's CHANGELOG.md section as
    the body (.github/scripts/release-notes.sh — the curated prose, not the
    generated PR list; no assets, the source tarball for the tag IS the
    package). And main's VERSION now carries -dev between releases
    (this PR: 0.6.1-dev): the versioned layout names install trees after
    VERSION, so a main install without the bump would land in
    versions/0.6.0 and impersonate the released tree. test/release.sh
    drives all of it offline — the extraction against fixtures and the real
    changelog, the resolution and every channel against a shim curl.

  • setup-host auto-picks a free subnet — nested box-in-box with zero
    flags
    (#80, completing its fix #1: "refuse … or automatically select a
    non-colliding subnet"). A bare box setup-host now decides the subnet
    itself, in four deliberate cases: an explicit BOX_SUBNET is honored or
    refused, never silently overridden (scripted hosts keep exact semantics);
    an existing boxnet bridge is converged on as-is — the bridge IS the pin —
    turning the old bare-re-run agree-gate refusal into plain convergence
    (unless a foreigner also claims the bridge's subnet: that is #80's
    poisoned state, and converging would rebuild on it, so it still refuses and
    names the bridge move); a free 10.88.0.0/24 stays the default; and a
    claimed default — the nested case: a drill or rehearsal running inside a
    box, whose own uplink owns 10.88 — scans 10.89.0.0/24 … 10.127.0.0/24
    in order, takes the first free candidate, announces the pick and the
    claimant loudly, and only refuses when every candidate is claimed. The
    decision happens before any mutation, and everything downstream (the
    bridge, BOX_GW, the ACL's gateway carve-out, the firewall, the doctor's
    expectations) derives from it.

  • setup-host refuses a claimed subnet, and BOX_SUBNET picks another
    (#80) — run inside a box, setup-host used to build a nested boxnet on
    the exact subnet and gateway of the guest's own uplink: the guest then held
    its gateway's address as a local address, carried duplicate connected
    routes for its uplink subnet, and suffered intermittent, self-recovering
    egress blackouts that looked like flaky internet (measured live: ~24–36 s
    outages, roughly hourly, with the host clean throughout). setup-host now
    scans the target subnet before any mutation — the default route's
    gateway inside it, or any non-boxnet interface holding an address in it —
    and refuses, naming the way out. A prior boxnet owning the subnet is the
    legitimate converge path and does not trip it. BOX_SUBNET=<a.b.c.0/24>
    (validated, alongside the existing BOX_DNS) moves the whole stack: the
    bridge address, the ACL's gateway carve-out (now converged via
    network acl edit, so a bridge moved off a colliding subnet no longer
    strands box DNS behind a stale /32), the firewall (box-firewall reads
    the gateway off the live bridge), and every drill/migrate probe that used
    to hardcode 10.88.

  • box doctor knows the #80 signature — a default gateway held as a
    LOCAL address, and duplicate connected routes for the uplink subnet, judged
    from ip route/ip addr on the machine doctor runs on (both tiers, before
    any daemon check — the nested daemon answering could be the impostor) and
    probed inside every box it examines. The existing "egress broken but DNS
    fine" split now names itself as #80's fingerprint (the impostor dnsmasq on
    a captured gateway keeps resolving while IP egress dies), and the admin ACL
    section verifies the gateway carve-out matches boxnet's actual gateway.
    The agent-context guard for the templates (suggested fix 4) lands in
    heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).

  • The staging template (#81, the re-cut of #69's layering) — a
    server-class, creds-free seed: Debian 13, user ops, tmux, rig,
    BOX_REQUIRE_VM=1 (the VM is its trust boundary), BOX_AUTOSTART=1 (a
    server returns from a host reboot without an operator), and
    BOX_BOOTSTRAP_ROLE="staging" — the server posture (docker, sshd
    hardening) converges via rig bootstrap staging after mint. The tailnet
    workload join holds a pre-auth key and therefore stays operator-run
    (box shell → sudo rig bootstrap workload), printed as a next step —
    box never sees the key.

  • BOX_BOOTSTRAP_ROLE template key + mint-time auto-run (#81) — a
    template names the creds-free rig tenant role box runs inside the guest
    after cloud-init settles (incus exec … rig bootstrap <role>); the value
    is a role name by allowlist (anything shell-shaped dies at parse time, on
    the host). A failed role leaves the box up and names the re-run — the roles
    are convergent by contract (rig#31). blank names no role and auto-runs
    nothing.

  • The rig pin point: RIG_REPO / RIG_REF (#81) — the tenant seeds
    preinstall rig, inverting the rig→box install edge (rig#28), and the new
    edge gets the same honest treatment rig#29 gave box's unpinned install:
    @RIG_REPO@/@RIG_REF@ tokens in the seed resolve at mint from the
    environment (default heavy-duty/rig @ main — unpinned, tracking main,
    until a release flow exists, rig#32/#83). The pin covers both the installer
    fetched and the tree it installs, so a rig branch under review is testable
    end to end; values are allowlist-validated before touching the YAML.

  • Server-posture template keys (#81, carved from #69) — two optional
    box.env allowlist keys. BOX_REQUIRE_VM=1 refuses both the silent
    container fallback (no /dev/kvm, exit 1) and an explicit --container
    (exit 2): such a template's trust boundary is the VM. BOX_AUTOSTART=1
    stamps boot.autostart=true at launch, per-instance like limits.*, so
    the box returns from a host reboot without an operator; clones inherit it
    via incus copy. Still no key for a network or a security.* flag, on
    purpose.

  • Dynamic template test suite (#81, carved from #69) — test/cli.sh
    discovers templates/*/ instead of hardcoding the list, so a new template
    cannot ship unseen. Per template: box.env is driven through the real,
    extracted load_template (unknown keys and missing BOX_IMAGE/BOX_USER
    fail, fixtures proving both dies); user-data.yaml exists, declares
    #cloud-config, parses as YAML, and installs tmux (#65). Grep guards pin
    the cmd_new half: the REQUIRE_VM refusal orders after pick_mode, and
    boot.autostart is stamped only under the T_AUTOSTART guard.

  • box export / box import (#70) — a box's state that survives the box
    and the host, unblocking #66's humane upgrade flow (down, export, rm,
    upgrade, re-import). box export <box> [<file>] wraps incus export into
    one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
    included by default (--instance-only opts out); the box must be stopped
    first (box down) so the artifact is a settled disk, not a moving one. The
    file is shouted about, not scrubbed — it carries the box's whole disk
    (agent logins, git credentials, SSH keys), and scrubbing a disk image is a
    promise tarball surgery cannot keep, so box says what is inside instead,
    every time. box import <file> [--name <box>] mints the box back and
    re-stamps what is the current host's truth, not the artifact's: the
    user.box=1 boundary tag (legacy user.claudebox=1 honored), the
    box-net placement (re-assigned if the artifact's differs — the
    migrate-host move), and a fresh machine identity: the NIC's MAC (imports
    restore volatile.* verbatim, and a re-import beside its sibling collided
    at start with "MAC address already defined on another NIC" — measured
    live; incus copy regenerates it, incus import does not) plus
    reset_identity (the clone trust boundary: no DHCP collision with the box
    it was exported from).
    Import refuses any name an existing instance holds — the resolve_box
    boundary, seen from the other side. Works on both tiers: box grant now
    also converges restricted.backups allow (incus-user blocks backups by
    default exactly like snapshots, and an export is a backup
    create+download — measured against incus 6.0's permissions.go); re-run
    box grant <user> after upgrading, as documented. CI's rehearsal job now
    proves the round-trip on a live Incus: mint → write a file → snapshot →
    down → export → rm → import → the file and the snapshot survived, the
    agent answers, the tag is present, and a colliding re-import is refused.

  • Versioned installs (#66's stance, made livable) — install.sh now lands
    each version side by side at <root>/versions/<v> (its own VERSION +
    INSTALLED_FROM), with a current symlink tracking the default and
    $BINDIR/box riding the chain, the way plenty of CLIs manage theirs. New
    verbs: box versions (lists installs, marks the current default and the
    running tree), box use <version> (flips the default, converges the PATH
    symlinks, and asserts the effective result — current must resolve to
    the asked-for version and the chain's box --version must answer it).
    Re-running the installer with an installed version is a converging no-op
    (BOX_REINSTALL=1 replaces that version's tree); a new version installs
    side-by-side and flips current only when no boxes exist — under existing
    boxes the flip is refused loudly, naming the boxes (#66: never change
    versions under a user's boxes; box use keeps the same refusal). A
    pre-0.7.0 flat tree is migrated in place (two renames, the operator's
    tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
    or dangling $BINDIR/box is healed instead of wedging the install; and the
    installer warns when the other tier's install (/opt/box vs ~/.local)
    coexists, since PATH order decides which wins.

  • A real uninstall — box uninstall [<version>] [--all] [--purge-host]
    replaces the "rm -rf two paths" prose. One version: refuses the current one
    (box use off it first). Everything: runs in the safe order — refuses
    while boxes exist (naming them) unless --purge-host runs teardown-host
    first — then removes every version, the current and PATH symlinks, and
    the legacy claudebox crumbs (both name generations), and ends with an
    absence assert
    : every removed path is re-checked, and any survivor makes
    it exit 1 as uninstall INCOMPLETE naming the leftovers (the
    revoke --purge discipline). teardown-host.sh gains --yes/BOX_YES=1
    for automation and now points at box uninstall when done.

  • BOX_INSTALL_SOURCE=<dir-or-tarball> — installs from a local tree,
    bypassing the download. CI's rehearsal job now installs via install.sh
    itself (proving the installer under review, not a cp -r mimic of it), and
    ends with an uninstall drill: grant + revoke --purge a throwaway
    user, teardown-host, box uninstall --all, then assert zero residue
    — no networks, profiles, ACLs, nft tables, systemd units, files or
    symlinks.

  • test/cli.sh drives real installs — still dependency-free, non-root, no
    daemon: BOX_INSTALL_SOURCE + throwaway BOX_HOME/BOX_BIN roots and a
    fake incus on PATH ($FAKE_BOXES) turn layout, chain, no-op/converge,
    reinstall, side-by-side upgrade, the three #66 refusals (install flip,
    use, uninstall — boxes named), flat-tree migration, symlink healing,
    single-version and zero-residue uninstalls, and the INCOMPLETE scream
    into driven tests instead of greps (154 checks).

Changed

  • Thin templates — box mints, rig converges (#81, companion rig#31) —
    the tenant content that lived in claude/codex/grok's cloud-init (the
    agent CLI installs, docker, node, the per-template agent-context heredocs)
    moves to rig's bootstrap roles, where it is convergent, idempotent and
    testable end to end instead of parse-only YAML. What remains per template
    is a thin, creds-free seed: the tenant user, tmux (#65), and rig
    preinstalled — nothing that joins a tailnet or admits credentials. The #80
    agent-context guard ("never run box setup-host or the drill inside a
    box") now lives once, in rig's roles, not copy-pasted per template. The
    template test sweep grew the contract's teeth: per-template seed asserts
    (user matches, rig pinned via both tokens) and fail-closed absence
    greps
    over effective cloud-init lines — no agent CLI, no docker, no
    tailscale/authkey/ssh, no write_files heredocs — so tenant content
    cannot quietly grow back.

Fixed

  • A wedged incus launch fails loudly, not forever — the mint's launch
    phase is narrated and time-boxed
    (#93) — twice in the 2026-07-19
    release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
    the child incus launch under box new hung with no server-side
    operation
    : incus operation list empty, the instance never created, the
    daemon journal quiet — one wedge ran 56 minutes before being killed by
    hand, and an immediate retry of the identical command succeeded in
    minutes, both times. box new inherited that as an indefinite silent
    hang, indistinguishable from a cold mint working. It now prints
    launching instance … before the call, and the call rides
    timeout -k 5 $BOX_LAUNCH_TIMEOUT (seconds, default 600 — generous: the
    coldest measured mint is minutes, never an hour; the same scripting-knob
    shape as BOX_CPU/BOX_MEMORY), with stdin pinned per the drill's own
    trap list. On the budget firing it probes whether the instance was ever
    registered and tells the two stories apart — the measured #93 wedge (no
    server-side operation; an immediate retry has been observed to succeed)
    vs a slow launch that overran the budget with the instance already
    created — then best-effort deletes either way, so the retry advice is
    clean in both worlds, and points at box doctor for the host. The
    --from clone path is untouched: incus copy of a local instance is a
    different operation and has never been observed to wedge this way.
  • UFW's gateway carve-out converges with the bridge, and the doctor can
    see it
    (the #86 review's blind spot) — box-firewall gated its whole
    UFW block behind "a DENY on boxnet rule exists", pinning every UFW host
    to the gateway of the first run: a bridge remapped off a colliding
    subnet (#80's escape hatch) kept its stale allow … to <old-gw> port 53
    and never gained the live gateway's, so box→gateway DNS died at box's own
    deny — while the doctor's carve-out check read only the incus ACL (which
    setup-host converges) and called the host clean. The UFW allows now
    converge off the live bridge address on every run (stale DNS allows
    deleted, the live set ensured — ufw skips existing rules, so a fresh host
    gets the identical rule set and a re-run is a no-op), and box doctor
    reads UFW's own table wherever UFW is active, flagging a DNS allow that
    does not match boxnet's gateway (and stale allows left beside a live
    one). The no-UFW nft carve-out never had this failure mode: it is
    interface-scoped, no gateway address to go stale.
  • The boot-time gateway fallback is gone — no rule beats a wrong one —
    with the bridge not yet addressed when box-firewall.service ran,
    box-firewall guessed GW=10.88.0.1; on a BOX_SUBNET host that hit
    that window the UFW carve-out was built for the wrong gateway, a latent
    DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves
    the persisted UFW rules exactly as they are (they survive boots on their
    own, and nothing else in the script needs the gateway) and says so on
    stderr; the next setup-host run or service restart converges them once
    the bridge is addressed.
  • revoke --purge re-checks the incus-user state — the purge removed
    /var/lib/incus/users/<uid> without ever asserting its absence, the one
    path its own absence block did not cover; and the stat now rides
    $SUDO test -d (/var/lib/incus is not traversable by a non-root admin,
    so a bare [ -d ] answered "absent" for a directory that was there).
  • A wedged $BINDIR/box no longer blocks installing — the old
    no-op-if-installed check keyed off the symlink's existence OR the tree's,
    so a stale symlink (or a half-removed tree) could fake "already installed"
    forever. Installed-ness is now judged from versions/<v> itself; symlinks
    are converged with ln -sfn, never trusted as the signal.