-
0.7.0 Stable
released this
2026-07-19 14:24:18 +00:00 | 109 commits to main since this releaseAdded
-
The installer defaults to the latest release, and releases publish
themselves (#83) —curl | bashused to hand out whatevermainwas 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.shnow
resolves the latest release tag by following GitHub'sreleases/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_REFas the way out — it never hangs and never silently falls back to
main. A setBOX_REFis 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 newrelease.yml(on a bareX.Y.Ztag
push — the0.6.0tag set the no-vprecedent) asserts the tag names the
tree's ownVERSION(a mismatch fails loudly and creates nothing) and
publishes the GitHub release with that version'sCHANGELOG.mdsection 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). Andmain'sVERSIONnow carries-devbetween releases
(this PR:0.6.1-dev): the versioned layout names install trees after
VERSION, so amaininstall without the bump would land in
versions/0.6.0and 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-hostauto-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 barebox setup-hostnow decides the subnet
itself, in four deliberate cases: an explicitBOX_SUBNETis honored or
refused, never silently overridden (scripted hosts keep exact semantics);
an existingboxnetbridge 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 free10.88.0.0/24stays the default; and a
claimed default — the nested case: a drill or rehearsal running inside a
box, whose own uplink owns 10.88 — scans10.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-hostrefuses a claimed subnet, andBOX_SUBNETpicks another
(#80) — run inside a box,setup-hostused to build a nestedboxneton
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-hostnow
scans the target subnet before any mutation — the default route's
gateway inside it, or any non-boxnetinterface holding an address in it —
and refuses, naming the way out. A priorboxnetowning the subnet is the
legitimate converge path and does not trip it.BOX_SUBNET=<a.b.c.0/24>
(validated, alongside the existingBOX_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-firewallreads
the gateway off the live bridge), and every drill/migrate probe that used
to hardcode10.88. -
box doctorknows the #80 signature — a default gateway held as a
LOCAL address, and duplicate connected routes for the uplink subnet, judged
fromip route/ip addron 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 matchesboxnet'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
stagingtemplate (#81, the re-cut of #69's layering) — a
server-class, creds-free seed: Debian 13, userops, 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 viarig bootstrap stagingafter 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_ROLEtemplate 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).blanknames 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 (defaultheavy-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.envallowlist keys.BOX_REQUIRE_VM=1refuses 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
stampsboot.autostart=trueat launch, per-instance likelimits.*, so
the box returns from a host reboot without an operator; clones inherit it
viaincus copy. Still no key for a network or asecurity.*flag, on
purpose. -
Dynamic template test suite (#81, carved from #69) —
test/cli.sh
discoverstemplates/*/instead of hardcoding the list, so a new template
cannot ship unseen. Per template:box.envis driven through the real,
extractedload_template(unknown keys and missingBOX_IMAGE/BOX_USER
fail, fixtures proving both dies);user-data.yamlexists, declares
#cloud-config, parses as YAML, and installs tmux (#65). Grep guards pin
thecmd_newhalf: theREQUIRE_VMrefusal orders afterpick_mode, and
boot.autostartis stamped only under theT_AUTOSTARTguard. -
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>]wrapsincus exportinto
one portable backup tarball (default<box>-<UTC stamp>.tar.gz), snapshots
included by default (--instance-onlyopts 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=1boundary tag (legacyuser.claudebox=1honored), the
box-netplacement (re-assigned if the artifact's differs — the
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
restorevolatile.*verbatim, and a re-import beside its sibling collided
at start with "MAC address already defined on another NIC" — measured
live;incus copyregenerates it,incus importdoes 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 — theresolve_box
boundary, seen from the other side. Works on both tiers:box grantnow
also convergesrestricted.backups allow(incus-user blocks backups by
default exactly like snapshots, and an export is a backup
create+download — measured against incus 6.0'spermissions.go); re-run
box grant <user>after upgrading, as documented. CI'srehearsaljob 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 ownVERSION+
INSTALLED_FROM), with acurrentsymlink tracking the default and
$BINDIR/boxriding 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 —currentmust resolve to
the asked-for version and the chain'sbox --versionmust answer it).
Re-running the installer with an installed version is a converging no-op
(BOX_REINSTALL=1replaces that version's tree); a new version installs
side-by-side and flipscurrentonly 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 usekeeps 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/boxis 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 useoff it first). Everything: runs in the safe order — refuses
while boxes exist (naming them) unless--purge-hostruns teardown-host
first — then removes every version, thecurrentand 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 asuninstall INCOMPLETEnaming the leftovers (the
revoke --purgediscipline).teardown-host.shgains--yes/BOX_YES=1
for automation and now points atbox uninstallwhen 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 acp -rmimic of it), and
ends with an uninstall drill: grant +revoke --purgea 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+ throwawayBOX_HOME/BOX_BINroots and a
fakeincuson 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 theINCOMPLETEscream
into driven tests instead of greps (154 checks).
Changed
- Thin templates — box mints, rig converges (#81, companion rig#31) —
the tenant content that lived inclaude/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 runbox setup-hostor 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, nowrite_filesheredocs — so tenant content
cannot quietly grow back.
Fixed
- A wedged
incus launchfails 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 childincus launchunderbox newhung with no server-side
operation:incus operation listempty, 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 newinherited 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 asBOX_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 atbox doctorfor the host. The
--fromclone path is untouched:incus copyof 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-firewallgated its whole
UFW block behind "aDENY on boxnetrule 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 staleallow … 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), andbox doctor
reads UFW's own table wherever UFW is active, flagging a DNS allow that
does not matchboxnet'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 whenbox-firewall.serviceran,
box-firewallguessedGW=10.88.0.1; on aBOX_SUBNEThost 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 --purgere-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/incusis not traversable by a non-root admin,
so a bare[ -d ]answered "absent" for a directory that was there).- A wedged
$BINDIR/boxno 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 fromversions/<v>itself; symlinks
are converged withln -sfn, never trusted as the signal.
Downloads
-
Source code (ZIP)
1 download
-
Source code (TAR.GZ)
1 download
-