• 0.9.0 86af660818

    0.9.0 Stable

    github-actions[bot] released this 2026-07-21 18:06:38 +00:00 | 12 commits to main since this release

    Added

    • box import stamps the trip, leaving the artifact's own mint stamp intact
      (#131)
    • A minted box records how it was minted, and box info reads it back (#103)
    • A clone re-stamps its own provenance instead of inheriting its source's
      (#103)
    • box info grew a provenance block, blank on boxes that predate the stamp
      (#103)
    • Every fresh mint marks a pristine snapshot, before rig converges anything
      (#104, heavy-duty/rig#62)
    • A mint that converges a tenant role marks a bootstrapped snapshot (#130)
    • CI refuses a release PR with no drill record at drills/<version>.md

    Changed

    • state:needs-human is set at handoff, not by the cron (#141)
    • PR labels split into two axes: state:* (whose ball) and blocker:* (what
      is in the way); state:needs-rebase is retired
    • BREAKING: the tenant templates carry rig's family suffix — claude
      claude-box, codexcodex-box, grokgrok-box, staging
      staging-box (#123, heavy-duty/rig#76)
    • Changelog entries are one line each, and the whole file now follows the rule
      (#147)

    Fixed

    • test/release.sh is green on the release ceremony's own tree
    • changelog-monotonic.sh no longer lets a duplicate heading through when it
      cannot see the base (#143)
    • An unreadable check rollup no longer reads as "nothing is failing"
    • state:needs-human no longer appears on PRs a human cannot merge (#136)
    • CI's shellcheck sweep now lints .github/scripts/*.sh (#116)
    • A PR can no longer delete or duplicate a shipped changelog section and stay
      green (#122)
    • An upgrade over a pre-0.7.0 flat /opt/box no longer skips host setup (#115)
    • Host setup runs the version it just installed, not whatever current points
      at (#115)
    • The pre-0.7.0 migration says what it left behind, and how to keep or reap it
      (#117)
    • teardown-host.sh refuses a terminal-less run instead of aborting mute
      (#113)
    • drill/wipe.sh no longer carries #102's SIGPIPE shape, and the pin sweeps
      the class (#107)
    • The racing-reader sweep guards the class, not one spelling, and names
      incus config trust list as a second writer (#124)
    Downloads
  • 0.8.0 c4f62e5d87

    0.8.0 Stable

    github-actions[bot] released this 2026-07-19 23:04:02 +00:00 | 79 commits to main since this release

    Added

    • Merging the release PR IS the release — and the release re-arms main
      itself
      (#96) — the 0.7.0 ceremony ended in an absence: the release PR
      merged with four approvals and nothing happened, correctly, because
      publishing hung off a separate, manual, silent-when-forgotten tag push —
      a failure shape with no error and no red X. The ship decision already
      lives in the release PR (the one PR whose whole diff is "the version
      leaves -dev"), so release.yml now fires on pushes to main
      (fork-sourced ceremony PRs get a read-only token on pull_request
      events), reading the transition from the push itself: event.before to
      the pushed head. A decide step answers four states — release-flow work
      merged under the release label (-dev endstates, the post-release
      window) no-ops green with a NOTICE; the two genuinely ambiguous bare
      states refuse loudly; a true transition then requires a merged,
      release-labeled PR behind the commit (read via the API — the label is
      the operator's declared intent) before anything is created. Then, in the
      same job, it tags the merge commit via the API, publishes — and bumps
      main to X.Y.(Z+1)-dev itself, direct push with a loud open-a-PR
      fallback, so no follow-up bump PR exists on the paved road. Same-job on
      purpose: a GITHUB_TOKEN-created tag triggers no workflows, which is
      also what makes double-publish impossible. The tag-push path stays
      unchanged as the documented manual fallback and backfill (it shipped
      0.7.0 itself). test/release.sh grep-pins the gate, every decide
      verdict, the single on.push key, and the same-job tag+publish+re-arm
      in the same daemon-free, fail-closed style.

    Fixed

    • The release ceremony re-arms CHANGELOG.md, and CI refuses to let
      main sit disarmed
      (#108) — the ceremony stamps ## Unreleased into
      ## X.Y.Z — DATE by hand, and nothing put the heading back, so main
      sat with no ## Unreleased from the release until the next PR that
      happened to re-create one. A PR authored before the release wrote its
      entry under ## Unreleased; with that heading gone, git lands the entry
      under whatever now occupies the position — the section that just
      shipped
      — and it merges cleanly. No conflict, no error, no red X:
      the one signal an author would trust is absent exactly when the outcome
      is wrong, and the changelog credits a released version with a change it
      does not contain until a human reads the file. Confirmed in the sibling
      repo (heavy-duty/rig#66); box has not drifted yet, and the reason is
      luck rather than design — 0.6.0's ceremony (77599ab) added its heading
      without removing ## Unreleased, so main was never disarmed, while
      0.7.0 did disarm it and left a window that nothing happened to cross.
      Two halves land together. The ceremony step in CONTRIBUTING.md is now
      explicitly two edits: stamp, then put an empty ## Unreleased back
      above the section just stamped — it belongs there and not in
      release.yml, which only ever touches VERSION. And
      .github/scripts/changelog-armed.sh enforces it in CI, keyed on
      VERSION because the two states are genuinely different: a -dev tree
      must carry ## Unreleased on top, a bare-VERSION tree (the ceremony
      PR, and the merge that publishes it) may carry either that or its own
      stamped section. The keying is the whole design and not an
      over-complication — box previously had no top-section guard at all,
      and the obvious one, an unconditional ## Unreleased requirement, is
      false by construction on the ceremony PR's own tree, which is why rig#44
      and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm
      does not block the release; it turns main red on the very next push,
      the automatic -dev bump the release itself makes. Leaving the bare
      branch's top heading unconstrained is what keeps both ceremony shapes
      legal, and a review round on the sibling fix (heavy-duty/cast#114) found
      the gap that asymmetry leaves: a half-ceremony tree — VERSION
      bumped, ## Unreleased still populated on top, and the section for that
      version never stamped — makes the wrong-number test false on its first
      clause, short-circuits, and passes. Nothing then refuses until
      release.yml extracts the notes, which is after the merge, on main,
      with the release already half-shipped. So the bare branch now also
      requires that the section it is about to publish exists and is non-empty,
      and it asserts that by running release-notes.sh — the very script
      release.yml runs — so the guard and the publisher cannot drift apart
      over what a section is. The message is its own: a missing stamp is not a
      misnumbered one, and an operator sent to correct a version number that is
      already right will not find the real problem. Matches
      heavy-duty/rig#67, so the three repos agree.

    • Ctrl-D at a confirmation prompt aborts out loud, instead of exiting
      in silence
      (#111) — confirm() and uninstall_confirm() both took
      the operator's answer with a bare read -r reply. Every answer a
      human can type routes through the case below it and ends at a
      return or at die "aborted." — every answer except EOF. Ctrl-D
      makes read return non-zero, set -euo pipefail ends the run on that
      line, and the case is never reached: box exits 1 having printed
      nothing at all after the question it just asked. It fails closed,
      which is why this is a small fix and not an incident — nothing is
      destroyed, the abort is real. The damage is that the tool goes mute at
      the one moment it had the operator's full attention, and someone who
      Ctrl-Ds out of box rm work cannot tell from the output whether the
      box is still there. The cure is one token in each function,
      read -r reply || die "aborted.", the same one heavy-duty/rig#43
      applied to rig's credential prompts so the two repos read alike. The
      bug predates everything it touches — rm has carried a confirm gate
      for as long as the verb has existed — but #105 took the number of
      verbs reaching that line from one to two, and both are irreversible,
      which is the argument for closing it now rather than the next time
      someone notices. The three answers a human can actually give (y,
      n, and Ctrl-D) are now driven for real on a pty via util-linux
      script: they were structurally untested before, because [ -t 0 ]
      sends a terminal-less suite to the refusal branch and every existing
      check stopped there — which is exactly how this survived four
      releases. Review caught that the first pass fixed the bug where it was
      reported and stopped there, while the same defect sat at two more
      destructive gates in this repo: host/revoke-user.sh:50, the prompt
      guarding box revoke --purge — the one whose own text says "this
      cannot be undone" — and host/teardown-host.sh:31, guarding a full
      host teardown. Both run under set -euo pipefail, both died mute on
      EOF with their aborted line never reached; both now carry the guard
      in their own script's wording. The three drill/ prompts are
      deliberately left alone — they run under set -u only, so EOF falls
      through to the *) arm and already aborts out loud — and
      install.sh:65 was already guarded. What keeps the class closed is a
      repo-wide sweep in test/cli.sh: every statement-initial read fed
      from stdin, in any file that turns on errexit, must carry a ||
      guard, with while read loops and <<< herestrings excluded because
      neither is a prompt. The sweep flags all four sites when their guards
      are removed and nothing else across the tree's fifteen shell files —
      the absence of exactly this check is why the host/ pair was missed
      in the first place.

    • box restore asks before it destroys — and the confirmation prompt is
      now the row's, not rm's
      (#105) — restore and rm both irreversibly
      discard user state, and only one of them asked. The table gave restore
      the preconditions box,arg2: the instance is ours, a snapshot name is
      present, go. So box restore work stale-label silently threw away
      everything done in the box since that snapshot, with no prompt, no
      --force, and no way to take it back — a warning in --help is not a
      gate. It has been that way since the verb shipped, and it is about to
      become routine rather than rare (heavy-duty/rig#62's pristine snapshot),
      which is the wrong time to still be relying on the operator typing the
      right label. The reason it stayed ungated is worth recording, because it
      is the actual bug: confirm was already a precondition token, but the
      dispatch line hardcoded the wordsconfirm "delete $inst and all its snapshots" — so the one-token fix would have gated restore behind a
      prompt offering to DELETE the box the operator was trying to rescue. A
      gate that names the wrong act is worse than no gate; it is how people
      learn to answer y without reading. So the prompt moved into the table
      as a seventh field, each row saying what it is about to do in its own
      words, and restore now asks to "roll <box> back to snapshot
      <label> and discard everything in the box since it was taken" — naming
      the label, because picking the wrong one is the whole risk. rm's
      wording is unchanged and pinned verbatim by a test, since rewording the
      one verb that already worked would be a regression shipped as a
      refactor. A row marked confirm with no words is now a hard internal
      error rather than a blank question. --force and the no-TTY refusal come
      free — confirm() already had both. The one automated caller had to
      consent explicitly: drill/multiuser.sh drives restore unattended on real
      Incus and now passes --force, which is the rehearsal proving the gate
      rather than working around it — the CI run of this very PR failed there
      first, which is the shape a gate is supposed to have. Coverage went from two
      argument-validation checks that never reached dispatch to the destructive
      path itself, driven against a fake incus: refusing leaves the call log
      empty, --force produces exactly one incus snapshot restore. Not
      changed, deliberately: restore still does not require the box stopped
      (#105 makes that case separately and it deserves its own call), and
      --help now says plainly that a rollback of a running box is
      crash-consistent, because these snapshots are stateless.

    • box-firewall could hand a UFW host the no-UFW firewall, ~2% of the
      time
      (#102) — filed as an intermittent test flake (test/cli.sh's
      fresh-UFW block going four-assertions-red on an unmodified main,
      measured here at 5 failing runs in 40), it was not one. The branch that
      decides the host's entire firewall stance read
      ufw status | grep -q "Status: active", and Status: active is the FIRST
      line ufw prints: grep -q matches it and exits immediately, closing the
      pipe while ufw is still writing the rest of the table, so ufw dies of
      SIGPIPE. grep returned 0, but under this script's set -o pipefail the
      PIPELINE returns 141 — the if reads false and a host with UFW plainly
      active takes the nft-fallback branch, never building the DNS carve-out its
      persisted rules depend on. A pure scheduling race, isolated at ~2% per
      invocation (PIPESTATUS = 141 0; a draining reader flakes 0/2000, a
      reader whose match is on the last line flakes 0/2000). Real ufw is a
      slower, longer writer than the test shim, so production had no reason to
      be safer. ufw status is now read ONCE into a variable and matched with
      [[ ]] — no reader, no race — and the stale-rule scan reads that same
      snapshot, so the branch decision and the converge loop can no longer
      disagree. host/teardown-host.sh carried the same live defect and is
      fixed with it: that file does set pipefail (line 12), so its UFW
      crumb-removal branch could read a plainly-active UFW as inactive and skip
      silently, leaving stale boxnet/claudenet rules on a host the operator
      was told is clean — and its numbered-delete loop had the same early-exit
      reader as its condition, so it could end while rules remained. Both now
      read captures. The sibling calls in drill/wipe.sh and drill/doctor.sh
      are the same shape but set only set -u, so the SIGPIPE is discarded
      there and the branch holds — latent, not live, until either gains
      pipefail.

    • A missing firewall log now diagnoses itself (#102) — the four greps
      reading $WFW/*.log used to fail together with empty output when the
      driving run took the wrong branch, a signature that looks specific and
      says nothing (#102 was filed reading it as "the log is not written";
      the log existed, the mutations did not, and that distinction was the
      diagnosis). test/cli.sh now asserts the precondition explicitly before
      the content greps and, on failure, prints the contents of $WFW, the log
      itself, and the stderr of the run that should have written it. It also
      keeps an agreeing UFW host deletes nothing honest: that check asserts an
      absence, which a run that did nothing at all passes for the wrong reason.

    • box grant provisions an incus-admin member instead of refusing them
      (#99) — the refusal read "they already have the admin tier; there is
      nothing tighter to grant", which is true about permission and silent
      about provisioning: the incus group is indeed a strict subset of what
      incus-admin opens at the daemon API, but the user-<uid> project, the
      boxnet narrowing, the snapshot and backup allowances, and the box-net
      profile installed into that project are none of them permissions, and an
      incus-admin member had none of them — box_tier() resolves them to
      admin, so they worked in the shared default project next to root and every
      other admin, with no world of their own and no supported way to get one.
      box grant now runs the full convergence for them.

      The group step is part of that convergence, not an exception to it: an
      incus-admin member is added to incus like anyone else. The subset
      argument holds for the API and fails at the filesystem, which is where
      it matters here — the two sockets are two files with two owning groups
      (Debian 13 / Incus 6.0.4, measured):

      socket group mode
      /var/lib/incus/unix.socket incus-admin 0660
      /var/lib/incus/unix.socket.user incus 0660

      incus-admin opens the first and not the second, and only the second
      provisions a user-<uid> project. Without the membership the provisioning
      touch takes EACCES, the swallowing || true hides it, no project appears,
      and the grant dies blaming a perfectly healthy incus-user — the exact
      incus-admin-only user #99 is about, left no better off. So the membership is
      granted, and the grant says out loud why: it is the key to a file, not a new
      privilege (box_tier() still reads them as admin, both-groups → admin).

      The touch itself is pinned at incus-user's socket: the incus client picks
      by writability (client/connection.go — the daemon socket when writable,
      unix.socket.user only otherwise), so for an incus-admin member an
      unpinned touch sails past incus-user and provisions nothing. The user-side
      proof that closes the grant names their project for the same reason, since an
      unqualified profile show would have answered from the shared default
      project and proved nothing. The socket's existence is probed through $SUDO,
      not a bare [ -e ]/var/lib/incus is not traversable by a non-root
      admin, so an unprivileged stat reports a present socket as absent, and this
      probe exits on absent (the discipline box revoke already documents).

      On success the grant prints the caveat the hard exit was gesturing at, in the
      two forms it actually takes: the restrictions are a default placement, not
      a confinement
      (admin membership still wins at the socket — the default
      project and other users' instances stay one flag away), and until
      incus-admin goes their own box commands keep landing in the default
      project. Dropping incus-admin then lands them in their ready project with
      no re-grant — a promise that is only true because they keep incus;
      without it that drop would leave them in neither group, box_tier() none,
      and a converged project they could not open. The failure path follows: the
      membership this run added is rolled back and verified, while the backout
      refuses to call that a lockout — incus-admin is untouched and still opens
      every project.

      box revoke mirrors it. A bare revoke of a granted incus-admin member now
      takes the incus membership back and reports partial: — the socket key
      box grant added is gone, their project is kept, and they are explicitly
      not locked out. An incus-admin member who was never granted is still a
      named no-op that makes no privileged call at all. --purge unmakes the
      provisioning while refusing to call them "out". Every path names
      gpasswd -d <user> incus-admin as the only thing that ends their access.

      Unblocks rig's users apply (heavy-duty/rig#49), which had to call box grant for a user who is both incus-admin by hand and role box in the
      fleet file. Driven end to end in test/cli.sh under logging incus/sudo shims
      — every assertion is made against what the run did, not what the source says
      it would — and, because those shims model neither INCUS_SOCKET nor file
      permissions and so cannot reproduce the EACCES, measured on real Incus in
      CI by a new drill/multiuser.sh criterion (o): an incus-admin-only member
      is granted, the membership lands, the project appears, unix.socket.user
      opens as them, and dropping incus-admin leaves them in their own project
      with no re-grant.

    Downloads
  • 0.7.0 fb7fc845bf

    0.7.0 Stable

    github-actions[bot] released this 2026-07-19 14:24:18 +00:00 | 109 commits to main since this release

    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/2410.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 shellsudo 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 resultcurrent 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 uninstallbox 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.
    Downloads
  • 0.6.0 77599ab3ee

    0.6.0 Stable

    danmt released this 2026-07-18 13:39:45 +00:00 | 170 commits to main since this release

    What's Changed

    New Contributors

    Full Changelog: https://github.com/heavy-duty/box/commits/0.6.0

    Downloads