-
0.9.0 Stable
released this
2026-07-21 18:06:38 +00:00 | 12 commits to main since this releaseAdded
box importstamps the trip, leaving the artifact's own mint stamp intact
(#131)- A minted box records how it was minted, and
box inforeads it back (#103) - A clone re-stamps its own provenance instead of inheriting its source's
(#103) box infogrew a provenance block, blank on boxes that predate the stamp
(#103)- Every fresh mint marks a
pristinesnapshot, before rig converges anything
(#104, heavy-duty/rig#62) - A mint that converges a tenant role marks a
bootstrappedsnapshot (#130) - CI refuses a release PR with no drill record at
drills/<version>.md
Changed
state:needs-humanis set at handoff, not by the cron (#141)- PR labels split into two axes:
state:*(whose ball) andblocker:*(what
is in the way);state:needs-rebaseis retired - BREAKING: the tenant templates carry rig's family suffix —
claude→
claude-box,codex→codex-box,grok→grok-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.shis green on the release ceremony's own treechangelog-monotonic.shno 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-humanno 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/boxno longer skips host setup (#115) - Host setup runs the version it just installed, not whatever
currentpoints
at (#115) - The pre-0.7.0 migration says what it left behind, and how to keep or reap it
(#117) teardown-host.shrefuses a terminal-less run instead of aborting mute
(#113)drill/wipe.shno 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 listas a second writer (#124)
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
0.8.0 Stable
released this
2026-07-19 23:04:02 +00:00 | 79 commits to main since this releaseAdded
- 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"), sorelease.ymlnow fires on pushes to main
(fork-sourced ceremony PRs get a read-only token onpull_request
events), reading the transition from the push itself:event.beforeto
the pushed head. A decide step answers four states — release-flow work
merged under thereleaselabel (-devendstates, 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 toX.Y.(Z+1)-devitself, direct push with a loud open-a-PR
fallback, so no follow-up bump PR exists on the paved road. Same-job on
purpose: aGITHUB_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.shgrep-pins the gate, every decide
verdict, the singleon.pushkey, 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
mainsit disarmed (#108) — the ceremony stamps## Unreleasedinto
## X.Y.Z — DATEby hand, and nothing put the heading back, somain
sat with no## Unreleasedfrom 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 inCONTRIBUTING.mdis now
explicitly two edits: stamp, then put an empty## Unreleasedback
above the section just stamped — it belongs there and not in
release.yml, which only ever touchesVERSION. And
.github/scripts/changelog-armed.shenforces it in CI, keyed on
VERSIONbecause the two states are genuinely different: a-devtree
must carry## Unreleasedon top, a bare-VERSIONtree (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## Unreleasedrequirement, 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 turnsmainred on the very next push,
the automatic-devbump 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,## Unreleasedstill 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.ymlextracts the notes, which is after the merge, onmain,
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 runningrelease-notes.sh— the very script
release.ymlruns — 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()anduninstall_confirm()both took
the operator's answer with a bareread -r reply. Every answer a
human can type routes through thecasebelow it and ends at a
returnor atdie "aborted."— every answer except EOF. Ctrl-D
makesreadreturn non-zero,set -euo pipefailends the run on that
line, and thecaseis 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 ofbox rm workcannot 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 —rmhas 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
guardingbox revoke --purge— the one whose own text says "this
cannot be undone" — andhost/teardown-host.sh:31, guarding a full
host teardown. Both run underset -euo pipefail, both died mute on
EOF with theirabortedline never reached; both now carry the guard
in their own script's wording. The threedrill/prompts are
deliberately left alone — they run underset -uonly, so EOF falls
through to the*)arm and already aborts out loud — and
install.sh:65was already guarded. What keeps the class closed is a
repo-wide sweep intest/cli.sh: every statement-initialreadfed
from stdin, in any file that turns on errexit, must carry a||
guard, withwhile readloops 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 thehost/pair was missed
in the first place. -
box restoreasks before it destroys — and the confirmation prompt is
now the row's, not rm's (#105) —restoreandrmboth irreversibly
discard user state, and only one of them asked. The table gaverestore
the preconditionsbox,arg2: the instance is ours, a snapshot name is
present, go. Sobox restore work stale-labelsilently 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--helpis 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:confirmwas already a precondition token, but the
dispatch line hardcoded the words —confirm "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 answerywithout 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, andrestorenow 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 markedconfirmwith no words is now a hard internal
error rather than a blank question.--forceand the no-TTY refusal come
free —confirm()already had both. The one automated caller had to
consent explicitly:drill/multiuser.shdrives 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,--forceproduces exactly oneincus snapshot restore. Not
changed, deliberately:restorestill does not require the box stopped
(#105 makes that case separately and it deserves its own call), and
--helpnow says plainly that a rollback of a running box is
crash-consistent, because these snapshots are stateless. -
box-firewallcould 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 unmodifiedmain,
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", andStatus: activeis the FIRST
line ufw prints:grep -qmatches it and exits immediately, closing the
pipe while ufw is still writing the rest of the table, so ufw dies of
SIGPIPE.grepreturned 0, but under this script'sset -o pipefailthe
PIPELINE returns 141 — theifreads 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 statusis 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.shcarried the same live defect and is
fixed with it: that file does setpipefail(line 12), so its UFW
crumb-removal branch could read a plainly-active UFW as inactive and skip
silently, leaving staleboxnet/claudenetrules 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 indrill/wipe.shanddrill/doctor.sh
are the same shape but set onlyset -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/*.logused 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.shnow 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
keepsan agreeing UFW host deletes nothinghonest: that check asserts an
absence, which a run that did nothing at all passes for the wrong reason. -
box grantprovisions anincus-adminmember 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: theincusgroup is indeed a strict subset of what
incus-adminopens at the daemon API, but theuser-<uid>project, the
boxnet narrowing, the snapshot and backup allowances, and thebox-net
profile installed into that project are none of them permissions, and an
incus-adminmember 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 grantnow runs the full convergence for them.The group step is part of that convergence, not an exception to it: an
incus-adminmember is added toincuslike 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.socketincus-admin0660 /var/lib/incus/unix.socket.userincus0660 incus-adminopens the first and not the second, and only the second
provisions auser-<uid>project. Without the membership the provisioning
touch takesEACCES, the swallowing|| truehides 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 asadmin, 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.useronly otherwise), so for anincus-adminmember 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
unqualifiedprofile showwould have answered from the shared default
project and proved nothing. The socket's existence is probed through$SUDO,
not a bare[ -e ]—/var/lib/incusis not traversable by a non-root
admin, so an unprivileged stat reports a present socket as absent, and this
probe exits on absent (the disciplinebox revokealready 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-admingoes their ownboxcommands keep landing in the default
project. Droppingincus-adminthen lands them in their ready project with
no re-grant — a promise that is only true because they keepincus;
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-adminis untouched and still opens
every project.box revokemirrors it. A bare revoke of a grantedincus-adminmember now
takes theincusmembership back and reportspartial:— the socket key
box grantadded is gone, their project is kept, and they are explicitly
not locked out. Anincus-adminmember who was never granted is still a
named no-op that makes no privileged call at all.--purgeunmakes the
provisioning while refusing to call them "out". Every path names
gpasswd -d <user> incus-adminas the only thing that ends their access.Unblocks rig's
users apply(heavy-duty/rig#49), which had to callbox grantfor a user who is bothincus-adminby hand and roleboxin the
fleet file. Driven end to end intest/cli.shunder 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 neitherINCUS_SOCKETnor file
permissions and so cannot reproduce theEACCES, measured on real Incus in
CI by a newdrill/multiuser.shcriterion (o): anincus-admin-only member
is granted, the membership lands, the project appears,unix.socket.user
opens as them, and droppingincus-adminleaves them in their own project
with no re-grant.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Merging the release PR IS the release — and the release re-arms main
-
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)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
0.6.0 Stable
released this
2026-07-18 13:39:45 +00:00 | 170 commits to main since this releaseWhat's Changed
- Import claudebox: creds-free, trust-less Claude Code VMs by @dan-claude-bot in https://github.com/heavy-duty/box/pull/1
- fix: install from the public canonical repo by @dan-claude-bot in https://github.com/heavy-duty/box/pull/2
- feat: drop the claude- instance-name prefix by @dan-claude-bot in https://github.com/heavy-duty/box/pull/4
- fix: preinstall the GitHub CLI in boxes by @dan-claude-bot in https://github.com/heavy-duty/box/pull/5
- feat: add
claudebox list, with snapshot labels by @dan-claude-bot in https://github.com/heavy-duty/box/pull/9 - fix: standard help, honest flags, and an
rmthat asks first by @dan-claude-bot in https://github.com/heavy-duty/box/pull/10 - feat: make the command surface a table, add
renameand an escape hatch by @dan-claude-bot in https://github.com/heavy-duty/box/pull/13 - test: add an end-to-end drill against a real Incus by @dan-claude-bot in https://github.com/heavy-duty/box/pull/18
- fix: first-run drill defects — pipefail false FAILs, exec-pty hang, DHCP race by @dan-claude-bot in https://github.com/heavy-duty/box/pull/19
- fix: run-2 cascade (per-name cleanup), and claude on exec's PATH by @dan-claude-bot in https://github.com/heavy-duty/box/pull/20
- fix(drill): read eth0 from inside the box; settle the flaky dns.mode verdict by @dan-claude-bot in https://github.com/heavy-duty/box/pull/21
- fix(drill): no in-box probe can hang the run again by @dan-claude-bot in https://github.com/heavy-duty/box/pull/22
- fix(drill): delete every listener — refused-vs-dropped already answers it by @dan-claude-bot in https://github.com/heavy-duty/box/pull/23
- fix(drill): stop going silent through host setup, and keep the run log in the repo by @dan-claude-bot in https://github.com/heavy-duty/box/pull/24
- fix(drill): clean the host before setup-host, and bound it by @dan-claude-bot in https://github.com/heavy-duty/box/pull/25
- fix: a clone must not inherit its source's identity (two boxes, one IP) by @dan-claude-bot in https://github.com/heavy-duty/box/pull/27
- fix(drill): the NIC inside a VM is enp5s0, not eth0 — read the address by subnet by @dan-claude-bot in https://github.com/heavy-duty/box/pull/28
- fix: the clone identity reset could never reboot, so it never took effect by @dan-claude-bot in https://github.com/heavy-duty/box/pull/30
- fix(drill): refuse to judge #16 on a broken baseline by @dan-claude-bot in https://github.com/heavy-duty/box/pull/31
- fix(drill): stop poisoning the host, and add a doctor to prove it by @dan-claude-bot in https://github.com/heavy-duty/box/pull/32
- fix: a failed cold mint must say why; doctor gains DNS diagnosis and --pin-dns by @dan-claude-bot in https://github.com/heavy-duty/box/pull/34
- fix(doctor): check that something is actually serving the network by @dan-claude-bot in https://github.com/heavy-duty/box/pull/35
- fix: boxes could reach each other — isolate them at the bridge by @dan-claude-bot in https://github.com/heavy-duty/box/pull/37
- fix: isolate boxes with the bridge's port-isolation flag by @dan-claude-bot in https://github.com/heavy-duty/box/pull/38
- fix(doctor): read the isolation off the bridge, not off the config by @dan-claude-bot in https://github.com/heavy-duty/box/pull/39
- docs(drill): the audit is complete — boxes are not isolated from each other by @dan-claude-bot in https://github.com/heavy-duty/box/pull/36
- fix(doctor): four checks that lied, and none of them about a real fault by @dan-claude-bot in https://github.com/heavy-duty/box/pull/40
- fix(drill): read curl's message — the exit code cannot tell you what happened by @dan-claude-bot in https://github.com/heavy-duty/box/pull/41
- fix(doctor): pin the probes' stdin — an interactive exec cannot be timed out by @dan-claude-bot in https://github.com/heavy-duty/box/pull/42
- fix(doctor): the gateway does not answer ping — by design, so stop asking by @dan-claude-bot in https://github.com/heavy-duty/box/pull/43
- fix(drill): stop demanding an empty host — assert OUR boxes, not NO boxes by @dan-claude-bot in https://github.com/heavy-duty/box/pull/44
- fix: pin claudenet's resolver — a box's DNS is not a function of the host's VPN by @dan-claude-bot in https://github.com/heavy-duty/box/pull/45
- docs(readme): the isolation contract as it ships — and measured, not claimed by @dan-claude-bot in https://github.com/heavy-duty/box/pull/47
- docs(drill): record runs 11–13 — the contract measured at zero, from a bare host by @dan-claude-bot in https://github.com/heavy-duty/box/pull/48
- feat: 'claudebox tmux [session]' — a shell that survives you by @dan-claude-bot in https://github.com/heavy-duty/box/pull/49
- feat: 'claudebox doctor' — the host-health checks as a first-class verb by @dan-claude-bot in https://github.com/heavy-duty/box/pull/50
- fix: create the storage pool deliberately — btrfs, because cloning is the whole point by @dan-claude-bot in https://github.com/heavy-duty/box/pull/51
- feat!: claudebox becomes box — the Claude box is one template among several by @dan-claude-bot in https://github.com/heavy-duty/box/pull/52
- release: box 0.5.0 — codex+grok templates, migrate-host, box expose by @dan-claude-bot in https://github.com/heavy-duty/box/pull/56
- feat(new): inline resource overrides — --cpu, --memory, --disk by @dan-claude-bot in https://github.com/heavy-duty/box/pull/59
- docs: make the box narrative agent-agnostic, not Claude-specific by @dan-claude-bot in https://github.com/heavy-duty/box/pull/58
- docs(readme): how to run the drill against the latest version by @dan-claude-bot in https://github.com/heavy-duty/box/pull/60
- fix(install): the rename broke every install — stop guessing the tarball's top dir by @dan-claude-bot in https://github.com/heavy-duty/box/pull/61
- fix(doctor): a fresh host is not a dirty one by @dan-claude-bot in https://github.com/heavy-duty/box/pull/62
- feat: global install (#71), tmux (#65), CI + tests; folds #66 by @dan-claude-bot in https://github.com/heavy-duty/box/pull/73
- Make host setup complete in one run, and let the installer run it by @claude-bot-andresmgsl in https://github.com/heavy-duty/box/pull/66
- Restricted incus tier: per-user projects converged onto hardened boxnet (#74) by @dan-claude-bot in https://github.com/heavy-duty/box/pull/75
New Contributors
- @dan-claude-bot made their first contribution in https://github.com/heavy-duty/box/pull/1
- @claude-bot-andresmgsl made their first contribution in https://github.com/heavy-duty/box/pull/66
Full Changelog: https://github.com/heavy-duty/box/commits/0.6.0
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads