Compare commits

...

109 commits
0.7.0 ... main

Author SHA1 Message Date
Daniel Marin
c33794ce70
Merge pull request #170 from codex-bot-andresmgsl/build/169-exec-newlines
Some checks failed
ci / check (push) Has been cancelled
ci / rehearsal (push) Has been cancelled
release / release (push) Has been cancelled
fix: preserve multiline box exec commands
2026-07-25 18:42:24 +01:00
codex-bot-andresmgsl
7e05328a6d test: rehearse exec boundary on real Incus 2026-07-25 17:24:26 +00:00
codex-bot-andresmgsl
7247768465 fix: preserve box exec command newlines 2026-07-25 17:16:00 +00:00
Daniel Marin
f8ca59c22e
Merge pull request #164 from codex-bot-andresmgsl/build/14-convert-box
feat: adopt the shared release ceremony
2026-07-23 18:07:55 +01:00
codex-bot-andresmgsl
e4d9005aca fix: keep labels caller within pinned feature set 2026-07-23 16:52:58 +00:00
codex-bot-andresmgsl
3bae42157e fix: honor the released ceremony guard set 2026-07-23 15:20:03 +00:00
codex-bot-andresmgsl
b0d4aff964 refactor: retire repository-local release machinery 2026-07-23 13:45:08 +00:00
codex-bot-andresmgsl
1c3d873bf2 feat: adopt ceremony callers and doctrine 2026-07-23 13:40:44 +00:00
Daniel Marin
c93e230396
Merge pull request #158 from dan-claude-bot/feat/kimi-box-template
Some checks failed
ci / check (push) Has been cancelled
ci / rehearsal (push) Has been cancelled
release / release-on-merge (push) Has been cancelled
release / release (push) Has been cancelled
feat: kimi-box template — the Moonshot Kimi CLI agent seed
2026-07-22 16:31:12 +01:00
dan-claude-bot
a4684f7afa docs+drill: add kimi-box to the two enumeration spots review caught
box-recipe.md's agent-template sentence and the drill's mint-surface
check (list + ok message) both enumerate the agent seeds by hand;
kimi-box joins them, matching what the grok-box addition did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:22:20 +00:00
dan-claude-bot
bf74744bda feat: kimi-box template — the Moonshot Kimi CLI agent seed
The fourth agent seed, thin per #81: Debian 13, the 'kimi' user, tmux
and rig — everything the box becomes lands via 'rig bootstrap kimi-box'
(heavy-duty/rig#109). Same resources as the sibling agent seeds. The
template loop in test/cli.sh discovers it dynamically; doc lists and the
README table gain the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:11:22 +00:00
github-actions[bot]
a17903f07c chore: bump main to 0.9.1-dev — a dev install must not impersonate 0.9.0 2026-07-21 18:06:51 +00:00
Daniel Marin
86af660818
Merge pull request #148 from dan-claude-bot/release/0.9.0
release: 0.9.0
2026-07-21 19:06:38 +01:00
dan-claude-bot
2c3b329b8f docs: record the 0.9.0 drill as waived, with what that leaves unproven 2026-07-21 17:42:38 +00:00
dan-claude-bot
991acd2ba6 release: 0.9.0 2026-07-21 17:42:38 +00:00
Daniel Marin
0b5bedd6e0
Merge pull request #151 from dan-claude-bot/refactor/drills-per-version
refactor: one drill record per version, in drills/
2026-07-21 18:41:42 +01:00
dan-claude-bot
1ffd20d35b test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:39 +00:00
dan-claude-bot
f63fa952d4 refactor: one drill record per version, in drills/
Drill records move from sections inside drill/RUNS.md to one file per
version: drills/<version>.md. drill/RUNS.md is untouched — it stays the
harness's own run log, traps table and lore. drills/ is release evidence
only, and the docs now say which is which.

The old guard parsed headings: em-dash field matching, an optional
' — DATE' tail, whole-version comparison so 0.9.0-rc1 could not satisfy
0.9.0, avoiding \x escapes because CI runs mawk not gawk, and a non-blank
body rule. Every one of those existed only because records shared one
file, and two separate defects were found in review because of that
complexity — the sed '/./,$!d' whitespace bypass, and heading-grammar
drift from the sibling repos.

One file per version makes almost all of it unrepresentable: 0.9.0.md and
0.9.0-rc1.md are simply different files, so whole-version matching is
free rather than a trap. The guard keeps only the rule that was never
about headings — a record of pure whitespace is not a record — and keeps
the failure message that names the version, the path, the unblock, and
the recorded-waiver escape hatch.

Plain drills/, not .drills/ — a dot-directory is invisible to globs
without dotglob, which is what caused #116 and #118.

Also corrects the release-drill framing: the three repos' drills are
INDEPENDENT and run in any order. What dissolves the box<->rig recursion
is that every drill pins the same fixed candidate refs, not sequencing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:35:28 +00:00
Daniel Marin
115b9c026e
Merge pull request #149 from dan-claude-bot/feat/drill-gate
feat: CI refuses a release PR with no drill record
2026-07-21 17:17:39 +01:00
dan-claude-bot
877d9ded55 fix: whitespace is not a drill record 2026-07-21 15:53:29 +00:00
dan-claude-bot
0678b22e01 feat: CI refuses a release PR with no drill record
CONTRIBUTING.md has said since #96 that the release PR is where the full
real-hardware drill hangs, recorded in drill/RUNS.md. No release has ever
done it: #95, #114 and #148 all shipped as a VERSION bump plus a CHANGELOG.md
stamp, and RUNS.md carries no '## Release drill' section at all. A reviewer
bot finally blocked on it — which is the point. The one time it was caught is
the one time somebody happened to look, and that is not a gate.

So the rule moves out of the document and into CI.

.github/scripts/drill-recorded.sh, keyed on VERSION the same way
changelog-armed.sh is: a -dev tree passes with nothing to assert (which is
what keeps the guard installable — a version that fired on every PR would be
switched off inside a day), and a bare VERSION must carry a section headed
'## Release drill — <version>', optional ' — <date>' tail, with prose under
it. The version is compared as a whole awk field, never as a substring, so
0.9.0 cannot be satisfied by a 0.9.0-rc1 drill or vice versa —
release-notes.sh's trap, solved the same way so the two cannot disagree
about what "the section for X" means.

What it asserts is a RECORD, deliberately, not a passing drill: CI cannot run
the drill (real hardware, the better part of an hour — ci.yml says as much
about the rehearsal job it runs instead). That also keeps the maintainer
waiver honest — a release that must ship undrilled writes that under the same
heading, so the skip is a reviewable line in the diff rather than silence.

Wired into ci.yml as its own step, NOT pull-request-only, for the reasoning
#143 applied to the monotonic guard: the merge that publishes a release is a
push to main carrying the same bare VERSION, so a PR-only check would leave
the tree that actually ships unasserted.

test/release.sh grows 27 cases (134 -> 161). Every fixture carries its own
VERSION and its own RUNS.md — reaching for $ROOT/VERSION is the coupling #146
had to fix, and it goes red on the ceremony tree, the one tree where the
release suite most needs to be trustworthy.

CONTRIBUTING.md now states the flow (draft -> ready -> bot round -> drill ->
state:needs-human -> merge), the heading format, that three releases shipped
through the gap, and the recorded-waiver escape.

It also describes the drill as ONE orchestrated run over the whole stack,
because box and rig are mutually recursive and cannot be linearly ordered:
rig sits below box as the host-builder ('rig bootstrap --host yes' installs
box and runs setup-host) and above it as the guest-converger (a box new seed
curls rig's installer and runs 'rig bootstrap <tenant>-box'), the inverted
edge bin/box already documents as rig#28. The run is host bootstrap -> box
new -> tenant converge -> cast. It drills CANDIDATE REFS, not released
artifacts: RIG_REPO/RIG_REF are mint-time environment variables defaulting
to heavy-duty/rig@main (bin/box:1116-1117), so a run pins the exact commits
under test and no repo must be released before another can be drilled.
Drilling the candidate is drilling the release — a release diff is VERSION +
CHANGELOG.md, so nothing executable differs. One run, one shared run ID; each
repo records its own legs citing that ID and the other two SHAs, and the
guard reads only this repo's file.

Recorded as a known gap, not fixed here: a released box still defaults
RIG_REF to main, so a box minted a week after a drill is not the drilled
combination. Pinning RIG_REF to a released rig tag in the templates is the
outstanding step from #81 (rig#32 step 5).

LABELS.md documents blocker:drill-pending — ceremony correct but unevidenced,
maintainer-created because the bot account gets a 403 on label creation, with
`blocked` standing in until it exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:15 +00:00
Daniel Marin
92dc3a2bdc
Merge pull request #147 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
2026-07-21 15:43:50 +01:00
Daniel Marin
67d5890e2e
Merge pull request #146 from dan-claude-bot/fix/release-suite-ceremony-tree
fix: the release suite accepts the ceremony's own tree
2026-07-21 15:43:35 +01:00
dan-claude-bot
683217ee42 docs(changelog): one line per entry, and a pass over the whole file 2026-07-21 13:56:34 +00:00
dan-claude-bot
f7058b2814 fix: the release suite accepts the ceremony's own tree 2026-07-21 13:47:53 +00:00
Daniel Marin
9fcd2e4a7f
Merge pull request #132 from dan-claude-bot/feat/bootstrapped-snapshot
feat: mark 'bootstrapped' after a rig hook box watched succeed
2026-07-21 13:21:31 +01:00
Daniel Marin
82c1888381
Merge pull request #133 from dan-claude-bot/feat/import-provenance
feat: box import records the trip, without rewriting who the box was
2026-07-21 13:09:30 +01:00
dan-claude-bot
4c595eb945 fix: offer the pristine rollback only when the mark actually exists
Both reviewers landed on the same line independently, and they are right.
cmd_new's hook-failure path offered `box restore $name pristine`
unconditionally, but three mints reach that line with no pristine mark, all
by this stack's own design: a `dir` pool (skipped, no CoW),
BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued).
On a `dir` host that is EVERY hook failure — so the operator standing at the
one moment the message exists for copy-pastes a restore that errors.

It could not simply be read off the return status. The never-fatal contract
means snapshot_mark returns 0 on all four paths on purpose — taken, skipped,
knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark
that exists from one that does not, and making it distinguish would put the
mint's success at the mercy of a checkpoint, which is what that contract
exists to prevent.

So the fact is recorded rather than inferred: `marks` accumulates the labels
snapshot_mark actually created, and `mark_taken <label>` answers the only
question a rollback offer may ask. Per-label, so one mark never answers for
another.

This is the same refusal the message one line earlier already makes when it
declines to invent a `bootstrapped` it never watched — a promise the disk
cannot deliver is the failure mode #130 was built to refuse. It just was not
applied to the restore offer sitting under it.

Pinned per path rather than once, because the three no-mark paths fail
differently and a single case would let the other two regress silently: five
drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused /
no bleed between labels) and a static check that the call site is gated.
Verified by mutation — dropping the recording, and un-gating the offer, each
fail the suite rather than passing quietly.
2026-07-21 11:57:01 +00:00
dan-claude-bot
050c90966e feat: mark 'bootstrapped' after a rig hook box watched succeed
converged anything. This takes the checkpoint one step later: after the rig
bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged
and untouched. 'box restore <box> bootstrapped' keeps the tenant role and
undoes only what happened after it, which is the undo an operator reaches
for far more often and which otherwise costs a ~10-minute re-mint.

The policy is #128's, shared rather than copied: snapshot_pristine and the
new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the
never-fatal contract and the 'dir' skip exist in exactly one place. What
does not generalise is the prose — the two marks name different moments —
so each wrapper owns its own narration.

Deliberately conditional where 'pristine' is unconditional. 'pristine' marks
a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank
box has no such event, so it gets no mark rather than a byte-identical
duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none
either: the failure message already sends the operator to 'box shell', which
is a run box does not watch, so box hands over 'box snapshot <box>
bootstrapped' instead of labelling a convergence it never saw.

The label is therefore documented as one-directional on every surface: its
presence means the hook converged untouched, its absence means nothing at
all. Same durability caveats restated everywhere it appears — it dies with
the box on 'box rm', and no filesystem rollback reaches off-box state
(heavy-duty/rig#62).

Closes #130

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:57:01 +00:00
dan-claude-bot
91349acac1 fix: the import count guard closes the octal hole, not just the garbage one
All three reviewers landed on the same line. Reproduced on this head under
`set -euo pipefail`:

    prev_n=08
    [ -n "$prev_n" ] && [ "$prev_n" -eq "$prev_n" ] 2>/dev/null || prev_n=0
    #  -> guard PASSES: test parses 08 as decimal
    n=$((prev_n + 1))
    #  -> bash: 08: value too great for base (error token is "08")

Two holes that had to close together. `test -eq` reads decimal, arithmetic
reads a leading zero as octal, so a value can pass the guard and still abort
the arithmetic it was guarding. The abort lands after the physical
`incus import` and before the stamp, the placement correction and the start —
the exact window the degrade-never-die contract exists to protect, and the
same side box_provenance()'s schema check falls on.

    case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
    n=$((10#$prev_n + 1))

Digits-only closes sign and garbage; 10# forces base ten. Checked across
08 -> 9, 007 -> 8, 5 -> 6, and '' / not-a-number / -3 / 3x / 00 -> 1.

The existing not-a-number fixture could never have caught this: that value
fails the guard and degrades, so it exercises the path that already worked.
Added a count=08 fixture beside it, asserting both halves — the import does
not fail, AND the count advances to 9 rather than degrading to 1, because 08
is a real previous total and reading it as anything else would be its own
small lie. A zero-padded count is not exotic; it is what any external tool
that formats numbers writes.

Verified by mutation: with the old guard restored both new assertions fail.

Also drops user.box.mode.asked from the IMPCLONE fixture (grok's nit). Since
#129 the clone path clears that key, so a fixture built from the mint shape
that kept it described a box the clone path cannot produce. Nothing asserts
it today — which is precisely why it would have rotted unnoticed.
2026-07-21 11:49:32 +00:00
Daniel Marin
64127b34e6
Merge pull request #128 from dan-claude-bot/feat/pristine-snapshot
feat: snapshot 'pristine' at mint, before the rig bootstrap hook
2026-07-21 12:49:22 +01:00
dan-claude-bot
b2afea580e feat: box import records the trip, without rewriting who the box was
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.

Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.

Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.

box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.

Closes #131.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:27:57 +00:00
dan-claude-bot
24a05c3020 fix: make the 'pristine' path's never-fatal contract structural, not incidental
Both from claude-bot's review of #128.

storage_driver's two driver probes had no '|| true' while the pool probe one
line up did. They were safe only via a bash subtlety: command substitution
strips errexit, so a failing 'incus storage show' fell through to the fallback
instead of aborting. Add 'shopt -s inherit_errexit' to bin/box — the class of
robustness tweak #107 documents sailing through review — and under pipefail a
restricted tier's probe refusal becomes a fatal abort mid-mint, inside the
function whose contract is NEVER fatal. All three probes now read alike and
depend on nothing. Driven test: refuse both probes with inherit_errexit on,
assert the caller survives and the driver reads empty (the unreadable-pool
case, which already takes the mark anyway).

The clone's inheritance narration piped a multi-line 'incus snapshot list'
straight into 'grep -q' — #124's racing-reader class in a 'set -euo pipefail'
file. A 141 there reads as "no pristine" and narrates the WRONG inheritance
shape on a clone that does carry one. Capture first, then read. Pinned by
shape (grep|head|sed|awk|read), not by instance spelling.

bin/box has ~10 more sites of that class and no sweep covers the file; one of
them unpins an exposure's static address on a fail-open read. Filed as #134
rather than widened into this PR.

Refs #104, #107, #124, #134.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:26:54 +00:00
dan-claude-bot
a091c6e496 feat: snapshot 'pristine' at mint, before the rig bootstrap hook
cmd_new's fresh-mint branch runs launch → wait_agent → cloud-init
status --wait → the rig bootstrap hook. In the gap between the last two
the guest IS pristine Debian plus box's thin seed — the state
heavy-duty/rig#62 calls "back to pristine Debian". It existed for a few
seconds on every mint box has ever taken and nothing captured it.

Take it there, unconditionally within a fresh mint and never fatally, so
'box restore <box> pristine' is a complete undo for every creds-free
tenant role. A --from clone takes none: it has no pristine moment, so it
inherits its source's snapshots or starts with none, and box will not
label a worked-in state as pristine. On a 'dir' pool (no CoW) the mark
would be a full multi-GB copy per mint, so the mint skips it loudly.

Closes #104
2026-07-21 11:26:54 +00:00
Daniel Marin
f84e474549
Merge pull request #127 from dan-claude-bot/test/racing-reader-sweep-class
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
2026-07-21 12:25:04 +01:00
Daniel Marin
8f1a281c20
Merge pull request #129 from dan-claude-bot/feat/mint-stamp
feat: a minted box records how it was minted, and box info reads it back
2026-07-21 12:24:40 +01:00
dan-claude-bot
256ded2e92 fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.

Both halves of the matcher are alternations now.

Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.

Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.

Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:00:41 +00:00
Daniel Marin
4fe91ef562
Merge pull request #120 from dan-claude-bot/fix/wipe-sigpipe-shape
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
2026-07-21 11:55:33 +01:00
dan-claude-bot
c3823e9a3f test: the mint drive mints 'claude-box', the name the template has now
The rebase onto main was textually clean and semantically wrong. #123
renamed the tenant templates (claude -> claude-box, following rig#76) while
this branch was open; the mint-stamp drive was written against the old name
and git had no reason to say so — the two changes never touch the same line.

`box new --template claude` now dies at the template lookup, so all 18 mint
assertions failed on a shimmed mint that never reached a launch line. Renamed
at the two mint invocations and the two stamp assertions that read the new
name back (user.box.template, user.box.role).

Deliberately NOT renamed:

- `user.box.user=claude` — the seed user did not move. #123 split the two
  namespaces on purpose: the template and the rig role are `claude-box`,
  the user the role converges stays `claude`. The assertion pins that split.
- the canned `$STAMPED`/`$PRERENAME` info fixtures — they stand for boxes
  minted BEFORE the rename, which is exactly the case box promises to keep
  reading (cmd_new matches both spellings of user.box.template for the login
  hint). Renaming them would delete the coverage.
2026-07-21 10:51:08 +00:00
dan-claude-bot
aed2f5bf1f fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.

It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print

    MODE       vm (asked: auto)

describing a demand never made of it, with nothing marking it ancestral.

There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.

The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.

+7 checks (529 -> 536), each proven to bite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:47:50 +00:00
dan-claude-bot
be633aa5f8 feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.

The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.

A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.

cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.

Closes #103.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:47:50 +00:00
dan-claude-bot
aec22277cd fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.

It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.

Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.

Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.

drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.

Refs #107
2026-07-21 10:39:03 +00:00
Daniel Marin
239428bf88
Merge pull request #119 from dan-claude-bot/fix/teardown-host-tty-gate
fix: teardown-host refuses a terminal-less run instead of aborting mute
2026-07-21 00:38:13 +01:00
Daniel Marin
9b06e29b47
Merge pull request #144 from dan-claude-bot/fix/monotonic-uniqueness-first
fix(changelog-monotonic): check uniqueness before anything base-side
2026-07-21 00:37:43 +01:00
dan-claude-bot
1e3fb473c3 test: terminate the ci.yml step block at the job boundary too
The monotonic step is the LAST step of the `check` job, so stopping only at
the next `- name:` ran the block into `rehearsal` and swallowed its job-level
`if:`. Unanchored `grep -q 'if:'` then fired on it — reintroducing the bug the
scoping was meant to fix, moved from "any step in the file" to "this step plus
the head of the next job".

Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.

Mutation-tested four ways: baseline green; an unrelated job gated stays green
(was the failure); the monotonic step gated fails; the step renamed trips the
companion found-the-block guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:54:31 +00:00
dan-claude-bot
4b658b6fb0 test: silence SC2016 on the new negative fixture
The inner `bash -c` takes its paths as positionals, so the single quotes are
deliberate — same class as the release-notes fixtures above it, and reusing
their exact disable wording.

Mine to catch: I linted .github/scripts, bin/box and install.sh by hand, while
CI lints `bin/* **/*.sh` under globstar+dotglob, which includes test/. The
narrower local invocation is what let this through; reproduced CI's exact
command before pushing this time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:43:35 +00:00
dan-claude-bot
94d830cdb3 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.

That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned.

Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide grep it forbade any FUTURE step from being pull_request-gated and
would have failed citing #143 when one legitimately was.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:35:01 +00:00
Daniel Marin
b7954e3f9b
Merge pull request #142 from dan-claude-bot/fix/labels-sweep-on-labeled
fix(labels): sweep on `labeled` so the handoff is immediate
2026-07-20 21:32:20 +01:00
dan-claude-bot
94f6ed0047 fix(changelog-monotonic): check uniqueness before anything base-side
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.

The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.

That inverted the two halves. Deletion needs a diff to see; duplication is the
one release-notes.sh actually mis-renders, re-arming its grab on the second
heading (#118). The half with the live extraction bug behind it had the most
ways to silently not run.

Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main.

Found by claude-bot-andresmgsl reviewing heavy-duty/rig#99 and
heavy-duty/cast#134, which inherited the ordering from here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:16:44 +00:00
dan-claude-bot
243ccc3aa5 fix(changelog): cite the sibling PRs, not the tracking issues
The "Landed in all three repos together" line pointed at the sibling
tracking issues rather than the sibling PRs. The entry already opens with
its own issue ref, so a reader following "landed together" was sent to more
issues and never reached the actual sibling changes.

All three PRs carried it identically because the three entries came from one
generator that took sibling references from its issue-number map, and
expanded them into the sentence without re-wrapping — which is also why the
line ran to 108 columns in a file that wraps at 83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:07:57 +00:00
dan-claude-bot
3155258cf5 fix(labels): sweep on labeled so the handoff is immediate
A review landing was never a trigger for the labels workflow, so the exact
moment `state:needs-human` became true — the third bot approving — fired
nothing, and the label waited on the `*/15` cron. That cron does not run at
its declared rate: measured across box, rig and cast over a two-hour window
on 2026-07-20, one scheduled run each against the eight `*/15` implies.

The obvious fix does not work. There is no `pull_request_review_target`, and
on fork PRs — all of them here — `pull_request_review` runs with a read-only
token and cannot label anything.

So the handoff wakes the sweep itself:

- `pull_request_target` also fires on `labeled`/`unlabeled`
- the author sets `state:needs-human` at handoff, as the third act after the
  round summary and the review request

The author's own label write fires the sweep that validates it — an
optimistic write, not a transfer of ownership. The reconciler confirms or
corrects it seconds later, and the cron falls back to a last resort. It
cannot loop: the reconciler writes with GITHUB_TOKEN, which does not create
workflow runs; agent writes use a PAT, which does.

`labels-reconcile.sh` is unchanged — it already recomputes every open PR
from scratch on every run, which is what makes the optimistic write safe.
The `scope` job is skipped on label events, where no path can have changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:44:07 +00:00
Daniel Marin
81c44c8b02
Merge pull request #139 from dan-claude-bot/docs/contributing-blocker-axis
docs(contributing): document the blocker axis and merge-next ownership
2026-07-20 20:07:20 +01:00
dan-claude-bot
b953d8b3c1 docs(contributing): document the blocker axis and merge-next ownership
The who-sets-what table is the day-to-day answer to "can I move this by
hand", and it never mentioned blocker:* -- a whole machine-owned family
added when state:needs-rebase was retired. merge-next was missing too, and
that is the one label whose ownership actually needs saying, because it is
the only one in the machine's vocabulary the machine deliberately does not
set.

Step 6 also read as though requesting the maintainer is sufficient to flip
state:needs-human. It is not: needs-human requires zero blockers, so the
request does nothing on a conflicted or red PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:38:18 +00:00
Daniel Marin
e6854ef62c
Merge pull request #138 from dan-claude-bot/fix/labels-two-axis
refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
2026-07-20 19:30:04 +01:00
dan-claude-bot
f956a85a1c fix(labels): a missing state label must skip the EDIT, not the whole PR
Round 2 review (claude-bot, codex-bot, grok-bot -- all three, independently).

The label pre-flight added in round 1 returned out of reconcile_pr entirely.
Everything below that point is independent of the state:* taxonomy: clearing a
stale merge-next, and the stale sweep. Stranding them meant a merge-next claim
reading "merge this one next" survived on a PR the board had moved to the
agent -- the same false invitation as #136, one scope smaller -- and the
staleness detector went silent. On a cold-start repo, where no state:* label
exists yet, that was EVERY PR.

It was also a regression against main rather than a missed improvement: the old
code failed the edit, logged, and fell through to both blocks. The round-1 fix
turned a per-edit failure into a per-PR abort.

Now skip_edit=true, and control reaches the rest of the function.

Also taken, both from claude-bot and grok-bot: the dead "$desired" term in the
filter loop, and `[ -n "$missing" ] && log` becoming a proper elif rather than
an &&-as-statement under set -e.

Four fixtures now drive reconcile_pr itself, stubbing run/gh -- the first in
this suite to reach past the pure functions, which is exactly why a per-PR
return was invisible to the fixtures that existed. Fixtures 68 -> 72.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:09:07 +00:00
dan-claude-bot
27cfc5b8c1 fix(labels): never name a label the repo lacks; a stale round owes an ask too
Round 1 review (claude-bot, codex-bot on #138 and the sibling PRs).

gh issue edit rejects the WHOLE call on one unknown label name, and this
change made state and blockers share a single edit for anti-flicker. On a repo
whose taxonomy predates the blocker:* labels -- every repo, at merge -- the
first sweep would therefore converge NOTHING on exactly the PRs the change
exists to fix, surfacing only as a warning in a cron log. Adds are now
filtered against the repo's real label set, read once per sweep. Removals need
no filter: they are built from has_label, so they provably exist. An
unreadable label set does not filter, because a failed read must not silently
strip the board.

blocker:unrequested also missed the stale round. MISSING and STALE are both
verdicts this head does not have -- nobody reviewed it, or everybody reviewed
something else -- and the agent owes an ask either way. The stale one is the
worse of the two: it has approvals on the page that no longer describe the
tree, which is the case LABELS.md already called the most dangerous.

Fixtures 66 -> 68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:00 +00:00
dan-claude-bot
074aea31d8 docs(changelog): record the UNREADABLE rollup fix
Its own Fixed entry rather than a line in the refactor above it: the two-axis
split is a design change, this is a correctness bug in a different function,
and a reader bisecting a false "merge me" wants the second one named.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:42:56 +00:00
dan-claude-bot
9bf669dabc fix(labels): a failed rollup read must not read as "nothing is failing"
gh returning nothing left the rollup KEY absent, which collapsed into the same
NONE as a PR that genuinely has no checks -- so an API hiccup presented as
mergeable-by-a-human. That is the unknown-certified-as-green shape this machine
exists to stop, surviving in the one place the #136 fix did not look.

checks_state now reports UNREADABLE for an absent key, and the sweep leaves
that PR exactly as it is rather than recomputing on facts it did not read.
Blocking on it instead would flap the whole board on one bad call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:36:46 +00:00
dan-claude-bot
2d49a5ad42 refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
Retires state:needs-rebase. Mergeability, check status and the review round
are independent facts; forcing them through one totally-ordered label meant
one always won and the rest vanished, which is where every precedence bug in
this machine has lived. needs-rebase was the clearest casualty -- it fired on
both a conflict and a failing check, which need opposite work.

Blockers are a set, so there is no ordering between them to get wrong. The
ordered axis is now purely about reviews. bots-reviewing also tightens to mean
a request is actually live; a PR nobody was asked to review is the agent's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:27:06 +00:00
Daniel Marin
e408b7a0ed
Merge pull request #137 from dan-claude-bot/fix/labels-mergeability-aware
fix(labels): `state:needs-human` means a human could merge it right now
2026-07-20 18:02:52 +01:00
dan-claude-bot
b9527d3cc1 fix(labels): date a check run by when it BEGAN, not by the newest stamp it carries
Third and final correction to the supersede rule, and the second one
that silently restored #136.

Round 3 replaced "date by completion" with "date by the newest stamp
the run carries". That is still not an ordering on runs: `max` over
[startedAt, createdAt, completedAt] resolves to completedAt for a
FINISHED run and startedAt for a LIVE one — different quantities, so
the comparison was never between like and like.

The consequence is the ordinary concurrency-group path, not an edge
case. A run cancelled by a concurrency group does not stop instantly;
it drains AFTER its replacement has already started, so
predecessor.completedAt > successor.startedAt is the normal shape. On
this PR's own aa5a6ba the window was 13 seconds. Inside it the dead
predecessor out-dated the live run replacing it, and a green
predecessor reported SUCCESS while a re-run was still in flight:

  SUCCESS completing 15:30:13, replacement started 15:30:00
    max   -> SUCCESS   (#136: needs-human over a disabled merge button)
    first -> PENDING

Fixed with `max` -> `first`. The list is already in preference order,
so `first` IS "date it by when it began" — and a replacement always
begins after the run it replaces, whatever order they finish in. The
sentinel filtering and the undateable-sorts-last tiebreak are
unchanged; this narrows the rule to a quantity that actually orders.

Prescribed independently by claude-bot-andresmgsl and
codex-bot-andresmgsl.

Two fixtures pin the drain window in both colours. 49 -> 51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:41:49 +00:00
dan-claude-bot
cbc8f4f7a8 test(labels): pin the reverse direction — a run that finished after an in-flight entry settles the context
The supersede fixtures pinned "an in-flight re-run is the newer word".
Nothing pinned the converse: a run that FINISHED after an earlier
in-flight entry is also the newer word, so the context is settled and
reads SUCCESS rather than staying PENDING.

That is the assertion that catches an over-correction — widening
"an undateable entry sorts last" into "an in-flight entry always wins".
Verified it fails under exactly that mutation and that nothing else in
the suite does.

Also corrects the comment above the undateable fixture: that case is
guarded by the sort tiebreak, not by the dating expression, so the two
changes are now separately pinned. Reverting only the dating expression
fails 2 fixtures (the two zero-sentinel ones), not 3.

48 -> 49. Reconciler unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:29:59 +00:00
dan-claude-bot
e40d2df5f1 fix(labels): date a check run by the newest stamp it carries, not by its completion
Round 3. @claude-bot-andresmgsl and @codex-bot-andresmgsl independently
caught that the supersede rule added in round 2 inverted itself on the
one shape its fixtures could not express: a re-run still in flight.

A running check does not omit `completedAt`. `gh` marshals the Go zero
time as the STRING "0001-01-01T00:00:00Z", and jq's `//` only falls
through null/false, so the sentinel won the sort key and sorted before
every real timestamp. The live re-run became the OLDEST entry in its
context, `last` discarded it, and the run it superseded was judged
instead — exactly backwards, and wrong in both directions:

  green + re-run in flight      -> SUCCESS   (should be PENDING)
  CANCELLED + re-run in flight  -> FAILURE   (should be PENDING)

The first is #136 restored by the very rule meant to close it: all bots
approve, mergeable, state:needs-human — over a tree whose merge button
branch protection has disabled. It was also a regression from round 1,
which caught it via `any(. == "")`. The second is the re-run flap the
supersede rule exists to prevent, narrowed rather than removed.

Fixed by taking the newest timestamp a run actually carries and
discarding BOTH spellings of absent — null and the zero sentinel —
rather than by reordering the fallbacks. An entry with no usable
timestamp now sorts LAST rather than first, so an undateable in-flight
run is never dropped in favour of a stale success. Every ambiguity
resolves toward "not settled".

The fixtures could not have caught this: the `run_()` helper sets only
`completedAt`, so every supersede fixture was a race between two
FINISHED runs. The helper now expresses an in-flight entry, and the
four new fixtures assert PENDING over both a green and a cancelled
predecessor. 44 -> 48; reverting just the dating expression fails 3 of
the 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:18:22 +00:00
dan-claude-bot
724f103908 fix(labels): an unrecognised check outcome blocks, and a staled round outranks an unfinished one
Round 2. Two blockers, both real, both closing the same hole this PR
exists to close — a `state:needs-human` that invites a human to merge a
tree that will not merge.

The check-rollup classifier enumerated the outcomes that block and let
the rest fall into `else "SUCCESS"`, so ERROR, CANCELLED and STALE all
read as green. Inverted: it now lists the outcomes that DON'T block —
SUCCESS, NEUTRAL, SKIPPED, plus the pending set — and treats everything
else as blocking. The direction is the point. The rollup mixes two
closed enums (CheckRun.conclusion, StatusContext.state) and an outcome
the list forgets is one we cannot certify as mergeable; the costs are
not symmetric, since a false FAILURE parks the PR on the agent who
looks, while a false SUCCESS is #136 exactly.

Once CANCELLED blocks, superseded runs must be dropped first: a re-run
does not evict the run it replaced, and this PR's own tip carries a
CANCELLED `scope` beside the SUCCESS `scope` that superseded it. Each
context now collapses to its newest entry before anything is judged,
keyed on workflow + job name because a bare job name is only unique
within its workflow. That preserves the re-run case the panel split
over while still blocking a cancelled run that is the newest word.

The classifier also moved out of main() into checks_state(). That is
why no fixture caught this: it was inline in the fetch loop, so the
fixtures could only inject CHECKS= as an already-decided string.

Second, decide_state() returned from inside the bot loop on the first
MISSING, so a STALE belonging to a later bot in BOTS was never read —
a round that was both unfinished and staled came out needs-human over
a head nobody had reviewed. The whole round is now collected before
precedence is applied to it as a unit, STALE before MISSING.

test/labels-reconcile.sh: 29 -> 44 fixtures, pinning the check-outcome
enum, the supersede rule (both orders, plus same name in another
workflow), and the mixed round at both ends of BOTS. All verified
non-vacuous against the round-1 code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:04:16 +00:00
dan-claude-bot
aa5a6baed6 fix(labels): state:needs-human means a human could merge it right now
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
Combined with the `if requested "$HUMAN"` short-circuit at the top of its
precedence, the label was sticky: once the maintainer was requested, the PR
read state:needs-human through conflicts, through red CI, through a
force-push that staled every approval. Nothing demoted it.

Observed twice in one afternoon, in two different shapes. Three PRs sat at
state:needs-human while CONFLICTING for hours -- the board inviting a merge
GitHub had already disabled. And #119, after a rebase, read MERGEABLE, four
green checks, state:needs-human, with ZERO reviews bound to its head: every
visible signal saying "merge me" over a tree no reviewer had seen. That second
shape is the dangerous one, because unlike a conflict nothing on the page
contradicts it.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- a maintainer
pulling a PR to themselves early is deliberate, and MISSING (nobody has
reviewed yet) is a different fact from STALE (everyone reviewed something
else). That distinction is why the two are handled in different arms rather
than collapsed.

UNKNOWN mergeability is deliberately NOT treated as unmergeable: GitHub
reports it for about a minute after every merge while it recomputes, and
flapping every open PR through needs-rebase on each merge would be worse than
the bug being fixed. A failed read of either fact degrades to the same "do not
know" value, for the same reason -- an API hiccup must not relabel the board.

Also adds merge-next, because a correct needs-human still does not say WHICH
PR to merge first, and order matters when they conflict through CHANGELOG.md.
Queue order is intent, so the reconciler never sets it; it only CLEARS it the
moment the PR stops being mergeable-by-a-human -- precisely the staleness that
made needs-human untrustworthy.

Both live shapes are pinned in test/labels-reconcile.sh (19 -> 29 fixtures),
including that UNKNOWN does not trigger needs-rebase and that a draft outranks
a conflict. Proven non-vacuous: dropping the mergeability arm fails 4
assertions, dropping the STALE precedence fails 2, restoring returns 29/0.

Closes #136

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:18:49 +00:00
dan-claude-bot
ecce72f025 test: the teardown check must not inherit BOX_YES
The new check drives host/teardown-host.sh for real — the first check in
the suite to execute a destructive script rather than grep it or run it
against shims. teardown-host honours an inherited BOX_YES, and test/cli.sh
never sanitised it, so under `BOX_YES=1 bash test/cli.sh` the invocation
took the consent arm instead of the gate: a real teardown of the host the
suite was running on, reported red only afterwards.

BOX_YES is the documented automation switch for this family, so an
operator's CI wrapper exporting it is the expected case, not a contrived
one. The file header promises the suite is runnable anywhere; before this
the promise held under any environment.

Fixed at the call site with `env -u BOX_YES`, plus a file-level `unset` so
the promise cannot be broken again by the next check that drives something
destructive.

Refs #113
2026-07-20 14:45:23 +00:00
dan-claude-bot
b3fa94a7d4 fix: teardown-host refuses a terminal-less run instead of aborting mute
host/teardown-host.sh had no `[ -t 0 ]` check before its confirmation
prompt. Without --yes/BOX_YES and without a terminal — CI, a pipe, a
nohup — it fell into `read`, took the instant EOF and exited 1 saying
only "aborted": a refusal naming neither the cause nor the override, in
the most destructive script in the tree.

It now refuses with the override named, exit 2 — "you invoked this
wrong", matching host/revoke-user.sh --purge and install.sh's confirm(),
versus 1 for "you were asked and you said no".

The gate sits below the --yes/BOX_YES arm, so consent given
non-interactively still runs headless, and above the first incus call,
so the refusal needs no daemon — which is what lets test/cli.sh drive it
for real instead of grepping for it.

Refs #113
2026-07-20 14:45:23 +00:00
Daniel Marin
a9e52325f4
Merge pull request #121 from dan-claude-bot/fix/flat-upgrade-setup-host
fix: run setup-host over a migrated flat tree, and name what the migration left
2026-07-20 15:43:11 +01:00
dan-claude-bot
b1968dabb7 fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.

The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.

Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.

Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.

test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.

Refs #115, #117
2026-07-20 14:18:21 +00:00
Daniel Marin
add1c2a142
Merge pull request #126 from dan-claude-bot/fix/changelog-heading-monotonicity
fix: refuse a PR that deletes a shipped changelog heading
2026-07-20 15:15:56 +01:00
dan-claude-bot
cb72c71c33 fix: the guard also refuses a DUPLICATED release heading, not just a deleted one
Review found the first cut incomplete, and the gap is the shape the #118
incident actually had. Containment catches a heading that VANISHED. It cannot
catch one that was DUPLICATED: the duplicate is head-side surplus, and
`comm -23` (base minus head) is blind to extras on the head side -- with or
without `sort -u`. Multiset comparison does not close it either, for the same
reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that
does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE
containment rather than replacing it, since containment remains the right
check for deletions.

That matters because the duplicate is not hypothetical. It is what a bad
rebase of #116 produced an hour before this commit: two
`## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between
them. Every other guard was green on that tree -- no conflict markers,
changelog-armed.sh happy because the top section was still right for the
VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its
grab on the second heading and folded post-cut prose into the shipped 0.8.0
body, and the stranded entry would have been dropped from the next release's
notes as well.

Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and
'Unreleased' fails the version shape before it reaches here.

Both trees are now pinned in test/release.sh side by side -- the deletion
near-miss and the real duplicate -- each with changelog-armed.sh asserted
green on it, which is the whole reason this script exists separately. The
duplicate case also pins that the containment arm stays silent on it, so a
future simplification cannot collapse the two asserts into one.

Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red
on exactly the three new assertions (117/3); restoring it returns 120/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:08:56 +00:00
dan-claude-bot
ce57070c14 fix: refuse a PR that deletes a shipped changelog heading
changelog-armed.sh asks only whether the TOP section agrees with VERSION.
A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block —
git merges the edit cleanly, and a shipped section is silently absorbed into
Unreleased. The damage only surfaces at the next release, when
release-notes.sh cannot find the section it extracts by heading.

Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must
be a superset of the set at the merge base. Release headings are append-only,
so the rule has no legitimate violation — and the ceremony's stamp passes by
construction, adding X.Y.Z and removing none.

Its own script, not a clause in changelog-armed.sh: the input is a git
history rather than two files, no base ref is a SKIP rather than a failure,
and changelog-armed.sh is driven by test/release.sh against constructed
non-git trees that cannot express the failure at all.

CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so
an unreachable base ref goes red there instead of degrading to the skip a
local run is allowed.

Closes #122

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:54:44 +00:00
Daniel Marin
cc0a1a9a79
Merge pull request #118 from dan-claude-bot/fix/shellcheck-dotglob
fix: lint the release path — globstar does not descend into dot-directories
2026-07-20 14:53:46 +01:00
dan-claude-bot
c68c519558 fix: restore the 0.8.0 heading the changelog entry replaced
The Unreleased entry was written over `## 0.8.0 — 2026-07-19` instead of
above it, folding the shipped 0.8.0 section into Unreleased: the release
lost its extractable anchor, and the next cut would have republished its
notes as new work.

changelog-armed.sh passed on that tree — correctly, since it asks only
whether the top section agrees with VERSION, and Unreleased was still on
top. The gap it leaves is filed as #122.

Also quote the class check's failure list instead of relying on unquoted
word-splitting to print one path per line.

Refs #116
2026-07-20 13:26:35 +00:00
dan-claude-bot
0b7ad21eca fix: lint the release path — globstar does not descend into dot-directories
CI's shellcheck sweep set globstar and globbed `bin/* **/*.sh`. globstar
makes `**` descend into subdirectories, but a glob still does not MATCH a
dot-prefixed name, so `**/` never entered `.github/` and three scripts were
never linted: changelog-armed.sh (the #108/#110 guard that gates every PR),
release-notes.sh (which produces the published release body), and
labels-reconcile.sh (the label state machine). That is the entire release
path, while the step's own comment promised the opposite — that a script in
a new subdirectory is linted without anyone editing a list.

Latent, not broken: all three pass shellcheck as-is, so this is a no-op on
current code. What changes is that a regression in them would be caught.

dotglob alongside globstar closes it, measured rather than assumed: it adds
exactly those three and nothing else — a checkout's .git carries no *.sh,
its hooks shipping as *.sample, so `**/*.sh` does not wander into it.

The one-time fix is dotglob; what keeps the gap shut is the CLASS check, in
the same shape as the eof_guard_sweep of #112. The sweep now compares the
globbed set against `git ls-files '*.sh'` and fails naming any tracked
script it does not cover, so a future dot-directory or shopt subtlety
cannot silently lint a subset and pass.

eof_guard_sweep carried the identical blind spot — it rebuilds the same
glob — and is widened the same way. A no-op today: the three scripts set
errexit, so they are in that class by construction, but none of them reads.

Refs #116
2026-07-20 13:11:41 +00:00
Daniel Marin
4c34facefe
Merge pull request #125 from dan-claude-bot/feat/template-family-suffix
refactor(templates): the tenant seeds carry rig's -box family suffix
2026-07-20 14:09:30 +01:00
dan-claude-bot
9c78911563 fix(templates): the printed workload join follows rig's machine-role rename
The tenant half of rig#76 is what #123 tracked, but the machine half reaches
box in one place: the tailnet workload join box prints as the next step for a
staging-box guest is `rig bootstrap workload`, and that role is now
`workload-server`.

box never runs it -- it holds a pre-auth key, and that it stays operator-run
is the absence that keeps box creds-free end to end -- but box does PRINT it,
in three places that all had to move together: cmd_new's hint, the staging-box
seed's own comment, and the README. A next step an operator copy-pastes is as
wrong as a role box executes, and it fails later and further from the cause.

The suite's assertion moved with it, so it still pins what it was written to
pin: that the join is printed and never exec'd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:07:00 +00:00
dan-claude-bot
86dbb0449e refactor(templates): the tenant seeds carry rig's -box family suffix
rig is growing a second family of roles, and once a 'staging' role can mean
either a fleet machine or a box tenant, the bare name stops naming anything.
rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:

  claude  -> claude-box    codex   -> codex-box
  grok    -> grok-box      staging -> staging-box

Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.

'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.

Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.

The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.

Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.

Closes #123

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
github-actions[bot]
15b67d938e chore: bump main to 0.8.1-dev — a dev install must not impersonate 0.8.0 2026-07-19 23:04:15 +00:00
Daniel Marin
c4f62e5d87
Merge pull request #114 from dan-claude-bot/release/0.8.0
release: 0.8.0
2026-07-20 00:04:02 +01:00
Claude
8661067468 release: 0.8.0
Stamp the ceremony: VERSION 0.7.1-dev -> 0.8.0 (bare), and rewrite
'## Unreleased' into '## 0.8.0 — 2026-07-19' with an empty '## Unreleased'
put back above it — the re-arm half of the two-edit stamp (#108), so main is
never disarmed between this merge and the automatic -dev bump.

0.8.0 rather than 0.7.1: the section carries an '### Added' (#96, the merge
door), and #105 changes 'box restore' to ask before it destroys — which
breaks non-interactive callers that did not pass --force.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:33:05 +00:00
Daniel Marin
2c4f83814f
Merge pull request #112 from dan-claude-bot/fix/confirm-eof-abort
fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
2026-07-19 22:27:57 +01:00
dan-claude-bot
bb51aeeca9 fix: close the silent-EOF class at the two host/ gates it also reached (#111)
The first pass fixed #111 where it was reported — confirm() and
uninstall_confirm() in bin/box — and stopped there, while the same defect
sat at two more destructive gates in this repo:

  · host/revoke-user.sh:50 guards 'box revoke --purge', the prompt whose
    own text says "this cannot be undone";
  · host/teardown-host.sh:31 guards a full host teardown.

Both run under 'set -euo pipefail', so an unguarded 'read' returning
non-zero on EOF ends the run before the 'case' that names the abort —
exit 1, in silence, at the moment the script asked. Each now carries the
guard in its own script's wording rather than importing bin/box's die().

The three drill/ prompts are deliberately untouched: they run under
'set -u' only, so EOF falls through to the '*)' arm and already aborts
out loud. 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 — 'while read' loops and '<<<'
herestrings excluded, since neither is a prompt. Removing any of the four
guards makes it fail and name the file:line; it flags nothing else across
the tree's fifteen shell files. The absence of exactly this check is why
the host/ pair was missed the first time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:08:25 +00:00
dan-claude-bot
e27ab239f1 fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
confirm() and uninstall_confirm() read the operator's answer with a bare
'read -r reply'. Ctrl-D makes read return non-zero, set -euo pipefail ends
the run on that line, and the case below — the only thing that ever says
"aborted." — is never reached. box exits 1 having printed nothing after
the question it just asked.

The cure is one token in each, 'read -r reply || die "aborted."', the same
one heavy-duty/rig#43 applied to rig's credential prompts.

The three answers a human can give (y, n, Ctrl-D) are now driven on a real
pty via util-linux 'script'. They were structurally untested before —
'[ -t 0 ]' sends a terminal-less suite to the refusal branch, so every
existing check stopped there, which is how this survived four releases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:08:25 +00:00
Daniel Marin
41bba74e10
Merge pull request #110 from dan-claude-bot/fix/changelog-rearm
fix: the release ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108)
2026-07-19 22:07:11 +01:00
dan-claude-bot
67331ebecd fix: the armed guard also refuses a half-done ceremony (#108, cast#114 review)
The bare-VERSION branch left the top heading unconstrained on purpose — both
ceremony shapes have to stay legal, which is the rig#44 / cast#108 lesson. A
review round on the sibling fix found the gap that asymmetry leaves: a tree
with VERSION bumped, '## Unreleased' still populated on top, and no stamped
section for that version makes the wrong-number test false on its first
clause, short-circuits, and passes. release.yml then refuses at publish time
— after the merge, on main, with the release already half-shipped.

So the bare branch now also requires the section it is about to publish to
exist and be non-empty, asserted by running release-notes.sh itself so the
guard and the publisher cannot drift over what a section is. The message is
distinct from the wrong-number case: a missing stamp is not a misnumbered
one. Matches heavy-duty/rig#67.

test/release.sh constructs the half-ceremony tree and the stamped-but-empty
tree and drives the real script at both — all three new assertions fail
against the previous guard — while the re-armed and un-re-armed ceremony
trees stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:43:52 +00:00
dan-claude-bot
1a9d96168d fix: the ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108)
Stamping '## Unreleased' away left main with no heading for a PR
authored before the release to land in, so its entry merged cleanly
into the section that just shipped. The ceremony now re-arms, and
changelog-armed.sh enforces it keyed on VERSION -- so the ceremony
PR's own bare-VERSION tree stays legal (rig#44 / cast#108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:30:41 +00:00
Daniel Marin
9ea50d21e2
Merge pull request #109 from dan-claude-bot/fix/restore-confirm
fix: box restore asks before it destroys, in its own words (#105)
2026-07-19 21:25:59 +01:00
dan-claude-bot
531803a8c9 test: pin the no-TTY refusal wording the rehearsal hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:47:05 +00:00
dan-claude-bot
b38518ee54 fix: the unattended rehearsal consents explicitly to the restore gate
CI's multi-user rehearsal drives 'box restore' with no terminal, so the
new gate correctly refused it. --force there is the drill proving the
gate is real. README says restore asks, and that scripts must pass it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:46:06 +00:00
dan-claude-bot
12713c5012 fix: box restore asks before it destroys, in its own words (#105)
The 'confirm' precondition existed but the dispatch hardcoded rm's
wording, so giving restore the token would have asked the operator to
confirm deleting the box they were rescuing. The prompt is now a
per-row field; restore names the snapshot and the loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:40:39 +00:00
Daniel Marin
90ca56cd3f
Merge pull request #106 from dan-claude-bot/fix/ufw-test-flake
fix: the fresh-UFW test block no longer flakes on a missing log
2026-07-19 19:54:46 +01:00
dan-claude-bot
57db9a56bb fix: teardown-host carried the same SIGPIPE race — it sets pipefail (#106 review)
The original diff claimed all three sibling `ufw status | grep -q` sites were
safe because none set pipefail. That is true of drill/wipe.sh and
drill/doctor.sh (both `set -u`) and FALSE of host/teardown-host.sh, whose
line 12 is `set -euo pipefail`. Its line 60 was the identical pipeline, so
the same race could read a plainly-active UFW as inactive and skip the whole
crumb-removal block — leaving stale boxnet/claudenet rules on a host the
operator was told is clean. Its numbered-delete loop had the same early-exit
reader as its condition, so it could also end while rules remained.

Both now read captures, matching box-firewall.sh's fix. The changelog claim
is corrected rather than deleted: this repo's changelog is the record of what
was proven, and shipping a disproven safety claim about a live defect is
worse than the defect, because it tells the next reader not to look.

Pinned in both directions, with comment lines stripped before matching — the
fix's own commentary quotes the racing shape to explain it, and a pin that
cannot tell prose from code fails on the comment documenting why it exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:18:36 +00:00
dan-claude-bot
dc09d54cbe fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `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 the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.

`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.

Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.

Closes #102

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:17:42 +00:00
Daniel Marin
9649659aff
Merge pull request #101 from dan-claude-bot/fix/grant-incus-admin
fix: box grant provisions incus-admin members instead of refusing them
2026-07-19 19:05:34 +01:00
dan-claude-bot
7067fb03b0 docs: box help matches what grant/revoke now mutate (#101 review)
Round 2 changed the incus-admin path from a skipped group step to a real
`usermod -aG incus`, but `bin/box` help still described the superseded
design — telling operators the group step is a no-op that will not happen
(it does) and that a bare revoke has no membership to drop (it drops one).

The help is the pre-run contract: it is what an operator reads to decide
whether to run the command at all, so prose that denies a mutation the
command performs is the same class of defect the rest of this PR exists to
remove. Both sections now describe the current behavior, and `help revoke`
carries the consequence the operator would otherwise discover: once `incus`
is gone, a later `gpasswd -d <user> incus-admin` lands them in NEITHER
group, so grant's "no re-grant needed" holds only while they hold `incus`.

Pinned in both directions — the current sentence must be present and the
superseded one absent — so the prose cannot drift from the scripts again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
dan-claude-bot
8598cf96fe fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:

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

incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.

The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:

- the backout rolls that membership back and verified, while refusing to call
  the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
  of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
  a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
  discipline about /var/lib/incus lying to a non-root admin

Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.

Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
dan-claude-bot
4a06c3ac00 fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.

box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.

On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.

Unblocks heavy-duty/rig#49.

Closes #99

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
Daniel Marin
f2f57cca7f
Merge pull request #97 from dan-claude-bot/feat/release-on-merge
feat: merging a release-labeled PR is the release
2026-07-19 18:12:28 +01:00
dan-claude-bot
c8150f2d5b fix: issues: write — the fallback PR's --label rides the issues API (labels.yml precedent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:35:39 +00:00
dan-claude-bot
b89ed144fe fix: pull-requests scope for the door's two PR-API calls; docs catch up
The permission-starvation blocker found on the cast twin (claude-bot,
cast#112 round 4) is identical here: a declared permissions: block zeroes
every unspecified scope, so the decide step's label read and the bump
fallback's gh pr create could only 403 — every genuine ceremony would end
red at the label check. pull-requests: write added with the consumers
named. CONTRIBUTING step 3 and the changelog entry now tell the shipped
story: push-to-main door, event.before interlock, self-re-arm with the
manual path's bump staying the operator's. Re-runs wording nit taken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:33:41 +00:00
dan-claude-bot
03e1a8c5b7 feat: the release re-arms main — the -dev bump folds into the release act
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner with no judgment for a review to add. After tag +
publish, the same job computes X.Y.(Z+1)-dev and pushes it to main
directly (a GITHUB_TOKEN push fires no workflows: no recursion, no red
run); if branch protection refuses, the step opens the bump PR itself,
loudly. #98 is the last hand-made bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:04:22 +00:00
Daniel Marin
cdf4c043c9
Merge pull request #98 from dan-claude-bot/chore/bump-0.7.1-dev
chore: bump main to 0.7.1-dev
2026-07-19 17:03:25 +01:00
dan-claude-bot
0fbd60e6ff chore: bump main to 0.7.1-dev — a dev install must not impersonate 0.7.0
The post-release step of the ceremony (#83, the 0.6.1-dev precedent):
versions/ names install trees after VERSION, so a main install without
the bump would land in versions/0.7.0 and impersonate the released tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:48:12 +00:00
dan-claude-bot
89345f7de3 fix: the merge door rides pushes to main — fork PR tokens are read-only
Round-1 blocker (claude + grok): a pull_request run from a public fork
gets a read-only GITHUB_TOKEN — permissions: cannot raise it — and every
ceremony PR this org merges is cross-repo from the bot fork, so the tag
create would 403 after green asserts, red on main per release. The door
now triggers on push to main (in-repo event, full token); the decide step
keeps the first-parent version interlock, and the release label — still
the operator's declared intent — is read via the API off the merge
commit's PR. A transition with no labeled PR behind it refuses. The doors
split on the pushed ref: tags to the tag door, main to the merge door.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:46:03 +00:00
dan-claude-bot
00e36b9436 test: the creating-nothing count follows the -dev-endstate change (5 -> 4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:41:21 +00:00
dan-claude-bot
13aa499dad fix: a -dev endstate is always work — the post-release bump must not run red
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), which would have put a red
run on main once per release, forever. A tree that ends -dev is by
definition not a release: every such merge is work, and no-ops green
with a NOTICE. The red verdicts now guard only bare endstates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:40:48 +00:00
dan-claude-bot
420e8e7f32 fix: the release label's two meanings part ways in a decide step
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — the PR that added the merge door included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads
VERSION against the merge commit's first parent and answers all states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output. Five new
pins in test/release.sh cover each verdict and the gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:25:52 +00:00
dan-claude-bot
b58ce5524f feat: merging a release-labeled PR is the release (#96)
The 0.7.0 ceremony exposed the gap: the release PR merged with four
approvals and nothing happened, correctly, because publishing hung off a
separate, manual, silent-when-forgotten tag push — the worst failure
shape, no error and no red X. The ship decision already lives in the
release PR, so the merge now IS the release.

release.yml grows a second door: pull_request closed on main, gated on
merged == true AND the hand-set release label (read from the event
payload — no extra permission). Four asserts, in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in this PR (merge vs first parent — the -dev interlock that
kills a mislabeled ordinary PR); the version's CHANGELOG.md section
extracts non-empty via the existing release-notes.sh; and no tag or
release exists yet. Then, in the same job, it creates the tag ref at
the merge commit via the API and publishes with gh release create
--verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers
no workflows (GitHub's anti-recursion), so the tag door can never fire
off it and double-publish, and the no-existing assert covers a manual
tag racing the merge. The tag-push path stays step-for-step identical
as the documented manual fallback and backfill, gated to the push event
so a closed PR never runs it against a branch ref.

CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS
the release", with the manual tag ritual kept as the fallback.
test/release.sh grep-pins the merged+labeled gate, all four asserts,
the same-job tag+publish, and that the tag-push trigger survives — in
the same daemon-free, fail-closed style.

Fixes #96

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:31 +00:00
47 changed files with 4109 additions and 1395 deletions

49
.ceremony/AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
# AGENTS.md — start here
You are an agent working in a repo governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
the router: find your role below, read its file, then act. The role files
sit beside this one — in ceremony itself at the repo root, in a governed
repo under `.ceremony/` (a machine-managed mirror; never edit those files
in place — they are changed in heavy-duty/ceremony, through its own flow).
## Your role
You were told your role when you were pointed at this repo ("you are a
reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.
## Repo specifics
What is true only of *this* repo — the review panel roster, the `scope:*`
label set, what a drill means, code conventions — lives in the repo's own
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.

76
.ceremony/BUILDER.md Normal file
View file

@ -0,0 +1,76 @@
# BUILDER.md — the builder role
You turn one issue into one PR. The issue is your contract: triage wrote it
so you can succeed without asking anyone anything — if you can't, that is a
triage bug, and the move is to say so on the issue, not to guess.
## Picking
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
never an `epic` (epics organize; their children are the work).
- Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work.
- **One issue at a time.** Finish or release your claim before taking
another.
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim is a promise of a draft PR soon — a claim with no PR
and no activity is what the staleness sweep reclaims.
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
push the branch if it holds anything useful, unassign, and restore
`ready`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. Drafts are invisible to the reviewer panel on purpose — the draft
phase is yours.
- **The issue's acceptance criteria are your definition of done.** Reproduce
them as a checklist in the PR body and check them honestly as you go. If
one turns out to be wrong or unreachable, say so on the issue and get it
amended by triage — do not silently ship less than the issue says.
- Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` — insert **above** the heading below it, never over it
(the monotonic guard's whole reason to exist).
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems you discover go to a **discussion** (or a comment on the
relevant issue), where triage will do its job. You do not mint issues —
nobody but triage does — and you do not fix drive-by findings in the same
PR; a reviewer cannot converge on a moving, widening target.
## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel** (the roster is in the
repo's CONTRIBUTING).
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
in the PR — a maintainer can be asked for a ruling; silence and
force-forward are not options.
## Handoff
When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — hand it to the human, in order:
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
The label write is optimistic — the reconciler validates it, and takes it
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
human's. Address what comes back (`state:addressing`) and re-hand-off the
same way.

98
.ceremony/LABELS.md Normal file
View file

@ -0,0 +1,98 @@
# Labels
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow —
issue #18). One rule joins everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts.
## PR state — who is the ball with? (exactly one per open PR)
| Label | Color | Waiting on |
|---|---|---|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
*poke the reviewers*, in the second *the builder dropped the ball*. And
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
| Label | Color | Means |
|---|---|---|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
States answer *whose ball*; blockers answer *what's in the way*. They are
separate axes because the single-label version kept lying — independent facts
projected onto one totally-ordered label meant one always won and the losers
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
strips it on sight).
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. A `claimed` issue with no open PR and no activity is what the
staleness sweep will reclaim (issue #18); until that machinery exists,
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
actions/labeler, issues get theirs from triage. This repo's set:
| Label | Covers |
|---|---|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
## Issue types
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
bootstrap — a `question` is a discussion, not an issue.

14
.ceremony/README.md Normal file
View file

@ -0,0 +1,14 @@
# .ceremony/ — the vendored doctrine mirror
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
these files here: they are byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
goes red. They are changed in heavy-duty/ceremony, through its own flow,
and arrive here when the pin moves.
The pin lives in `.github/workflows/release.yml` — the single
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
pin governs machinery and doctrine alike: bump it and re-sync this mirror
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
what is stale).

70
.ceremony/REVIEWER.md Normal file
View file

@ -0,0 +1,70 @@
# REVIEWER.md — the reviewer role
You are one voice on a panel. The panel's job is to converge — on an
approval the human can trust, or on a precise statement of what is wrong.
The machine reads only your **verdict**; humans read your reasons.
## The verdict doctrine
- **Every review ends in a verdict**: approve, or request changes. A
comment-only review is a non-verdict — it does not say whether the round
passed, the state machine treats it as not-approved, and the PR simply
stalls. If you have an opinion, you have a verdict; commenting without one
only wedges the flow.
- **The verdict carries blockingness only; the body carries the feedback.**
Non-blocking nits ride an **approval**, and the builder addresses them at
their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what
unblocks it.
- An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right.
## What you review against
In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
spec. Check every criterion; a PR that ships less than the issue says is
a request-changes even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the
incident that made the rule.
3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not
what.
**Verify over opine.** Run what can be run; construct the failing input; a
test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
- **Approve a moving target.** Your approval is of a specific head. If the
builder pushes after your approval, GitHub stales it — that is correct,
and the builder owes a re-request, not an assumption.
## The round rhythm
- Review the **whole PR at the current head** each round, not just the diff
since your last comments — the fix for someone else's point can break
yours.
- The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target).
- Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done.
- If a round exposes a disagreement **within the panel**, argue it in the PR
with evidence until one side concedes or the builder escalates to the
maintainer for a ruling. Two reviewers pulling a builder in opposite
directions without resolution is a panel failure, not a builder failure.

95
.ceremony/TRIAGE.md Normal file
View file

@ -0,0 +1,95 @@
# TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open
**discussions**; you decide what becomes work. The quality of every
downstream stage — a builder succeeding without asking, a reviewer having a
spec to review against — is set here, by you, and nowhere else.
## Why this door exists
Discussions are allowed to be ambiguous; issues are not. An issue is a work
order a builder must be able to execute **without asking anyone anything**.
Keeping one accountable role between the two is what keeps the bar from
eroding — the moment anyone can mint an issue, the backlog fills with
"improve X" entries nobody can build, and builders start guessing. Guessing
is the failure this whole flow exists to prevent.
## Your inputs
- **Every open discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance back into a discussion and close it, saying why. Do not shame the
filer; do route the work correctly.
## For each discussion, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or already tracked. Reply with the answer (link the code,
the doc, the existing issue), mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto
the builder.
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
money, product direction, breaking a public contract. Say precisely what
the decision is, list the options with your recommendation, and name the
decider. The discussion is where humans decide; wait there.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
## The issue contract
Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
open question, the issue is not ready to exist — go back to outcome 2 or 3.
- **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim.
- **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
The bar, stated once: **a competent builder who has read only this issue and
the repo can succeed.** The release-ceremony epic and its children
(heavy-duty/ceremony#1#16) are the house exemplars — that is the density
expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic`
label): the approach, the decisions, the constraint list, and a
dependency-ordered task list of child issues. Children reference the epic;
the epic's checklist is the progress view. Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan.
## Backlog hygiene (yours until #18 automates it)
- **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands.
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
comment, unassign, restore `ready`.
- **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.
## What you never do
- Write code, review code, or build the thing yourself.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint an issue to "discuss" something — that is a discussion.

2
.github/labeler.yml vendored
View file

@ -1,4 +1,4 @@
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope # path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive # story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives. # only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:cli": "scope:cli":

7
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,7 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
scope:cli|C5DEF5|bin/box — the command surface
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
scope:templates|C5DEF5|templates/ — the box seeds
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: three
# formal head-current approvals. Any approval that counts must be bound to
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
# fixture tests in test/labels-reconcile.sh.
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
# ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review="$(jq -c --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<<"$REVIEWS_JSON")"
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
case "$state" in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ;;
esac
}
human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested "$HUMAN"; then return 1; fi
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
return 0
}
decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi
# an explicit human request outranks the bot rounds — it is the final
# gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi
local b v verdicts=""
for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi
done
for b in "${BOTS[@]}"; do
v="$(bot_verdict "$b")"
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
verdicts="$verdicts $v"
done
case "$verdicts" in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
# STALE = a verdict for an older head → the agent owes a re-request.
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:cli|C5DEF5|bin/box — the command surface
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
scope:templates|C5DEF5|templates/ — the box seeds
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
EOF
}
has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age
desired="$(decide_state)"
# encode the runbook's last step for the no-judgment case: three formal
# head-current approvals → the human is asked, once. The guard asks whether
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
# ---- converge the state:* labels ----
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
remove="${remove#,}"
if ! has_label "$desired" || [ -n "$remove" ]; then
args=(--add-label "$desired")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((NOW - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
}
main() {
REPO="${REPO:?set REPO to owner/name}"
NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
local n
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
(
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs"
done
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#83). Fails loudly when the section
# is missing or empty — a tag without its changelog section is a release
# ritual skipped, and an empty release body would paper over it.
#
# A file of its own (not inlined in release.yml) so test/release.sh drives
# the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
printf '%s\n' "$notes"

View file

@ -8,23 +8,105 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# pure-bash tree costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- name: shellcheck - name: shellcheck
# -x follows `source`/`.` directives; box has no lib split today, but the # -x follows `source`/`.` directives; box has no lib split today, but the
# flag costs nothing and keeps the invocation identical to rig's. # flag costs nothing and keeps the invocation identical to rig's.
# globstar so a script in a new subdirectory is linted without anyone # globstar so a script in a new subdirectory is linted without anyone
# remembering to edit this list; bin/* covers the extensionless entrypoint # remembering to edit this list; bin/* covers the extensionless entrypoint
# (bin/box). The file list is printed so under-coverage shows up in the log. # (bin/box). The file list is printed so under-coverage shows up in the log.
#
# dotglob is not decoration (#116): globstar makes `**` descend, but a
# glob still does not MATCH a dot-prefixed name, so `**/` never entered
# `.github/` — and the whole release path (changelog-armed.sh, which
# gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted
# while the comment above told the next author it was covered.
# Measured when this invariant was introduced: dotglob added exactly
# those three and nothing else — a checkout's `.git` carries no `*.sh`
# (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it.
#
# The sweep below is the CLASS check, same shape as the eof_guard_sweep
# in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps
# the gap from reopening is asserting that every TRACKED script is in
# the set actually handed to shellcheck. `git ls-files` is the authority
# on what the repo contains; if the glob ever drifts from it again —
# another dot-directory, another shopt subtlety — CI says which files
# escaped instead of quietly linting a subset and passing.
run: | run: |
shopt -s globstar shopt -s globstar dotglob
files=(bin/* **/*.sh) files=(bin/* **/*.sh)
printf 'shellcheck: %s\n' "${files[@]}" printf 'shellcheck: %s\n' "${files[@]}"
missing="$(comm -13 \
<(printf '%s\n' "${files[@]}" | sort -u) \
<(git ls-files '*.sh' | sort -u))"
if [ -n "$missing" ]; then
echo "tracked scripts the shellcheck sweep does not cover (#116):"
printf '%s\n' "$missing" | sed 's/^/ /'
exit 1
fi
shellcheck -x "${files[@]}" shellcheck -x "${files[@]}"
- name: cli tests - name: cli tests
run: bash test/cli.sh run: bash test/cli.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
- name: release-flow tests - name: release-flow tests
run: bash test/release.sh run: bash test/release.sh
# The changelog is ARMED for the next entry (#108). Its own step rather
# than a line inside test/release.sh: this one asserts a fact about THIS
# tree, not about the release machinery, so when it goes red the log
# says which check found the drift without anyone reading a suite.
- name: changelog is armed for the next entry
uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
# ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143).
# Its own step for the same reason as the one above — when it goes red the
# log names the invariant that broke — but a DIFFERENT invariant: armed is
# a fact about this tree, monotonicity is a fact about this tree versus
# its merge base. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
#
# NOT pull-request-only, and that is the #143 fix at the workflow level.
# The two halves have different vacuity: DELETION is vacuous on a push to
# main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at
# all, so gating the whole script on `pull_request` left a duplicate that
# reached main by any other route unasserted forever.
#
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
# push event `github.base_ref` is EMPTY, so the argument would collapse to
# a bare `origin/`, which does not resolve — and STRICT=1 correctly
# promotes that to a hard failure, turning every push to main red. With
# the fallback it resolves to the pushed branch, whose merge base with
# HEAD is HEAD or its parent: containment passes vacuously, exactly as the
# old `if` intended, while uniqueness now runs on every push.
# ...and a RELEASE tree has a drill record. Its own step for the same
# reason as the two above — when it goes red the log names the invariant
# that broke — and a third invariant again: armed is about the shape of
# the changelog, monotonic about the diff, this one about whether the
# ritual CONTRIBUTING.md hangs on the release PR actually happened.
#
# NOT pull-request-only, and for the same reasoning the monotonic guard
# was un-gated in #143: the release does not finish at the PR. The merge
# that publishes it is a PUSH to main carrying the same bare VERSION,
# and it is release.yml's trigger — so a check that only ever ran on the
# PR would leave the tree that actually ships unasserted, and any bare
# VERSION reaching main by another route (a direct push, a backfill,
# the manual tag path) unasserted forever. On a -dev tree, which is
# every ordinary push and PR, the script no-ops green by design, so
# running it everywhere costs nothing and closes the route.
- name: a release tree has a drill record
uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
- name: no shipped changelog heading was deleted or duplicated
uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
- name: vendored ceremony doctrine matches the pin
uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a # The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
# disposable VM, which is exactly the substrate the rehearsal needs. It runs # disposable VM, which is exactly the substrate the rehearsal needs. It runs

View file

@ -1,56 +1,13 @@
name: labels name: labels
# The automation LABELS.md promises. Two halves:
# scope — path-derived scope:* labels on PRs (actions/labeler)
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
#
# pull_request_target, not pull_request: every PR here arrives from a fork,
# where pull_request (and pull_request_review) run with a READ-ONLY token and
# cannot label anything. _target is safe in this workflow because no PR code
# is ever checked out or executed — labeler reads changed paths via the API,
# and reconcile checks out the BASE branch only. Keep it that way.
#
# Review-submitted transitions (bots finishing a round) ride the cron: there
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
# the same cadence the reviewer bots poll at.
on: on:
schedule: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
- cron: "*/15 * * * *" workflow_dispatch: # bootstraps missing labels on a fresh repo
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
scope: labels:
if: github.event_name == 'pull_request_target' uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
runs-on: ubuntu-latest
concurrency:
group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/labeler@v5
with:
# additive only — a hand-applied scope must survive the machine
sync-labels: false
reconcile:
runs-on: ubuntu-latest
# ONE shared group: every reconcile sweeps every open PR, so cron and
# PR-event runs must serialize or two sweeps race the same PR's labels
# and both pass the request-the-human-once guard. GitHub keeps at most
# one queued run per group (older queued runs are superseded), which
# coalesces bursts instead of piling them up.
concurrency:
group: labels-reconcile
cancel-in-progress: false
steps:
- uses: actions/checkout@v4 # base branch only — never the PR's code
- name: reconcile state + stale
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/labels-reconcile.sh

View file

@ -1,42 +1,18 @@
name: release name: release
# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set # Triggers and permissions MUST live here (a called workflow cannot define them):
# the precedent — no 'v' prefix). Two facts, then one act: the tag must name
# the tree's own VERSION (a mismatch fails loudly and creates NOTHING — a
# wrong release is worse than a missing one), and the release body is that
# version's CHANGELOG.md section (.github/scripts/release-notes.sh, shared
# with test/release.sh) — the curated prose, not the generated PR list. No
# assets are uploaded: for a pure-bash tree, GitHub's source tarball for the
# tag IS the package, and install.sh downloads exactly that.
on: on:
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# `push:` silently replaces the first and kills a door (rig's review catch).
push: push:
# Every tag, not a shape filter (rig's precedent): a tag that mismatches tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY # never be skipped by a shape filter that didn't match
# below, not be silently skipped by a pattern that didn't match. branches: [main]
tags: ["**"]
permissions: permissions:
contents: write # gh release create contents: write # tag ref create + release create + the bump push
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
issues: write # --label on that fallback PR rides the issues API
jobs: jobs:
release: release:
runs-on: ubuntu-latest uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
steps: with:
- uses: actions/checkout@v4 version-source: file
- name: the tag must name the tree's VERSION
run: |
ver="$(cat VERSION)"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
- name: release notes — the version's own CHANGELOG.md section
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: create the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.

View file

@ -3,381 +3,163 @@
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md),
which records not just what changed but what each drill run proved. which records not just what changed but what each drill run proved.
## Unreleased
### Fixed
- `box exec` preserves newlines and command argv across its login-user boundary (#169)
### Changed
- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14)
### Added
- `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant)
## 0.9.0 — 2026-07-21
### 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`, `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.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)
## 0.8.0 — 2026-07-19
### Added
- Merging the release PR is the release, and the release re-arms main itself
(#96)
### Fixed
- The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let main sit
disarmed (#108, heavy-duty/rig#67)
- Ctrl-D at a confirmation prompt aborts out loud instead of exiting in
silence (#111)
- `box restore` asks before it destroys, in the row's own words rather than
`rm`'s (#105)
- `box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the time
(#102)
- A missing firewall log now diagnoses itself (#102)
- `box grant` provisions an `incus-admin` member instead of refusing them
(#99)
## 0.7.0 — 2026-07-19 ## 0.7.0 — 2026-07-19
### Added ### Added
- **The installer defaults to the latest release, and releases publish - The installer defaults to the latest release, and releases publish
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at themselves (#83)
that second: the 0.6.0 release was a bookmark, not a package, and two - `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
operators "on 0.6.0" could be running different trees. `install.sh` now (#80)
resolves the latest release tag by following GitHub's `releases/latest` - `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
redirect (one HEAD request — no API, no token, no rate-limit pain) and - `box doctor` knows the #80 signature: a gateway held as a local address, and
downloads that tag's tarball; a failed resolution refuses loudly, naming duplicate connected routes for the uplink subnet
`BOX_REF` as the way out — it never hangs and never silently falls back to - The `staging` template — a server-class, creds-free seed (#81)
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one - The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
knob yields three channels: default = latest release, `BOX_REF=0.6.0` = - The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag - Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the - The template test suite discovers `templates/*/` instead of hardcoding the
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and list (#81)
publishes the GitHub release with that version's `CHANGELOG.md` section as - `box export` / `box import` — a box's state that survives the box and the
the body (`.github/scripts/release-notes.sh` — the curated prose, not the host (#70)
generated PR list; no assets, the source tarball for the tag IS the - Versioned installs at `<root>/versions/<v>`, with `box versions` and
package). And `main`'s `VERSION` now carries `-dev` between releases `box use` (#66)
(this PR: `0.6.1-dev`): the versioned layout names install trees after - A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
`VERSION`, so a `main` install without the bump would land in in an absence assert
`versions/0.6.0` and impersonate the released tree. `test/release.sh` - `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
drives all of it offline — the extraction against fixtures and the real rehearsal drills the uninstall to zero residue
changelog, the resolution and every channel against a shim curl. - `test/cli.sh` drives real installs against throwaway roots and a fake incus
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero (154 checks)
flags** (#80, completing its fix #1: "refuse … or automatically select a
non-colliding subnet"). A bare `box setup-host` now decides the subnet
itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or
refused, never silently overridden (scripted hosts keep exact semantics);
an existing `boxnet` bridge is converged on as-is — the bridge IS the pin —
turning the old bare-re-run agree-gate refusal into plain convergence
(unless a foreigner *also* claims the bridge's subnet: that is #80's
poisoned state, and converging would rebuild on it, so it still refuses and
names the bridge move); a free `10.88.0.0/24` stays the default; and a
*claimed* default — the nested case: a drill or rehearsal running inside a
box, whose own uplink owns 10.88 — scans `10.89.0.0/24``10.127.0.0/24`
in order, takes the first free candidate, announces the pick and the
claimant loudly, and only refuses when every candidate is claimed. The
decision happens before any mutation, and everything downstream (the
bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's
expectations) derives from it.
- **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another**
(#80) — run inside a box, `setup-host` used to build a nested `boxnet` on
the exact subnet and gateway of the guest's own uplink: the guest then held
its gateway's address as a *local* address, carried duplicate connected
routes for its uplink subnet, and suffered intermittent, self-recovering
egress blackouts that looked like flaky internet (measured live: ~2436 s
outages, roughly hourly, with the host clean throughout). `setup-host` now
scans the target subnet **before any mutation** — the default route's
gateway inside it, or any non-`boxnet` interface holding an address in it —
and refuses, naming the way out. A prior `boxnet` owning the subnet is the
legitimate converge path and does not trip it. `BOX_SUBNET=<a.b.c.0/24>`
(validated, alongside the existing `BOX_DNS`) moves the whole stack: the
bridge address, the ACL's gateway carve-out (now converged via
`network acl edit`, so a bridge moved off a colliding subnet no longer
strands box DNS behind a stale `/32`), the firewall (`box-firewall` reads
the gateway off the live bridge), and every drill/migrate probe that used
to hardcode `10.88`.
- **`box doctor` knows the #80 signature** — a default gateway held as a
LOCAL address, and duplicate connected routes for the uplink subnet, judged
from `ip route`/`ip addr` on the machine doctor runs on (both tiers, before
any daemon check — the nested daemon answering could be the impostor) and
probed *inside* every box it examines. The existing "egress broken but DNS
fine" split now names itself as #80's fingerprint (the impostor dnsmasq on
a captured gateway keeps resolving while IP egress dies), and the admin ACL
section verifies the gateway carve-out matches `boxnet`'s actual gateway.
The agent-context guard for the templates (suggested fix 4) lands in
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
- **The `staging` template** (#81, the re-cut of #69's layering) — a
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
server returns from a host reboot without an operator), and
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
hardening) converges via `rig bootstrap staging` after mint. The tailnet
workload join holds a pre-auth key and therefore **stays operator-run**
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
box never sees the key.
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
template names the **creds-free** rig tenant role box runs inside the guest
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
the host). A failed role leaves the box up and names the re-run — the roles
are convergent by contract (rig#31). `blank` names no role and auto-runs
nothing.
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
preinstall rig, inverting the rig→box install edge (rig#28), and the new
edge gets the same honest treatment rig#29 gave box's unpinned install:
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
until a release flow exists, rig#32/#83). The pin covers both the installer
fetched and the tree it installs, so a rig branch under review is testable
end to end; values are allowlist-validated before touching the YAML.
- **Server-posture template keys** (#81, carved from #69) — two optional
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
(exit 2): such a template's trust boundary is the VM. `BOX_AUTOSTART=1`
stamps `boot.autostart=true` at launch, per-instance like `limits.*`, so
the box returns from a host reboot without an operator; clones inherit it
via `incus copy`. Still no key for a network or a `security.*` flag, on
purpose.
- **Dynamic template test suite** (#81, carved from #69) — `test/cli.sh`
discovers `templates/*/` instead of hardcoding the list, so a new template
cannot ship unseen. Per template: `box.env` is driven through the real,
extracted `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER`
fail, fixtures proving both dies); `user-data.yaml` exists, declares
`#cloud-config`, parses as YAML, and installs tmux (#65). Grep guards pin
the `cmd_new` half: the `REQUIRE_VM` refusal orders after `pick_mode`, and
`boot.autostart` is stamped only under the `T_AUTOSTART` guard.
- **`box export` / `box import`** (#70) — a box's state that survives the box
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
one portable backup tarball (default `<box>-<UTC stamp>.tar.gz`), snapshots
included by default (`--instance-only` opts out); the box must be stopped
first (`box down`) so the artifact is a settled disk, not a moving one. The
file is **shouted about, not scrubbed** — it carries the box's whole disk
(agent logins, git credentials, SSH keys), and scrubbing a disk image is a
promise tarball surgery cannot keep, so box says what is inside instead,
every time. `box import <file> [--name <box>]` mints the box back and
re-stamps what is the _current host's_ truth, not the artifact's: the
`user.box=1` boundary tag (legacy `user.claudebox=1` honored), the
`box-net` placement (re-assigned if the artifact's differs — the
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
restore `volatile.*` verbatim, and a re-import beside its sibling collided
at start with "MAC address already defined on another NIC" — measured
live; `incus copy` regenerates it, `incus import` does not) plus
`reset_identity` (the clone trust boundary: no DHCP collision with the box
it was exported from).
Import refuses any name an existing instance holds — the `resolve_box`
boundary, seen from the other side. Works on both tiers: `box grant` now
also converges `restricted.backups allow` (incus-user blocks backups by
default exactly like snapshots, and an export _is_ a backup
create+download — measured against incus 6.0's `permissions.go`); re-run
`box grant <user>` after upgrading, as documented. CI's `rehearsal` job now
proves the round-trip on a live Incus: mint → write a file → snapshot →
down → export → `rm` → import → the file and the snapshot survived, the
agent answers, the tag is present, and a colliding re-import is refused.
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
`INSTALLED_FROM`), with a `current` symlink tracking the default and
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
verbs: `box versions` (lists installs, marks the current default and the
running tree), `box use <version>` (flips the default, converges the PATH
symlinks, and *asserts the effective result*`current` must resolve to
the asked-for version and the chain's `box --version` must answer it).
Re-running the installer with an installed version is a converging no-op
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
side-by-side and flips `current` only when no boxes exist — under existing
boxes the flip is refused loudly, naming the boxes (#66: never change
versions under a user's boxes; `box use` keeps the same refusal). A
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
installer warns when the *other* tier's install (/opt/box vs ~/.local)
coexists, since PATH order decides which wins.
- **A real uninstall**`box uninstall [<version>] [--all] [--purge-host]`
replaces the "rm -rf two paths" prose. One version: refuses the current one
(`box use` off it first). Everything: runs in the safe order — refuses
while boxes exist (naming them) unless `--purge-host` runs teardown-host
first — then removes every version, the `current` and PATH symlinks, and
the legacy claudebox crumbs (both name generations), and **ends with an
absence assert**: every removed path is re-checked, and any survivor makes
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
for automation and now points at `box uninstall` when done.
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
bypassing the download. CI's rehearsal job now installs via install.sh
itself (proving the installer under review, not a `cp -r` mimic of it), and
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
— no networks, profiles, ACLs, nft tables, systemd units, files or
symlinks.
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
into *driven* tests instead of greps (154 checks).
### Changed ### Changed
- **Thin templates — box mints, rig converges** (#81, companion rig#31) — - Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the the tenant content (#81, heavy-duty/rig#31)
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 ### Fixed
- **A wedged `incus launch` fails loudly, not forever — the mint's launch - A wedged `incus launch` fails loudly, not forever: the launch phase is
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19 narrated and time-boxed (#93)
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), - UFW's gateway carve-out converges with the bridge, and the doctor can see it
the child `incus launch` under `box new` hung with *no server-side (#86)
operation*: `incus operation list` empty, the instance never created, the - The boot-time gateway fallback is gone — an unaddressed bridge leaves the
daemon journal quiet — one wedge ran 56 minutes before being killed by persisted UFW rules alone (#86)
hand, and an immediate retry of the identical command succeeded in - `revoke --purge` re-checks the incus-user state, and stats it through
minutes, both times. `box new` inherited that as an indefinite silent `$SUDO`
hang, indistinguishable from a cold mint working. It now prints - A wedged `$BINDIR/box` no longer blocks installing
`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.
## 0.6.0 — 2026-07-18 ## 0.6.0 — 2026-07-18
### Added ### Added
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin - The restricted tier: `box grant` / `box revoke` give a user their own boxes
runs `box grant <user>` and that user gets their own boxes on the same on the shared hardened `boxnet` (#74)
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back - CI runs the multi-user rehearsal on a real Incus
(`--purge` deletes their world, and asserts the absence). The tier rides - Global / root install — one world-readable tree at `/opt/box` (#71)
incus-user, whose defaults miss box's contract three measured ways (Debian - CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
13 / Incus 6.0.4): a private _unhardened_ NAT bridge per user, snapshots
blocked, the `box-net` profile invisible — so grant is an idempotent
convergence: project narrowed to `boxnet` **and only boxnet** (listing the
private bridge too, the obvious fix, would keep an unhardened network one
`--network` flag away), snapshots allowed, the shipped profile installed
into their project. `box_tier()` (live credentials, argless `id -nG`)
drives the tier-aware surface: `expose` refuses honestly before any daemon
call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed
end-to-end by `drill/multiuser.sh` (criteria an: confinement, lifecycle,
cross-user visibility, name collisions, the in-box isolation contract,
escape hatches, re-sync survival, revoke incl. the live-session case) —
54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review.
- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal`
job stands up the full stack on the runner (setup-host, doctor, then
`multiuser.sh --container`), so every PR proves the tier's semantics
against a live daemon, not a mock. The VM trust boundary itself remains a
real-hardware ritual, like the full drill.
- **Global / root install** (#71) — run as root, box installs _once_ to
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
every operator on a shared host runs the same tree. Per-user installs are
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
got `command not found` — so the root branch lands in a system location and
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
unblocks "rig installs box" (rig#24's `box` role).
- **CI + a test suite**`.github/workflows/ci.yml` (a `check` job: globstar
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
dependency-free and runnable by a non-root user with no Incus. It exercises the
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
tmux in every template — the box was the repo with "no tests and no CI".
### Fixed ### Fixed
- **`box restore` never worked against Incus 6** — the command table - `box restore` never worked against Incus 6 — it dispatched `incus restore`,
dispatched `incus restore`, a subcommand that does not exist (Incus 6 which does not exist
spells it `incus snapshot restore`), so every restore died on "unknown - `box tmux` works on every template — tmux is in each template's package list
command". Found by #74's rehearsal exercising the full lifecycle as a (#65)
restricted user; fixed for every tier, and the rehearsal + a grep-guard in - `box setup-host` finishes in one run, re-execing itself under
`test/cli.sh` now hold it. `sg incus-admin` (#63)
- **`box tmux` works on every template** (#65) — `box tmux` runs - `setup-host` works as root, with or without `sudo`
`tmux new-session` _inside_ the box, but the templates did not install tmux, so - `setup-host` grants `incus-admin` to the human, not to root
it failed with `tmux: command not found`. `tmux` is now in each template's - `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
cloud-init package list (`blank`/`claude`/`codex`/`grok`). - `setup-host`'s apt calls can no longer hang on the dpkg lock
- **`box setup-host` finishes in one run** (#63). When it had to add you to
`incus-admin` it stopped there and told you to re-login and re-run — an
`exit 0` that reported success having built none of the stack: no `boxnet`,
no ACL, no `box-net` profile, no firewall. It now re-execs itself under
`sg incus-admin` and completes in that one invocation. The membership check
was also asking the wrong question: `id -nG "$USER"` reads the group
database, which lists the group the moment `usermod` returns, so a
same-session re-run passed the check with credentials that still lacked the
group and died further down on a bare permission error from `incus`. Argless
`id -nG` asks the process what it actually holds.
- **`setup-host` works as root, with or without `sudo`** — every privileged
call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package)
it died on `sudo: command not found` before doing anything. Privilege is now
resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if
neither is possible. This is what made `install.sh`'s root path real rather
than nominal.
- **`setup-host` grants `incus-admin` to the human, not to root** — under
`sudo install.sh` it would have added `root` to the group: a no-op (UID 0
opens the socket regardless) that also left the actual user locked out of
their own boxes. It now derives the login user from `SUDO_USER`.
- **`box-firewall.service` now reports its state honestly** — the unit is
`Type=oneshot` and was missing `RemainAfterExit=yes`, so it went
`inactive (dead)` the instant it succeeded: a host whose isolation was
perfectly live read as one whose firewall unit had died. drill.sh sends you
to `systemctl status box-firewall` to diagnose exactly that, and
setup-host.sh's own comment already asserted the unit "is RemainAfterExit" —
it was not. Found by running the drill on a real host and mistrusting the
green: `nft list table bridge box` showed the drop live while the unit read
dead. `restart` was and remains correct either way.
- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has
`apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain
`apt-get install` waits on it silently and indefinitely. Now bounded
(`DPkg::Lock::Timeout=300`) and non-interactive, which matters because
`install.sh` runs it with nobody watching.
### Changed ### Changed
- **`drill.sh` proves the new contract instead of masking it** — the drill ran - `drill.sh` asserts the post-install stack instead of building it itself
`setup-host` itself right after installing, so the stack existed by its own - `install.sh` asks, sets up the host, and no-ops on re-run (#64)
hand and a run passed identically whether or not `install.sh` had done a - `install.sh` never overwrites an existing install
thing; a fresh run converged the stack three times, while the messages still
described the pre-#63 "first pass may only add you to the group" behaviour.
It now asserts the post-install stack in-group before touching the host, and
runs `setup-host` exactly once more — after the clean, which deliberately
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
hands sequencing back to the drill. Pre-setup tripwires now read _before_
`install.sh`, since that is what triggers setup now.
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
prompts _"Install box?"_, then on a fresh host installs the tree and asks a
second question, _"Set up this machine as a box host now?"_, running the whole
isolation stack if you say yes (previously it only printed a warning and left
you a command, so the install reported success and `box new` died on a host
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
- **`install.sh` never overwrites an existing install** — if box is already
installed it says so and changes nothing, so a stray re-run can no longer
clobber a working tree or rebuild the host stack under live boxes. Upgrading
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
preserved first) and install fresh. This replaces the earlier version-diff
refusal with a simpler rule that dissolves the same class of errors. The
version-aware upgrade that migrates boxes instead is #67; a portable
`box export` so a box survives its own deletion is #70.
## 0.5.0 — 2026-07-15 ## 0.5.0 — 2026-07-15
@ -388,61 +170,27 @@ installer retires — but nothing current carries the old name.
### Added ### Added
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes, - `codex` and `grok` templates
creds-free like every template. The template mechanic (image + user + - `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
resources, never a network or a `security.*` key) now has three tenants inside a box
beside `blank`, and the drill mints all of them cold. - Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only - Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
door to a port inside a box, for seeing a dev server in your browser. The `box migrate-host`
listen side is always the host's `127.0.0.1` (no flag to widen it), the door - The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
is per-port, `--list`/`--remove` manage it, and `box info` shows open read)
exposures — a box with a hole says so.
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
flag can touch. `--from` refuses them — a clone carries its source's
resources.
- **Host lifecycle as verbs**`box setup-host`, `box teardown-host`, and
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
bridge once empty (`--retire-legacy`).
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
can ship, renamed from `.claudebox/` (both spellings read).
### Fixed ### Fixed
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on, - VM mints no longer hang at GRUB — boxes launch with
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad `security.secureboot=false`
shim signature" forever. Boxes now launch with `security.secureboot=false`; - `box expose` actually delivers packets
the VM boundary, not boot attestation, is the box threat model. - Firewall rules converge on upgrade instead of pinning a host to the release
- **`box expose` actually delivers packets** — a trilogy of drill-found that first ran there
absences: the NAT proxy needs the box's boxnet lease pinned as a static - Failed mints tell you why
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not - `grok` installs the binary it actually ships
the lease); a loopback-sourced packet needs `route_localnet` plus a
masquerade on the bridge to leave the host and be answerable; and the box's
replies need a `ct state established,related` accept ahead of the host
firewall's input drop, which was eating them statelessly. Boxes still
cannot initiate toward the host — a box-originated SYN is a NEW flow.
- **Firewall rules now converge on upgrade**`box-firewall.sh` rebuilds its
chains every run (add + flush + re-add) instead of skipping when they
exist, which had pinned every host to the rule set of the release that
first ran there.
- **Failed mints tell you why** — cloud-init failures print the box's own log
excerpts and leave the box up to inspect; a mint that never boots names the
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
console dump; the installer asserts it landed the ref it was asked for.
- **`grok` installs the binary it actually ships** — the installer was read,
not guessed at, and the CLI lands on the non-interactive PATH (same fix
class as codex).
### Changed ### Changed
- **Debrand complete** — env vars, install dir, docs, template descriptions - Debrand complete — env vars, install dir, docs, template descriptions and
and the README all say `box`; the install URL is the README all say `box`; the install URL is `heavy-duty/box`
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides). - The drill grew from 47 to 84 checks
- **The drill grew from 47 to 84 checks** — the expose door opened, exercised
and shut (with the contract re-probed around it), every template minted
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
inline resource flags asserted (including their precedence over the
environment).

View file

@ -1,87 +1,83 @@
# Contributing # Contributing
How change lands in this repo. The short version: PRs are born as drafts, This repository is governed by
three reviewer bots take the first rounds, a human takes the last word — and [heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
labels tell you where everything is without opening anything. [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it
selects. The files under `.ceremony/` are machine-managed and must never be
edited in place.
## The PR loop Only triage mints issues. Everyone else opens or extends a discussion when
they find work outside an existing issue contract. Only humans merge.
1. **Fork and branch.** Contributors work from forks; upstream branches are ## Review panel
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
and include a `CHANGELOG.md` entry under `## Unreleased` when the change The review panel is:
deserves one.
2. **Open as a draft** while you build. Drafts are invisible to the reviewer - `claude-bot-andresmgsl`
bots on purpose. - `codex-bot-andresmgsl`
3. **When it's ready**: mark ready-for-review and request all three bots — - `grok-bot-andresmgsl`
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. - `kimi-bot-andresmgsl`
They poll roughly every 15 minutes.
4. **Rounds are answered whole.** Wait until all three have reviewed, then Every PR needs a current-head verdict from the whole panel minus its author.
answer the entire round in a **single reply**, push the fixes, and `dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
re-request the bots that didn't approve. Prefer verification over invisible to the panel; when ready, request every eligible reviewer.
argument: a test settles what a comment thread can't.
5. **Reviews end in a verdict.** A reviewer — bot or human — either ## Code and verification
**approves** or **requests changes**, never a bare comment. A
comment-only review is a non-verdict: it doesn't say whether the round - Bash executables use `set -euo pipefail`; test harnesses use `set -u`
passed, and the state machine (and anyone scanning the board) has to because they assert failing commands.
guess. The verdict carries *blockingness only*, the body carries the - Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
feedback: non-blocking nits ride an **approval** and the author addresses CI also runs the Incus multi-user rehearsal.
them at their discretion; anything blocking — including a question that - Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
gates the verdict — is **request changes**, saying what unblocks it. The - Comments preserve the incident that bought a rule, including its issue
reconciler treats a comment-only review as not-approved, so commenting number.
without a verdict only stalls the PR. The machine never reads review
bodies: when a comment-only reviewer's line is really an agreement, that ## Changelog
judgment belongs to the **author** — escalate by requesting the
maintainer's review (step 6), and the reconciler flips the label on that Every behavior-changing PR adds one concise line under `## Unreleased`,
request, because an explicit request is a fact it can trust. above the shipped heading below it. Cite the issue or PR. Never replace or
6. **When the round passes, the author hands the PR to the maintainer** by duplicate a shipped heading; the shared armed and monotonic guards enforce
requesting their review — that request is what flips `state:needs-human`. both halves of this rule.
With three formal head-current approvals the labels workflow requests it
automatically; when part of the panel is comment-only, reading their
agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
## Releases ## Releases
A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83)): The release ceremony, merge and tag doors, version stamps, guard semantics,
and recovery paths are defined by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md).
Box pins the shared machinery and doctrine at `0.1.0`.
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION` Box uses the `file` version backend and has no artifact hook: for this
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version + pure-Bash tree, GitHubs source tarball for the tag is the package, and
date (feature PRs land their changelog entry as part of the PR, so the `install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
section is already written). This PR is where the release ritual hangs: `drills/<version>.md` remain box-owned release inputs.
the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary.
2. **Merge, then tag the merge commit** bare `X.Y.Z` — no `v` prefix, the
`0.6.0` tag set the precedent — and push the tag.
[release.yml](.github/workflows/release.yml) takes it from there: it
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. No assets — the source tarball for
the tag is the package, and `install.sh` downloads exactly that.
3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not
cosmetic — the versioned layout names install trees after `VERSION`, so a
`main` install without the bump would land in `versions/X.Y.Z` and
impersonate the release you just cut.
## Labels — who sets what ### What a box drill proves
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is The box drill is the 85-probe VM isolation contract: it exercises the trust
who sets each kind — most of it is machinery, and hand-moving a boundary on real hardware. The lighter Incus container rehearsal in CI proves
machine-owned label just gets corrected on the next pass: the tier mechanics but cannot substitute for that boundary measurement. The
record format and operating procedure live in [drills/README.md](drills/README.md).
| Labels | Set by | `drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
|---|---| different artifacts. The former is per-release evidence read by the release
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. | guard; the latter is the harnesss ongoing run log and lore. Updating one
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | never satisfies the purpose of the other.
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
| `blocked`, `release` | you — automation never guesses intent. |
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
## Issues The family drills are independent and may run in any order. Each pins the
same fixed candidate refs: rigs drill uses the candidate box ref, while
boxs drill mints with the candidate rig ref. Static refs dissolve the
box↔rig runtime recursion; no repository needs to release first.
Give issues the same care as PR titles: say the surface in the title, apply a A known gap remains from box#81: released box templates still default
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when `RIG_REF` to `main`, so a later mint may consume a rig revision other than
you open one, and `blocked` when it waits on something — that is what keeps the one drilled. This conversion does not change that behavior or claim the
the board navigable as the issue count grows. gap is closed.
## Scope labels
- `scope:cli``bin/box`, the command surface
- `scope:installer``install.sh`, versioned installs, upgrade/uninstall
- `scope:host` — host setup, teardown, firewall, and isolation stack
- `scope:tiers` — grant/revoke and multi-user boundaries
- `scope:templates` — template and profile seeds
- `scope:drill` — rehearsals, doctor, and run evidence

View file

@ -1,87 +0,0 @@
# Labels
How this repo uses GitHub labels. The taxonomy is shared across the
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
because it names this repo's actual surfaces.
## State — who is the ball with? (PRs, exactly one)
Every open PR carries exactly one `state:` label, and it answers the only
question a board scan actually asks: *who is this PR waiting on?* The states
mirror the review loop this repo runs — PRs open as drafts, three reviewer
bots pick up ready PRs with reviews requested, each round is answered in a
single reply, and a human takes the final review.
| Label | Color | Waiting on | Enters when | Leaves when |
|---|---|---|---|---|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
first means *poke the bots*, staleness in the second means *the agent dropped
the ball*. Collapsing them loses exactly the information a sweep needs.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers |
|---|---|
| `scope:cli` | `bin/box` — the command surface itself |
| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall |
| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack |
| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics |
| `scope:templates` | `templates/` — the box seeds |
| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md |
## Issue types
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
would just say the same thing twice, drifting apart eventually.
## Maintenance
State labels are written by automation, never by hand. Every state above is
derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so the labels workflow
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: a manual dispatch creates any
missing label idempotently. To create them by hand (needs push access):
```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
gh label create "scope:cli" --color C5DEF5 --description "bin/box — the command surface" --force
gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force
gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force
gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force
gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force
gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --force
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
# that, so this block converges on re-run instead of erroring after first success.
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
gh label delete "$L" --yes 2>/dev/null || true
done
```

209
README.md
View file

@ -2,8 +2,8 @@
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh, **Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the coding-agent templates network-isolated Incus box from a **template**; the coding-agent templates
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig) (OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
converges**: the template is a thin seed, and the agent tooling lands via a converges**: the template is a thin seed, and the agent tooling lands via a
creds-free `rig bootstrap` role auto-run at mint creds-free `rig bootstrap` role auto-run at mint
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product ([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
@ -217,13 +217,14 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
## Quick start ## Quick start
```sh ```sh
box new --name work --template claude # a creds-free coding-agent box (~10 min cold) box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
box shell work # enter as the template's user box shell work # enter as the template's user
``` ```
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
`blank` for none. Inside the box, authenticate as needed. The `claude` template `grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
looks like this; the others follow the same shape with their own login step: `claude-box` template looks like this; the others follow the same shape with
their own login step:
```sh ```sh
claude # then run /login — copy the URL (press c), open it claude # then run /login — copy the URL (press c), open it
@ -239,12 +240,13 @@ No coding agent is special — each is one template among several, and adding
another is just another directory. What ships today: another is just another directory. What ships today:
| Template | What it becomes | | Template | What it becomes |
| --------- | ---------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------ |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. | | `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude` | Claude Code, creds-free — where this project started | | `claude-box` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex CLI, creds-free | | `codex-box` | OpenAI Codex CLI, creds-free |
| `grok` | xAI Grok CLI, creds-free | | `grok-box` | xAI Grok CLI, creds-free |
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts | | `kimi-box` | Moonshot Kimi CLI, creds-free |
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
**Templates are thin seeds; rig does the becoming** **Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a ([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
@ -255,8 +257,11 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
joins a tailnet or admits credentials — and after cloud-init settles, box joins a tailnet or admits credentials — and after cloud-init settles, box
auto-runs the template's **creds-free** tenant role inside the guest auto-runs the template's **creds-free** tenant role inside the guest
(`rig bootstrap claude` / `codex` / `grok` / `staging`, (`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI, [rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
template is named for the role it converges,
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
docker, the server posture and the agent-context file all come from that docker, the server posture and the agent-context file all come from that
role — convergent and idempotent, so the same command re-run later converges role — convergent and idempotent, so the same command re-run later converges
an *existing* box to a newer spec (`box shell <box>` → an *existing* box to a newer spec (`box shell <box>` →
@ -265,9 +270,9 @@ an *existing* box to a newer spec (`box shell <box>` →
`box setup-host`, `box teardown-host` or the drill *inside* a box — once, `box setup-host`, `box teardown-host` or the drill *inside* a box — once,
from rig's roles, instead of copy-pasted per template. from rig's roles, instead of copy-pasted per template.
**Anything that joins or admits stays operator-run.** The `staging` box's **Anything that joins or admits stays operator-run.** The `staging-box`
tailnet workload join holds a pre-auth key, so box only prints it as the tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept). split, kept).
@ -279,9 +284,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
environment: environment:
```sh ```sh
box new --name work --template claude # heavy-duty/rig @ main box new --name work --template claude-box # heavy-duty/rig @ main
RIG_REPO=you/rig RIG_REF=my-branch \ RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude # a rig branch under review box new --name trial --template claude-box # a rig branch under review
``` ```
Both directions of that edge track `main` unpinned today — said honestly, Both directions of that edge track `main` unpinned today — said honestly,
@ -320,11 +325,92 @@ box new --name feature --from work/authed # clone the authed state into a new
`--from` copies the whole box (agent login, git creds, clones and all) while `--from` copies the whole box (agent login, git creds, clones and all) while
preserving isolation. You can also `box new --name x --from work` to clone preserving isolation. You can also `box new --name x --from work` to clone
a box's live state, or roll a box back with `box restore work authed`. a box's live state, or roll a box back with `box restore work authed` — which
asks first, since a rollback discards everything since the snapshot (`--force`
skips the prompt, and scripts must pass it: with no terminal to ask on, box
refuses rather than assuming yes).
Forgotten what you called a checkpoint? `box info work` prints the box's Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one. snapshot labels and the `--from` line to clone one.
### `pristine` — the one checkpoint box takes for you
Every fresh mint marks a snapshot called `pristine`
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
is true: **after cloud-init, before `rig bootstrap` converges the tenant
role.** At that instant the guest is pristine Debian plus box's thin seed
(the user, tmux, rig) and nothing else — the state
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
to pristine Debian". It exists for a few seconds on every mint, so box
captures it rather than asking you to be quick.
```sh
box restore work pristine # undo the tenant role and everything since
```
That is a complete undo for every tenant role: everything `rig bootstrap
claude|codex|grok|staging` does — docker, node, the agent CLI, the
agent-context file, the role marker — is box-local and file-shaped, so a
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
Three things it deliberately does not do:
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
deletes a box _and_ every snapshot it has. `box export` is the only state
that outlives the box — see below.
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
registration, a pushed commit: those are records held somewhere else, and
no filesystem rollback undoes them (rig#62 covers those separately).
- **A `--from` clone gets no `pristine` of its own.** A clone skips
cloud-init and rig entirely, so it has no pristine moment to capture, and
box will not label a source's worked-in state as one. Cloning a _box_
inherits the source's snapshots (a real `pristine` among them, if the
source had one); cloning a _snapshot_ starts with none. `box new` says
which of the two you got.
On a host whose storage pool uses the `dir` driver, a snapshot is a full
multi-GB copy rather than a near-free copy-on-write mark, so the mint
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
skips the mark on any host.
### `bootstrapped` — the same undo, one step later
A mint whose template names a bootstrap role marks a second snapshot,
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
`rig bootstrap` has run and box has **watched it succeed**. That is the box
converged and not yet touched — the state you actually wanted back most of
the time.
```sh
box restore work bootstrapped # keep the tenant role, undo what you did to it
box restore work pristine # throw the tenant role away too
```
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
pool (with two marks that disk cost is twice the size, so a CoW-less host is
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
(`box export` is the durable path), and it **cannot reach off-box state**
a tailnet device record, a runner registration (rig#62).
**Read the label in one direction only.** Its _presence_ means the mint-time
hook converged and nothing has touched the box since. Its _absence_ means
nothing at all, because box only marks a hook it watched:
- A **blank** box runs no hook, so there is no convergence to mark. It gets
none — `pristine` and `bootstrapped` would be the same disk state at twice
the cost, and a label claiming a convergence that never happened is worse
than no label.
- A box whose hook **failed** gets none either. box tells you to re-run the
role by hand through `box shell`, and a by-hand run happens in a shell box
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
at the moment it is true, rather than inventing a fact.
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
`box info <box>` is what actually tells you which labels a box has.
## Survive the host: `box export` / `box import` ## Survive the host: `box export` / `box import`
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
@ -383,12 +469,17 @@ the door is per-port, punched and removable at runtime.
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container] box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
box templates # list the templates this install can mint box templates # list the templates this install can mint
box list # list your boxes box list # list your boxes
box info <box> # one box: state, IP, exposures, snapshot labels box info <box> # one box: state, IP, exposures, provenance, snapshots
box shell <box> # enter as the template's user box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>) box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> # roll back to a snapshot box restore <box> <snap> [--force]
# roll back to a snapshot — destructive, asks first
# 'pristine' is auto-marked at mint: back to
# pristine Debian + box's seed, before rig ran
# 'bootstrapped' too, if a rig hook converged:
# the role kept, everything since undone
box export <box> [<file>] [--instance-only] box export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host # one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>] box import <file> [--name <box>]
@ -419,6 +510,82 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
`security.nesting=true`) is for hosts without nested virt — weaker isolation, `security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only. dev/test only.
## What minted this box: `box info`
A box outlives the release that minted it, the template that shaped it and the
image build it came from — and until
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
There is no host-side per-box store; the Incus instance config _is_ the
database, so a fact not written at mint time is simply gone. `box new` now
stamps what it knew, and `box info` reads it back:
```
NAME work
STATE RUNNING
TYPE VM
IPV4 10.x.x.x
MINTED 2026-07-19T14:22:07Z by box 0.8.1
TEMPLATE claude (user claude, role claude)
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
MODE vm (asked: auto)
RIG heavy-duty/rig@main
ORIGIN mint
```
The image line carries both halves on purpose: the template names an
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
only reproducible fact. `box info --json` carries every key verbatim — they
ride `incus list --format json` in `config`.
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
its source's template and user for free — but inheriting the mint stamp would
not make it stale, it would make it **false**: the clone was not present at that
mint. `box new --from` therefore re-stamps the four keys that describe _this_
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
box version that cloned it) and leaves the lineage keys alone, because the
clone's disk genuinely did come from that image, template and role. `origin.from`
records one hop: a clone of a clone names its parent, not its grandparent.
**An import records the trip, and rewrites nothing**
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
import` restores is the _artifact's_ truth, so an imported box keeps its mint
stamp verbatim — the mint time, the box version, the image and the origin
belong to the originating host and survive the trip on purpose. What `box
import` adds is the one fact the artifact cannot carry: that the trip happened.
```
MINTED 2026-06-01T10:00:00Z by box 0.7.0
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
ORIGIN clone of work/authed
```
It is **not** `origin=import`, and the difference is the whole point. `origin`
answers how the instance came into _being_ — mint or clone — and overwriting it
would destroy that: the clone above would come back claiming to be an import,
with nothing left saying it was ever a clone and an `origin.from` naming a
lineage no key explains. The import is a _third_ fact, orthogonal to the first
two, so it takes its own keys and leaves every other one alone.
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
stops the mint time being misread as this host's. Note what it does not claim:
box has no record of _which_ host minted the box, and a box can be exported and
re-imported onto the same host (that is the upgrade flow above), so the line
states only the ordering — the one thing box actually knows.
**A box can make the trip more than once**, and both ends are kept: the first
import is pinned forever, the latest is refreshed on every arrival, and a count
says how many. Last-wins alone would erase the evidence of the earlier trips,
which is the same mistake `origin=import` makes one level up. (The shape
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
manifest: a birth pair plus a latest pair.)
**Boxes minted before this stamp existed keep working**, under this verb and
every other — they render as a box with blanks and say `MINTED (not recorded)`
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
not the box version) so a box minted by a later release reads back on an older
box as "here is what I understand, and there is more I don't".
## Boxes are just Incus instances ## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes

View file

@ -1 +1 @@
0.7.0 0.9.1-dev

709
bin/box
View file

@ -9,10 +9,27 @@ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0 remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
cpu=""; memory=""; disk=""; instance_only=0 cpu=""; memory=""; disk=""; instance_only=0
inst="" # the resolved Incus instance, set by the 'box' precondition inst="" # the resolved Incus instance, set by the 'box' precondition
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
# the exit status cannot tell a mark that was taken from one that was skipped
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
# operator a mark must ask this, not assume the call happened.
marks=""
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; } # The tree's own version, read in ONE place. 'box --version' says it out loud;
# the mint stamp (#103) writes it onto every instance box creates, so a box can
# still name the release that made it long after that release is history.
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
version() { echo "box $(box_version) ($root)"; }
# The SHAPE of the mint stamp, not the box version — an integer that changes
# only when a key is removed or repurposed, never when one is added (a reader
# that does not know a key simply does not print it). Absent means pre-stamp:
# every box minted before #103 has no schema key at all, and must keep working
# under every verb, which is the same promise 'user.claudebox' carries.
BOX_STAMP_SCHEMA=1
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG': # Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
# what the kernel will present when incus opens the socket), never from the # what the kernel will present when incus opens the socket), never from the
@ -35,7 +52,7 @@ box_tier() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The command table. # The command table.
# #
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message # verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
# #
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]). # Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
# #
@ -44,7 +61,8 @@ box_tier() {
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed # isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
# arg2 a second positional is required # arg2 a second positional is required
# stopped the box must not be running # stopped the box must not be running
# confirm destructive: prompt unless --force # confirm destructive: prompt unless --force. The row supplies the words
# (last field) — see 'confirm prompt' below.
# #
# action: # action:
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]` # incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
@ -52,6 +70,19 @@ box_tier() {
# #
# ok message: printed on success; {} = the box, {1} = the second positional. # ok message: printed on success; {} = the box, {1} = the second positional.
# #
# confirm prompt: the words the 'confirm' precondition asks with, phrased as
# the act ("delete X", "roll X back to Y") — confirm() wraps it into
# "box: <prompt>? this cannot be undone." and into the no-TTY refusal
# ("refusing to <prompt> without --force"). It is a per-row field and not a
# shared string on purpose: the prompt was hardcoded to rm's wording once, and
# the whole reason 'restore' shipped ungated for four releases is that adding
# the token to its row would have asked the operator to confirm DELETING the
# box they were trying to rescue (#105). A gate that names the wrong act is
# worse than no gate — it teaches people to answer 'y' without reading. Same
# substitutions as the ok message, except {} is the RESOLVED instance: the
# prompt names the thing about to be destroyed, so under --remote it should
# say 'lab:work', not 'work'.
#
# Adding a thin verb is one row. If a request can't be expressed as a row and # Adding a thin verb is one row. If a request can't be expressed as a row and
# doesn't enforce a box invariant, it is incus's job, not ours — that is # doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for. # what `box incus` is for.
@ -59,18 +90,18 @@ CMDS=(
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^" "new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^" "templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^" "list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" "info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^" "shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^" "exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" "tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^" "snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}" "restore^<box> <snapshot> [--force]^box,arg2,confirm^Roll a box back to one of its snapshots — irreversible, and it asks first^incus:snapshot restore^restored {} to {1}^roll {} back to snapshot '{1}' and discard everything in the box since it was taken"
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^" "export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^" "import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}" "rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}" "down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
"start^<box>^box^Start a stopped box^incus:start^started {}" "start^<box>^box^Start a stopped box^incus:start^started {}"
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}" "rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}^delete {} and all its snapshots"
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^" "expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^" "incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^" "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
@ -92,12 +123,12 @@ is_command() { cmd_row "$1" >/dev/null 2>&1; }
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from # locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
# error paths inside it — a global read would clobber the row being dispatched. # error paths inside it — a global read would clobber the row being dispatched.
field() { field() {
local r f_syn f_pre f_sum f_act f_ok local r f_syn f_pre f_sum f_act f_ok f_cnf
r="$(cmd_row "$1")" || return 1 r="$(cmd_row "$1")" || return 1
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r" IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
case "$2" in case "$2" in
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;; syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
act) echo "$f_act" ;; ok) echo "$f_ok" ;; act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
esac esac
} }
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; } synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
@ -147,7 +178,7 @@ COMMANDS
EOF EOF
local r v sum local r v sum
for r in "${CMDS[@]}"; do for r in "${CMDS[@]}"; do
IFS='^' read -r v _ _ sum _ _ <<<"$r" IFS='^' read -r v _ _ sum _ _ _ <<<"$r"
printf ' %-13s %s\n' "$v" "$sum" printf ' %-13s %s\n' "$v" "$sum"
done done
cat <<'EOF' cat <<'EOF'
@ -161,7 +192,8 @@ OPTIONS
dev/test only. Default where /dev/kvm is absent. dev/test only. Default where /dev/kvm is absent.
--json Emit Incus JSON instead of a table (list, info) --json Emit Incus JSON instead of a table (list, info)
--instance-only Export the live state only, no snapshots (export) --instance-only Export the live state only, no snapshots (export)
--force, -f Delete without asking (rm); overwrite the file (export) --force, -f Destroy without asking (rm, restore); overwrite the
file (export)
--remote <r> Act on Incus remote <r> (any) --remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command --help, -h Help; after a command, help for that command
--version, -V Print the box version --version, -V Print the box version
@ -170,7 +202,7 @@ OPTIONS
EXAMPLES EXAMPLES
# mint a claude box and log in inside it — the tool never handles your token # mint a claude box and log in inside it — the tool never handles your token
box new --name work --template claude box new --name work --template claude-box
box shell work # then: run 'claude', then /login box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it # log in once, reuse forever: checkpoint the authed box, clone from it
@ -227,13 +259,13 @@ help_cmd() {
case "$1" in case "$1" in
new) cat <<'EOF' new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default: Mint a box. Without --from, launches a fresh box from a template (default:
blank — bare Debian 13, nobody home; --template claude gets Claude Code blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or installed, creds-free, ~10 min cold). With --from, clones an existing box or
one of its snapshots — login state, git creds and clones carry over, one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without isolation is preserved, and the clone knows its template's user without
being told. being told.
A tenant template (claude, codex, grok, staging) is a THIN seed — the user, A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
role inside it ('rig bootstrap <role>', rig#31): that role installs the role inside it ('rig bootstrap <role>', rig#31): that role installs the
agent CLI / server posture and the agent-context file. rig is preinstalled agent CLI / server posture and the agent-context file. rig is preinstalled
@ -270,9 +302,40 @@ there is no flag for a network or a security key, on purpose.
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call — BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
a launch that overruns it fails loudly instead of hanging forever (#93). a launch that overruns it fails loudly instead of hanging forever (#93).
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
it is true: after cloud-init, before rig converges anything. That is the
guest as pristine Debian plus box's thin seed, and it is what
box restore <box> pristine
rolls back to — undoing the tenant role and everything done since, without
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
every snapshot it has, so 'box export' remains the only state that outlives
the box. A --from clone takes no 'pristine' of its own — it has no pristine
moment; it inherits the source's snapshots (copying a box) or starts with
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
full copy rather than a CoW mark, the mint skips it and says so.
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
A mint whose template names a bootstrap role marks a second snapshot,
'bootstrapped' (#130), once the rig hook has run and box has watched it
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
away only what you did afterwards. Same rules: never fatal, skipped on a
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
It is deliberately NOT unconditional the way 'pristine' is. A blank box
runs no hook, so there is no convergence to mark and it gets none — the
label marks an event, not a mint. And if the hook FAILS, box tells you to
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
shell box does not watch, so box will not label a convergence it never saw.
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
the label one way only: its PRESENCE means the hook converged untouched;
its ABSENCE means nothing at all.
box new --name scratch # blank, the default box new --name scratch # blank, the default
box new --name work --template claude box new --name work --template claude-box
box new --name lean --template claude --cpu 2 --memory 3GiB box new --name lean --template claude-box --cpu 2 --memory 3GiB
box new --name feature --from work/authed box new --name feature --from work/authed
EOF EOF
;; ;;
@ -351,6 +414,19 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have. have.
box snapshot work authed box snapshot work authed
Two labels are taken for you (see 'box help restore'): every fresh mint
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
that role converge (#130). 'bootstrapped' is skipped where there was no hook
to watch — a blank box, or a hook box told you to re-run by hand — so its
absence proves nothing. Take it yourself in that case:
box snapshot work bootstrapped
Snapshots do not outlive their box: 'box rm' deletes a box and every
snapshot it has. They are an undo, not a backup — 'box export' is the
durable path.
EOF EOF
;; ;;
restore) cat <<'EOF' restore) cat <<'EOF'
@ -358,6 +434,51 @@ Roll a box back to one of its snapshots, in place. Anything in the box since
that snapshot is lost. 'box info <box>' lists the labels. that snapshot is lost. 'box info <box>' lists the labels.
box restore work authed box restore work authed
box restore work authed --force
box restore work pristine
'pristine' is the one label box takes for you (#104): every fresh mint marks
it after cloud-init and before rig converges the tenant role, so it is the
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
"I broke this box" for every tenant role — everything 'rig bootstrap
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
all of it, without a ~10-minute re-mint.
What it does NOT undo is anything that left the box: a tailnet join, a
GitHub runner registration, a pushed commit. Those are off-box records a
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
A box minted with --from has no 'pristine' of its own unless it inherited
one from its source, because a clone has no pristine moment to capture.
'bootstrapped' is the other label box takes for you (#130), and it is the
one you probably want more often: it is the box AFTER the tenant role
converged and before you touched it.
box restore work bootstrapped # keep the role, undo what you did to it
box restore work pristine # throw the role away too
The same two caveats apply to it exactly as written above — it dies with the
box on 'box rm' ('box export' is what survives), and it cannot undo anything
that left the box, a tailnet join or a runner registration among them
(heavy-duty/rig#62).
Its absence proves NOTHING. box takes it only after a rig hook it ran and
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
box whose hook failed and was re-run by hand through 'box shell' has none
either — box will not label a convergence it did not watch. 'box info <box>'
tells you which labels a box actually has; take the mark yourself any time
with 'box snapshot <box> bootstrapped'.
Destructive, so it asks first — naming the snapshot it is rolling back to,
because the whole risk is picking the wrong label. --force (-f) skips the
prompt; with no TTY to ask on it refuses rather than assuming yes.
box does not require the box to be stopped for this. Snapshots here are
stateless — no live memory is captured — so a rollback is crash-consistent:
the box comes back the way a machine comes back from losing power. 'box down
<box>' first if that matters.
EOF EOF
;; ;;
export) cat <<'EOF' export) cat <<'EOF'
@ -402,6 +523,17 @@ snapshots). What box then re-stamps is THIS host's truth:
differs, the same move migrate-host makes re-homing a legacy box differs, the same move migrate-host makes re-homing a legacy box
· a fresh machine identity (reset_identity, exactly like a clone), so its · a fresh machine identity (reset_identity, exactly like a clone), so its
DHCP lease can never collide with the box it was exported from DHCP lease can never collide with the box it was exported from
· the import EVENT — when the box landed here, and by which box version
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
template and origin (mint or clone) are the originating host's truth and
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
line under them saying the mint predates the arrival. In particular the import
does not overwrite 'origin': a clone that was exported and imported still
reads as a clone, and still names the box it was cloned from.
A box that makes the trip more than once keeps both ends: the first import is
pinned forever, the latest is refreshed each time, and a count says how many.
Auth state rides along by design — the artifact carries the box's whole disk, Auth state rides along by design — the artifact carries the box's whole disk,
logins included. That is the point (log in once, keep the file), and the same logins included. That is the point (log in once, keep the file), and the same
@ -448,7 +580,7 @@ The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port. that is safe: nothing but this proxy can reach the port.
box new --name web --template claude box new --name web --template claude-box
box shell web # inside: run a dev server on 0.0.0.0:3000 box shell web # inside: run a dev server on 0.0.0.0:3000
box expose web 3000 # then open http://127.0.0.1:3000 in your browser box expose web 3000 # then open http://127.0.0.1:3000 in your browser
EOF EOF
@ -542,6 +674,17 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
rides snapshots, 'box export' rides backups — #70) rides snapshots, 'box export' rides backups — #70)
· installs the box-net profile into their project · installs the box-net profile into their project
An incus-admin member is provisioned too, not refused (#99): they are added
to 'incus' like anyone else — not a new privilege, since incus-admin already
opens the daemon, but the key to a FILE, because incus-user's socket is group
'incus' mode 0660 and nothing below can provision them without it. Everything
else converges, so they finally have a project of their own. What it is not
is a confinement:
incus-admin wins at the socket, so the restrictions are a default placement
they can step outside at will, and their own client keeps resolving to the
admin socket (and the default project) until incus-admin is taken away. The
grant says all of that out loud when it lands.
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/ The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
import/rm on their own boxes. Not theirs: expose (edits daemon-global import/rm on their own boxes. Not theirs: expose (edits daemon-global
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
@ -561,6 +704,15 @@ recreate the project, unhardened, afterwards — measured, not theoretical),
then their boxes, images, project, private bridge and trust-store then their boxes, images, project, private bridge and trust-store
certificate are removed — irreversible, so it asks first. certificate are removed — irreversible, so it asks first.
On an incus-admin member a bare revoke takes back the 'incus' membership that
grant added — reported as 'partial:', because it ends no access: incus-admin
still opens every project on this host. (One who was never granted is a named
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
project becomes unreachable — grant's "no re-grant needed" holds only while
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.
box revoke dev1 # take the tier; their boxes keep running box revoke dev1 # take the tier; their boxes keep running
box revoke dev1 --purge # ...or end their sessions and delete everything box revoke dev1 --purge # ...or end their sessions and delete everything
EOF EOF
@ -764,12 +916,23 @@ need_arg2() {
fi fi
} }
# Row templates: {} -> $2, {1} -> the second positional. The caller picks what
# {} means because the two uses differ on purpose — the ok message reports on
# the box the operator NAMED, the confirm prompt names the RESOLVED instance,
# because a prompt about to destroy something should say which machine.
fill() { # $1 = template, $2 = what {} stands for
local t="${1//\{\}/$2}"
printf '%s\n' "${t//\{1\}/${args[1]:-}}"
}
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse. confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
if [ "$force" -eq 1 ]; then return 0; fi if [ "$force" -eq 1 ]; then return 0; fi
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)" [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
local reply local reply
printf 'box: %s? this cannot be undone. [y/N] ' "$1" printf 'box: %s? this cannot be undone. [y/N] ' "$1"
read -r reply # EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
# non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
read -r reply || die "aborted."
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
} }
@ -946,8 +1109,16 @@ load_template() {
# environment must die on the host, never execute in the guest. bash's =~ # environment must die on the host, never execute in the guest. bash's =~
# anchors to the whole string — a multi-line value cannot sneak one clean # anchors to the whole string — a multi-line value cannot sneak one clean
# line past it the way a line-oriented grep would. # line past it the way a line-oriented grep would.
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
# substitutes it into the seed, and the mint stamp (#103) records it onto the
# instance. Two spellings of the same default would eventually disagree, and a
# stamp that disagrees with the seed is worse than no stamp at all.
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
render_userdata() { render_userdata() {
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data local f="$1" repo data ref
repo="$(rig_repo)"; ref="$(rig_ref)"
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \ [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|| die "RIG_REPO must look like owner/repo: $repo" || die "RIG_REPO must look like owner/repo: $repo"
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \ [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
@ -970,6 +1141,144 @@ cmd_templates() {
echo "mint one: box new --name <box> --template <template>" echo "mint one: box new --name <box> --template <template>"
} }
# When this instance came into being. A timestamp in a CONVERGENT file would be
# churn — the same run writing a different byte every time — but a mint is not
# convergent: it happens exactly once, to exactly one instance, and is never
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
# thing on every host that reads it back.
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# Which storage driver is under this box? The placement contract answers it:
# every box launches with --profile box-net, and that profile hardcodes the
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
# read back from the profile that actually placed the instance, and only falls
# back to the name setup-host.sh creates. Two probes for the driver because
# the tiers see storage differently: 'incus storage show' is the admin read
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
# is the one a restricted user's incus-user proxy is likelier to answer.
# Prints the driver, or nothing when neither probe answers.
storage_driver() {
local pool driver
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
[ -n "$pool" ] || pool=default
# '|| true' on all three probes, not just the first. A refusal here is an
# ANSWER ("this tier cannot read the pool"), never an error — this function
# must never be the thing that kills a mint. Today the two pipelines are
# safe only by accident: command substitution strips errexit, so a failing
# 'incus storage show' under pipefail falls through to the fallback instead
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
# robustness tweak #107 describes sailing through review — and that accident
# reverses into a fatal abort mid-mint, inside the function whose contract
# is NEVER fatal. Make the three probes read alike and depend on nothing.
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
printf '%s' "$driver"
}
# The shared policy behind every mark box takes for you (#104, #130):
# read the opt-out, read the storage driver, refuse to double the disk cost on
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
#
# It lives once on purpose. What generalises here is the MECHANISM, and the
# never-fatal contract inside it is exactly the property that must not be got
# subtly different in two places — one function, one place to review it, one
# place a future third mark inherits it from. What does NOT generalise is the
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
# different sentences, so each wrapper below owns its own narration and passes
# it in. Two thin wrappers over one policy is the honest split; a single
# stringly-parameterised function taking five sentences would not be.
#
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
# operator actually has to set.
#
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
snapshot_mark() {
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
if [ "$enabled" = 0 ]; then
echo "box: skipping the '$label' snapshot ($knob=0)."
return 0
fi
driver="$(storage_driver)"
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
# kilobytes until the guest diverges. On the documented 'dir' fallback
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
# naming the by-hand command, because a silent skip teaches an operator to
# expect a mark that will not be there. With two marks the objection is
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
# for one full root copy per mint, let alone two.
if [ "$driver" = dir ]; then
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
return 0
fi
# Neither probe answered — an unusual host, or a tier that cannot read the
# pool. Proceed, and say what was assumed. The two mistakes are not
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
# see in 'box info' and delete, while a snapshot NOT taken is the moment
# gone for good, which is the whole thing this exists to prevent.
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
"assuming copy-on-write ($knob=0 to skip)." >&2
echo "box: marking '$label' — $what"
if ! incus snapshot create "$instance" "$label" </dev/null; then
# An undo is not the mint's product: a mint that worked must not be
# failed by a checkpoint that didn't.
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
return 0
fi
marks="$marks $label"
echo "box: roll back to it any time with: box restore $name $label"
}
# Did THIS run actually create <label>? The only honest source for a message
# that offers a rollback: see 'marks' above for why the return status cannot
# answer it.
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
# of this mark only exists if it is already there on the bad day, and nobody
# takes it by hand at the one moment it is true. The escape hatch is the
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
snapshot_pristine() {
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
"the guest after cloud-init, before rig converges anything."
}
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
# contract as 'pristine' — it is the same policy, one function up.
#
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
# the asymmetry is the point rather than an oversight. 'pristine' marks a
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
# happened since" — and a blank box has no such event. Minting the label there
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
# them, so it is the same disk state at twice the cost, which is precisely the
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
# convergence that never happened. So a hookless box gets nothing, and the
# label's presence is itself information: this box had a role and box saw it
# converge.
#
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
# the box has not been touched since; ABSENCE means nothing at all — a blank
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
# (see cmd_new's hook-failure path, which hands the operator the command
# because box cannot honestly take a mark for a run it did not watch). Nothing
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
snapshot_bootstrapped() {
snapshot_mark "$1" "$2" bootstrapped \
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
"the tenant role converged, and nothing has touched the box since."
}
cmd_new() { cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)" [ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
require_stack require_stack
@ -981,10 +1290,78 @@ cmd_new() {
case "$from" in */*) snap="${from#*/}" ;; esac case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap" srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
incus copy "$srcref" "$instance" incus copy "$srcref" "$instance"
# 'incus copy' carries every user.* key forward (audit B2) — which is what
# makes a clone know its template and user for free, and is also why the
# mint stamp (#103) cannot simply ride along. A clone that inherited the
# stamp verbatim would claim to have been minted at the source's mint time,
# by the box version that minted the SOURCE, in a mint that never touched
# this instance. That is not a stale field, it is a false one.
#
# So re-stamp exactly the keys that describe THIS instance's coming into
# being, and leave the rest alone:
# · version / created / schema — the clone was made HERE, NOW, by THIS box
# · origin=clone, origin.from=<srcref> — how, and from what
# Deliberately NOT re-stamped, because they are lineage and stay true: the
# clone's disk really did come from that image, that template, that user and
# that rig role — reading them off the source is the whole point of a clone.
# ('incus copy' preserves the instance type too, so mode stays true as well.)
#
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
# only the mint knew whether a container was asked for or fallen back into
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
# refuses --vm/--container outright (nobody was asked anything here), so an
# inherited 'asked' makes 'box info' print a demand that was never made of
# this instance. There is no true value to re-stamp it with: the honest
# answer is absence, and absence is already how the whole block renders
# what it does not know — the MODE line simply does not print, while TYPE
# above still says VM or CT off the preserved instance type.
#
# origin.from records ONE hop. A clone of a clone names its parent and
# forgets its grandparent: the alternative is an unbounded chain in a config
# value, and the parent is the box an operator can actually go look at.
incus config set "$instance" \
user.box.schema="$BOX_STAMP_SCHEMA" \
user.box.version="$(box_version)" \
user.box.created="$(mint_time)" \
user.box.origin=clone \
user.box.origin.from="$srcref"
# Cleared, not set-to-empty: an empty value is still a key, and a reader
# that greps the config would find it. Tolerated failure because the source
# may predate the stamp and never have carried the key at all — a clone
# must not die over a key that was already absent.
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
incus start "$instance" incus start "$instance"
wait_agent "$instance" wait_agent "$instance"
reset_identity "$instance" reset_identity "$instance"
echo "box: cloned $srcref — isolation and auth state carry over from the source." echo "box: cloned $srcref — isolation and auth state carry over from the source."
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
# the rig hook entirely, so the pristine MOMENT never happens here —
# anything box marked at clone time would be "whatever the source was",
# converged and worked-in, wearing a label that promises pristine Debian.
# That is worse than no mark at all, so the mint-path snapshot lives
# strictly in the fresh-mint branch below and never runs here.
#
# What a clone gets instead is INHERITANCE, and it is already correct:
# 'incus copy <box>' carries the source's snapshots, so a source that has
# a 'pristine' hands the clone a real one — the true pristine state of
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
# carries no snapshot list, so that clone starts with none. Both are
# honest; neither is invented. Say which one this is rather than leaving
# the operator to run 'box info' to find out.
# Capture first, THEN read (#124's class). Piping a multi-line incus
# writer straight into an early-exit reader lets grep close the pipe on
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
# would read as "no pristine" and narrate the WRONG inheritance shape on a
# clone that actually has one. Un-racy today at this writer's size, but
# the assignment costs nothing and does not depend on that staying true.
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
else
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
fi
else else
local t="${template:-blank}" m extra=() local t="${template:-blank}" m extra=()
load_template "$t" load_template "$t"
@ -1021,6 +1398,47 @@ cmd_new() {
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone # which user. 'incus copy' preserves user.* keys (audit B2), so a clone
# knows what it is without ever consulting the template again. # knows what it is without ever consulting the template again.
# #
# And the rest of what this line knows and used to drop on the floor (#103).
# There is no host-side per-box store — the Incus instance config IS the
# database — so a fact not written here is simply gone the moment the mint
# returns. The stamp describes the MINT, not the outcome: it lands before
# cloud-init and before rig, and nothing later edits it.
#
# schema the stamp's shape, so a future reader knows what it is holding
# version the box that minted it — 'box --version' is a fact about the
# binary in front of you, never about the box you are looking at
# image the alias asked for. It is an UNPINNED alias on a moving
# remote: two boxes minted a month apart from "the same
# template" are not the same box, and the alias alone cannot
# say so. What it resolved to is pinned after the launch below.
# mode what it minted as, and what was ASKED — a container that fell
# back for want of /dev/kvm and one the operator asked for read
# identically afterwards, and only the mint knew which
# role the rig role box auto-runs at the hook below
# rig.* WHICH rig converged it, stamped only for a seed that actually
# installs rig from the pin ('blank' seeds none, so it gets none)
# created when. See mint_time() for why a timestamp belongs here.
# origin mint. A clone re-stamps it (see the --from branch above).
#
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
# duplicate drifts the first time someone edits the limit by hand); disk
# (a VM's is the root device size, and a container's does not exist — its
# root rides the pool, so a stamped value would be fiction); and tier,
# which box_tier() derives from whoever is ASKING, not from the box.
local stamp=(
--config user.box.schema="$BOX_STAMP_SCHEMA"
--config user.box.version="$(box_version)"
--config user.box.image="$T_IMAGE"
--config user.box.mode="$m"
--config user.box.mode.asked="$mode"
--config user.box.created="$(mint_time)"
--config user.box.origin=mint
)
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
fi
#
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19 # The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
# release drill the child 'incus launch' wedged before the create was # release drill the child 'incus launch' wedged before the create was
# even accepted — 'incus operation list' empty, the instance never # even accepted — 'incus operation list' empty, the instance never
@ -1039,6 +1457,7 @@ cmd_new() {
--config user.box=1 \ --config user.box=1 \
--config user.box.template="$t" \ --config user.box.template="$t" \
--config user.box.user="$T_USER" \ --config user.box.user="$T_USER" \
"${stamp[@]}" \
--config limits.cpu="$T_CPU" \ --config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \ --config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \ --config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
@ -1076,6 +1495,20 @@ cmd_new() {
# Not a wedge: incus refused and said why on stderr, right above. # Not a wedge: incus refused and said why on stderr, right above.
die "incus launch failed (exit $rc)" die "incus launch failed (exit $rc)"
fi fi
# The one field the launch line could not know: 'user.box.image' above is
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
# Incus resolves it during the launch and records what it landed on in
# volatile.base_image — read it back and pin it into the stamp, so an
# incident six months from now can ask "was this box built on the image
# that broke?" and get an answer instead of a template name.
#
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
# it runs only after a launch that already succeeded, and a box that exists
# and boots must never be failed over a provenance field. Every failure
# here is silent and leaves the alias standing as the honest partial answer
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
wait_agent "$instance" wait_agent "$instance"
echo "box: waiting for phase-1 (cloud-init)..." echo "box: waiting for phase-1 (cloud-init)..."
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)" echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
@ -1108,6 +1541,19 @@ cmd_new() {
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2 echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
die "cloud-init failed — the box is incomplete, so refusing to hand it over" die "cloud-init failed — the box is incomplete, so refusing to hand it over"
fi fi
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
# finished and rig has not started — the guest is pristine Debian plus
# box's seed, and nothing else. That state exists for a few seconds on
# every mint and has never been captured; rig#62's "back to pristine
# Debian" is exactly this state, so take it while it is true.
#
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
# blank box has no rig hook but it does have the same pristine moment,
# and 'box restore <box> pristine' should mean one thing on every box.
#
# NEVER fatal. This is an undo, not the mint's product — a mint that
# worked must not be failed by a checkpoint that didn't.
snapshot_pristine "$instance" "$name"
# The tenant convergence (#81): the seed above is thin — the user, tmux, # The tenant convergence (#81): the seed above is thin — the user, tmux,
# rig — and what the box BECOMES is rig's job. A template that names a # rig — and what the box BECOMES is rig's job. A template that names a
# bootstrap role gets it auto-run here, as root inside the guest, because # bootstrap role gets it auto-run here, as root inside the guest, because
@ -1123,20 +1569,57 @@ cmd_new() {
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2 echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2 echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2 echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
# The mark box cannot take for you (#130), said HERE because here is
# the only moment the operator is looking. 'bootstrapped' means "box
# ran the hook and watched it succeed" — a by-hand re-run happens in a
# shell box does not watch, so box cannot know when (or whether) it
# converged, and a mark taken on a guess would be a label asserting a
# convergence box never saw. That is the same lie #104 refused on the
# clone path. So: hand over the command instead of inventing the fact.
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
echo " the role converges, at the moment it is true:" >&2
echo " box snapshot $name bootstrapped" >&2
# Only offer the rollback that EXISTS. Three mints reach this line
# with no pristine mark, all by this stack's own design: a 'dir' pool
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
# mint continued). On a 'dir' host that is EVERY hook failure, and an
# operator standing at the one moment this message exists for would
# copy-paste a restore that errors. Offering a mark the disk does not
# have is the same lie this PR refuses one message earlier, when it
# declines to invent a 'bootstrapped' it never watched.
if mark_taken pristine; then
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
fi
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready" die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
fi fi
# The hook RAN and box WATCHED it succeed — so right here the box is
# converged and untouched, the state an operator re-mints ten minutes
# for when they break a tenant role but not the OS under it. Never
# fatal, same as 'pristine': a mint that worked is not failed by a
# checkpoint that didn't.
snapshot_bootstrapped "$instance" "$name"
fi fi
fi fi
# The login hint belongs to the claude template — read the EFFECTIVE # The login hint belongs to the claude-box template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a # template off the instance, so a clone of a claude-box box gets it too and
# blank box is not told to run a binary it doesn't have. # a blank box is not told to run a binary it doesn't have.
#
# Both spellings match, and that is not an alias for the ROLE. The role
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
# seeds ask for 'claude-box' and nothing here softens that. What these arms
# read is user.box.template, a stamp left on an INSTANCE at its own mint
# time: every box minted before the rename carries the bare name forever,
# and a clone carries it forward. Refusing the old spelling here would not
# cut anything over, it would only drop the hint on boxes that predate the
# rename — the same reason user.claudebox is honored everywhere else.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)" local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude [ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
if [ "$eff" = claude ]; then if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login." echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
elif [ "$eff" = staging ]; then elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):" echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name" echo " box shell $name # then: sudo rig bootstrap workload-server --hostname $name"
else else
echo "box: ready — 'box shell $name'." echo "box: ready — 'box shell $name'."
fi fi
@ -1219,6 +1702,85 @@ cmd_list() {
fi fi
} }
# One instance config key. 'incus config get' on an UNSET key prints empty and
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
# key being absent, and every caller below reads absence as an empty string.
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
# is the whole block: a box minted before the stamp existed carries none of it
# and must render as a box with blanks — never as an error, and never as a box
# wearing a mint time it does not have. Legacy boxes are not a transitional
# case: a box outlives the release that minted it, which is exactly what the
# legacy 'user.claudebox' tag already says out loud at resolve_box.
box_provenance() {
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
local imp impby impl implby impn
schema="$(box_cfg "$i" user.box.schema)"
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
role="$(box_cfg "$i" user.box.role)"
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
# above still says mint or clone, which is what it has always meant.
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
impl="$(box_cfg "$i" user.box.imported.last)"
implby="$(box_cfg "$i" user.box.imported.last.by)"
impn="$(box_cfg "$i" user.box.imported.count)"
# A pre-rename box has no metadata at all but is always a Claude box — the
# same mapping box_user() makes, for the same reason.
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
echo
if [ -n "$created" ] || [ -n "$ver" ]; then
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
else
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
fi
# Directly under MINTED, because adjacency is what defeats the misreading:
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
# and a reader who meets it alone will take it for this host's.
#
# The qualifier states the one thing box actually knows — the ORDERING. It
# does not say "on another host": a box can be exported and re-imported onto
# the SAME host (that is the installer's own upgrade advice, #66), and
# nothing on the box records which host minted it. Inventing a location here
# would be precisely the fabricated fact the stamp exists to avoid.
if [ -n "$impl" ] || [ -n "$imp" ]; then
printf '%-11s%s\n' IMPORTED \
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
# The birth of the import history, shown only when it is a DIFFERENT trip
# from the latest — on a single import both pairs hold the same values and
# a second line would be noise. Continuation indent, no second label.
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
fi
fi
if [ -n "$tpl" ]; then
local paren=""
[ -z "$u" ] || paren="user $u"
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
fi
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
# The mode is only worth a line alongside what was ASKED: TYPE above already
# says VM or CT, but only the mint knew whether a container was chosen or
# fallen back into for want of /dev/kvm.
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
# A schema box does not recognise is NEWER than box, not broken: show what is
# understood and say so, rather than refusing to describe a box that a later
# release minted perfectly well. (A non-integer lands here too, which is the
# right side to fail on.)
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
fi
}
cmd_info() { cmd_info() {
local box="${args[0]}" row local box="${args[0]}" row
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
@ -1240,6 +1802,12 @@ cmd_info() {
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}" printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
done < <(incus config device list "$inst" 2>/dev/null) done < <(incus config device list "$inst" 2>/dev/null)
# What built this box, from what, when, with which box (#103). Nothing else
# on the host records it — the instance config IS the store — so a stamp
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
# for free: 'incus list --format json' includes config verbatim.
box_provenance "$inst"
echo echo
case "${snaps:-0}" in case "${snaps:-0}" in
''|0) ''|0)
@ -1277,13 +1845,18 @@ box_user() {
} }
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; } cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; } # sudo -i joins its command argv into one shell string. In that join, a
# backslash-newline becomes a shell continuation and silently deletes the
# newline from a multi-line `box exec` payload (#169). Keep the login
# environment explicitly, but let the inner shell exec the original argv.
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${args[@]:1}"; }
# A shell is a child of the exec connection: drop the terminal and everything # A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A' # in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
# attaches when the session exists and creates it when it doesn't, so starting # attaches when the session exists and creates it when it doesn't, so starting
# work and reattaching after a disconnect are the same command. 'shell' stays # work and reattaching after a disconnect are the same command. 'shell' stays
# bare on purpose — two verbs, two contracts. # bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
# caller-supplied command payload; its session name is validated below.
cmd_tmux() { cmd_tmux() {
local session="${args[1]:-main}" local session="${args[1]:-main}"
case "$session" in case "$session" in
@ -1389,6 +1962,69 @@ cmd_import() {
tag="$(incus config get "$target" user.box 2>/dev/null || true)" tag="$(incus config get "$target" user.box 2>/dev/null || true)"
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)" [ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
[ "$tag" = 1 ] || incus config set "$target" user.box=1 [ "$tag" = 1 ] || incus config set "$target" user.box=1
# The import EVENT (#131). Everything above either restores the ARTIFACT's
# truth or re-establishes THIS host's boundary; none of it records that the
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
# which is correct, mint facts belong to the originating host — and that is
# exactly what creates the gap: an imported box is otherwise indistinguishable
# from one minted here at the artifact's mint time.
#
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
# into BEING — mint or clone — and overwriting it destroys that answer: a
# clone that was exported and imported would come back claiming to be an
# import, with nothing left saying it was ever a clone and an 'origin.from'
# naming a lineage no key explains any more. The import is a THIRD fact,
# orthogonal to the first two, so it takes its OWN keys, and every key the
# artifact carried — origin, origin.from, created, version, template, user,
# image, role, rig.* — is left exactly as it arrived.
#
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
# same repeated-event question. It transfers: a box can make the trip more
# than once, and these keys ride inside the artifact on every subsequent one.
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
# · imported.last / .last.by LAST-wins. When it landed HERE.
# · imported.count how many trips — first and last alone
# cannot tell 2 apart from 5, and nothing
# else on the box can reconstruct it.
# Last-wins alone would repeat, one level down, the very mistake this key
# exists to avoid: the second import would erase the evidence of the first.
# First-wins alone cannot answer "when did this land on THIS host", which is
# the question an operator actually asks.
local first_at prev_n n now
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
# A count that is absent (the first trip) or not an integer (a hand-edited
# config, a foreign user.box.imported.count) must never fail an import that
# has already happened — arithmetic on it under 'set -e' would. Same side the
# schema check in box_provenance() falls on: degrade, never die.
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
# base", because arithmetic reads a leading zero as octal. That abort lands
# after the physical 'incus import' and before the stamp, the placement fix
# and the start: precisely the window this guard exists to protect. So the
# digits-only case closes sign and garbage, and 10# forces base ten.
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
n=$((10#$prev_n + 1)); now="$(mint_time)"
local istamp=(
user.box.imported.last="$now"
user.box.imported.last.by="$(box_version)"
user.box.imported.count="$n"
)
# The birth of this box's import history: pinned on the first trip and
# carried by the artifact forever after, so a box on its third host still
# names the trip that started it.
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
# No 'user.box.schema' is written here, on either path. "Absent schema means
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
# a legacy artifact that carries no mint stamp would claim a shape it does
# not have. These keys need no schema to be read — every reader tolerates
# absence by construction. Nor does the schema BUMP: #103's contract is that
# it moves on removal or repurposing, never on addition, and this only adds.
#
# Before the start, like the clone re-stamp and for the same reason: an
# imported box is never observable — not for a second, not by a concurrent
# 'box info' — without the record of how it got here.
incus config set "$target" "${istamp[@]}"
# Placement: the artifact carries its profile list, but the isolation # Placement: the artifact carries its profile list, but the isolation
# contract is THIS host's box-net profile. A box export already says # contract is THIS host's box-net profile. A box export already says
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets # box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
@ -1597,7 +2233,8 @@ uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)" [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
local reply local reply
printf 'box: %s? [y/N] ' "$1" printf 'box: %s? [y/N] ' "$1"
read -r reply # Same EOF cure as confirm() above — Ctrl-D must abort out loud.
read -r reply || die "aborted."
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
} }
@ -1888,12 +2525,19 @@ cmd_expose() {
# --- dispatch: driven by the table, not by a hand-written case -------------- # --- dispatch: driven by the table, not by a hand-written case --------------
row="$(cmd_row "$cmd")" || unknown_command "$cmd" row="$(cmd_row "$cmd")" || unknown_command "$cmd"
IFS='^' read -r _ _ pre _ action ok <<<"$row" IFS='^' read -r _ _ pre _ action ok cnf <<<"$row"
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
case ",$pre," in *,arg2,*) need_arg2 ;; esac case ",$pre," in *,arg2,*) need_arg2 ;; esac
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac # The prompt comes from the ROW, never from here. A shared string can only be
# right for one verb, and it was rm's — which is why 'restore' could not be
# gated by adding the token alone (#105). A row with 'confirm' and no words is
# a table bug, and it dies as one rather than asking a blank question.
case ",$pre," in *,confirm,*)
[ -n "$cnf" ] || die "internal: '$cmd' is marked confirm but its row carries no prompt"
confirm "$(fill "$cnf" "$inst")" ;;
esac
case "$action" in case "$action" in
fn:*) fn:*)
@ -1905,8 +2549,7 @@ case "$action" in
# shellcheck disable=SC2086 # shellcheck disable=SC2086
incus $sub "$inst" "${args[@]:1}" incus $sub "$inst" "${args[@]:1}"
if [ -n "$ok" ]; then if [ -n "$ok" ]; then
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}" echo "box: $(fill "$ok" "${args[0]}")"
echo "box: $msg"
fi fi
;; ;;
esac esac

View file

@ -1,8 +1,8 @@
# box design # box design
`box` is a CLI that mints and manages **trust-less, network-isolated VMs `box` is a CLI that mints and manages **trust-less, network-isolated VMs
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
none). It is infrastructure, not a project provisioner. `kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
See issue #3 for the full reframe and rationale. This doc captures the durable See issue #3 for the full reframe and rationale. This doc captures the durable
design decisions. design decisions.
@ -43,6 +43,33 @@ snapshots, not a secrets store:
Log in once → snapshot → spin up authed boxes from it. Log in once → snapshot → spin up authed boxes from it.
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
after cloud-init and before the `rig bootstrap` hook (#104, child of
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
installed but not yet run — is the state "back to pristine Debian" names, it
lasts a few seconds, and nobody is standing there to snapshot it. `box
restore <n> pristine` undoes a tenant role wholesale, because everything the
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
`pristine`: it never has the moment, so it inherits its source's snapshots or
has none, and box refuses to label a worked-in state as pristine. On a `dir`
pool the mark would be a full copy rather than a CoW one, so the mint skips
it loudly.
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
hook has run and box has *watched it succeed*. Same policy, literally the same
function: default on, never fatal, skipped on `dir`, opt-out via
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
therefore no event — marking it anyway would duplicate `pristine` byte for
byte at twice the disk cost, or assert a convergence that never happened. A
failed hook gets no mark either: box tells the operator to re-run the role
through `box shell`, which is a run box does not watch, so it hands over `box
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
that is only ever read one way — **presence** means "converged and untouched";
**absence** means nothing, and nothing in box may treat a missing
`bootstrapped` as evidence a box is unconverged.
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
clone still lives on the same host. The off-host mechanism is `box export` / clone still lives on the same host. The off-host mechanism is `box export` /
`box import` (#70) — one portable backup tarball, snapshots included by `box import` (#70) — one portable backup tarball, snapshots included by
@ -50,7 +77,13 @@ default, that survives `rm`, a host teardown, an upgrade, a move. The split
of truths is the design: everything `incus import` restores is the artifact's of truths is the design: everything `incus import` restores is the artifact's
(disk, config, snapshots); everything box re-stamps on import is the current (disk, config, snapshots); everything box re-stamps on import is the current
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
machine identity via the same `reset_identity` a clone gets). Auth state machine identity via the same `reset_identity` a clone gets, and the record
that the trip happened). That last one is #131, and it is deliberately *not*
`origin=import`: `origin` says how the instance came into **being** — mint or
clone — and the import is a third, orthogonal fact. Overwriting `origin` would
make an exported clone come back claiming to be an import, with its
`origin.from` lineage left unreadable, so the import gets its own keys and the
artifact's mint stamp survives the trip untouched. Auth state
rides along deliberately — and because scrubbing a disk image is a promise rides along deliberately — and because scrubbing a disk image is a promise
tarball surgery cannot keep, export shouts that the file is a credential tarball surgery cannot keep, export shouts that the file is a credential
instead of pretending to sanitize it. instead of pretending to sanitize it.
@ -60,15 +93,17 @@ instead of pretending to sanitize it.
A template is a **thin, creds-free seed** — base image, the tenant user, A template is a **thin, creds-free seed** — base image, the tenant user,
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
template's creds-free tenant role after cloud-init (`rig bootstrap claude` / template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
`codex` / `grok` / `staging`), which installs the agent CLI or server / `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
posture. The split is deliberate: cloud-init is a first-boot one-shot — `-box` for box tenants and `-server` for fleet machines, and the templates are
named for the roles they converge, rig#76), which installs the agent CLI or
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
not convergent, not re-runnable, only parse-and-grep testable — while a rig not convergent, not re-runnable, only parse-and-grep testable — while a rig
role is an idempotent script with effective-state asserts that can also role is an idempotent script with effective-state asserts that can also
converge an *existing* box to a newer spec. Anything that joins a tailnet or converge an *existing* box to a newer spec. Anything that joins a tailnet or
holds a key (staging's workload join) stays operator-run through holds a key (the staging-box tenant's workload join) stays operator-run
`box shell`; box prints it as a next step and never sees the key. The seed's through `box shell`; box prints it as a next step and never sees the key. The
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases), `heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
and box's template suite holds the line with fail-closed absence greps: no and box's template suite holds the line with fail-closed absence greps: no
agent CLI, no docker, no tailscale, no context-file heredocs in any agent CLI, no docker, no tailscale, no context-file heredocs in any

View file

@ -2,9 +2,9 @@
`box` mints trust-less, creds-free, isolated VMs with a coding agent already `box` mints trust-less, creds-free, isolated VMs with a coding agent already
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows `claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
**nothing** about your project. There is no `install` step and no host-run CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
setup script. host-run setup script.
A project makes itself easy to stand up inside a box by shipping an optional A project makes itself easy to stand up inside a box by shipping an optional
`.box/` folder. This folder is **agent-facing documentation** — read and `.box/` folder. This folder is **agent-facing documentation** — read and

View file

@ -127,11 +127,13 @@ It had never worked.
## Rehearsal and CI ## Rehearsal and CI
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves `drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
criteria (a)(f) from #74 plus the measured extensions (g)(n): the in-box criteria (a)(f) from #74 plus the measured extensions (g)(o): the in-box
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop, isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
injections (n). Real users, real grants, real mints, probes from inside; injections (n) and the `incus-admin`-only grant (o) — the last of these
because the shim suite models neither `INCUS_SOCKET` nor socket permissions,
so the group gate on `unix.socket.user` can only be measured live (#99, #101). Real users, real grants, real mints, probes from inside;
`--container` for CI, VM mode on real hardware; cleanup deletes everything it `--container` for CI, VM mode on real hardware; cleanup deletes everything it
made. made.

View file

@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
# Read from inside a box WITHOUT ever hanging the drill. # Read from inside a box WITHOUT ever hanging the drill.
# #
# Two traps, both hit for real: # Two traps, both hit for real:
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and # · 'box exec' crosses a login-user shell boundary. Fine for a person,
# all). Fine for a person, needless machinery for a probe. # needless machinery for a probe.
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild # · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
# inheriting the exec session's stdout keeps the substitution open forever, # inheriting the exec session's stdout keeps the substitution open forever,
# and 'timeout' does not save you: it kills the wrapper, not the holder of # and 'timeout' does not save you: it kills the wrapper, not the holder of
@ -378,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
fi fi
inf "host setup complete" inf "host setup complete"
# A real server has room for the claude template's resources (8GiB/4cpu), and # A real server has room for the claude-box template's resources (8GiB/4cpu), and
# drilling the real numbers is worth more than drilling shrunken ones. Only # drilling the real numbers is worth more than drilling shrunken ones. Only
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the # shrink if we must. Since 0.4.0 resources are per-box, stamped from the
# template at mint — a profile edit no longer reaches them; the supported # template at mint — a profile edit no longer reaches them; the supported
@ -386,9 +386,9 @@ inf "host setup complete"
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)" ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
if [ "$ram" -lt 20 ]; then if [ "$ram" -lt 20 ]; then
export BOX_MEMORY=3GiB BOX_CPU=2 export BOX_MEMORY=3GiB BOX_CPU=2
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)" note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude-box template's 8GiB/4cpu is what was NOT drilled)"
else else
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged" inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
fi fi
KVM=0; [ -e /dev/kvm ] && KVM=1 KVM=0; [ -e /dev/kvm ] && KVM=1
@ -492,10 +492,10 @@ fi
# --- templates: the mint surface is itself a surface to test ---------------- # --- templates: the mint surface is itself a surface to test ----------------
tpl_missing="" tpl_missing=""
for t in blank claude codex grok; do for t in blank claude-box codex-box grok-box kimi-box; do
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t" box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
done done
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \ [ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|| no "templates listing is missing:$tpl_missing" || no "templates listing is missing:$tpl_missing"
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused" && ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
@ -551,19 +551,22 @@ else
fi fi
# The generic mechanic (metadata, placement, user, isolation parity) is proven # The generic mechanic (metadata, placement, user, isolation parity) is proven
# once by blank+claude and needs no per-template repeat. What a NEW template # once by blank+claude-box and needs no per-template repeat. What a NEW template
# still has to prove is its own payload: the CLI installs, lands on the # still has to prove is its own payload: the CLI installs, lands on the
# non-interactive exec PATH, and answers --version. One mint each. # non-interactive exec PATH, and answers --version. One mint each.
# The box NAME stays the bare agent name — it is what the pre-flight banner
# announces and what teardown deletes — while the TEMPLATE carries rig#76's
# family suffix. They are two different namespaces and only one of them moved.
for t in codex grok; do for t in codex grok; do
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
printf '\n minting a %s box (cold — validates the template install)…\n' "$t" printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \ [ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user" && ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH" ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
else else
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)" no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)" inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
# Do not throw the evidence away — say WHAT the installer actually left. # Do not throw the evidence away — say WHAT the installer actually left.
# Do NOT throw the evidence away — say what the installer actually left # Do NOT throw the evidence away — say what the installer actually left
@ -583,10 +586,10 @@ for t in codex grok; do
fi fi
done done
printf '\n minting a claude box (cold, ~10 min)…\n' printf '\n minting a claude-box box (cold, ~10 min)…\n'
t0=$SECONDS t0=$SECONDS
if mint_box /tmp/mint-drill.log --name drill --template claude; then if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
ok "box new --name drill --template claude ($((SECONDS - t0))s)" ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
else else
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')" no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1 timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
@ -799,7 +802,7 @@ fi
# =========================================================================== # ===========================================================================
phase "E. box expose — a deliberate loopback door (#55)" phase "E. box expose — a deliberate loopback door (#55)"
# =========================================================================== # ===========================================================================
# archive is a running claude box (node is installed). Start a DETACHED # archive is a running claude-box box (node is installed). Start a DETACHED
# listener on 0.0.0.0 inside it, expose the port, and prove the door works # listener on 0.0.0.0 inside it, expose the port, and prove the door works
# from the HOST's loopback. Then prove removing it closes the door, and that a # from the HOST's loopback. Then prove removing it closes the door, and that a
# NON-exposed port still obeys the ingress drop — the feature must not # NON-exposed port still obeys the ingress drop — the feature must not

View file

@ -30,6 +30,9 @@
# host-owned control — the scoped guarantee, measured (#75 review) # host-owned control — the scoped guarantee, measured (#75 review)
# n. a grant that fails is fail-closed: fresh user backed out (verified), # n. a grant that fails is fail-closed: fresh user backed out (verified),
# pre-existing member warned loudly, re-run converges (#75 review) # pre-existing member warned loudly, re-run converges (#75 review)
# o. an incus-admin-ONLY member is provisioned for real: the group step
# opens incus-user's socket, the lazy project appears, and dropping
# incus-admin lands them in it with no re-grant (#99, #101 review)
# #
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the # ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
# same one drill.sh is built on (and the reason for the SC2015 disable). # same one drill.sh is built on (and the reason for the SC2015 disable).
@ -114,7 +117,7 @@ cleanup() {
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; } [ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
echo echo
echo "── cleanup" echo "── cleanup"
for u in "$U1" "$U2" boxdrill3 boxdrill4; do for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do
id "$u" >/dev/null 2>&1 || continue id "$u" >/dev/null 2>&1 || continue
# A half-failed purge followed by userdel leaves a project owned by # A half-failed purge followed by userdel leaves a project owned by
# nobody — and doctor's leftover check keys on the USER existing. Keep # nobody — and doctor's leftover check keys on the USER existing. Keep
@ -199,11 +202,42 @@ fi
rm -f "$mintlog" rm -f "$mintlog"
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine" as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed" as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
# #169: compare the new explicit login-user boundary with sudo -i while the
# latter is still safe (one fixed `env` argv), then exercise the exact
# silent-success multiline shape that sudo -i used to corrupt.
target_user="$(as_u "$U1" incus config get mine user.box.user)"
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
exec_env="$(as_u "$U1" box exec mine -- env \
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
[ "$exec_env" = "$legacy_env" ] \
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|| {
no "(b) box exec changed the cross-user login environment (#169)"
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
}
as_u "$U1" box exec mine -- bash -lc '
set -o errexit -o nounset -o pipefail
touch /tmp/box-169-step-one
touch /tmp/box-169-step-two
' >/dev/null 2>&1 \
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|| no "(b) box exec corrupted a multiline command (#169)"
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \ as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \ && ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|| no "(g) mine has no boxnet address in box info" || no "(g) mine has no boxnet address in box info"
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused" as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
as_u "$U1" box restore mine s1 >/dev/null 2>&1 && ok "(b) box restore mine s1 (the incus 6 'snapshot restore' spelling)" || no "(b) restore failed" # --force, and it is the assertion as much as the fix: restore is destructive
# and now asks (#105), so an unattended drill MUST consent explicitly. Without
# the flag this line fails — correctly — because confirm() refuses rather than
# assuming yes when there is no terminal to ask on. Adding --force here is not
# working around the gate; it is the drill proving the gate is real, and that
# the documented non-interactive path through it works on real Incus.
as_u "$U1" box restore mine s1 --force >/dev/null 2>&1 && ok "(b) box restore mine s1 --force (the incus 6 'snapshot restore' spelling, through the #105 gate)" || no "(b) restore failed"
as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed" as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed"
# c1 stays alive through phase g: it is the distinctly-NAMED sibling the # c1 stays alive through phase g: it is the distinctly-NAMED sibling the
# enumeration probe needs (both users' primaries are 'mine' by design of d). # enumeration probe needs (both users' primaries are 'mine' by design of d).
@ -469,6 +503,65 @@ else
fi fi
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge" aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
phase "o. an incus-admin-ONLY member — #99's canonical user, on real Incus"
# The case the shim suite structurally cannot reach: the fake 'incus' in
# test/cli.sh ignores INCUS_SOCKET and file permissions, so a grant that could
# never connect() still logged a clean run there. This is the same path over
# the real daemon, where the socket is a real file with a real owning group.
#
# The blocker it exists to catch (#101 review): incus-user's socket is
# /var/lib/incus/unix.socket.user, group 'incus', mode 0660. incus-admin opens
# the ADMIN socket and not that one, so an incus-admin-only member without an
# 'incus' membership takes EACCES on grant's pinned touch — swallowed by its
# '|| true' — no project is created, and the grant dies blaming a perfectly
# healthy incus-user. Every assertion below is dead under that implementation.
U5=boxdrill5
useradd -m -s /bin/bash "$U5" 2>/dev/null
usermod -aG incus-admin "$U5"
gpasswd -d "$U5" incus >/dev/null 2>&1 || true # stage the ONLY, exactly
uid5="$(id -u "$U5")"; p5="user-$uid5"
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
&& no "(o) $U5 is already in 'incus' — the admin-ONLY precondition is not staged, so this phase proves nothing" \
|| ok "(o) $U5 staged in 'incus-admin' only (the precondition the blocker needed)"
out="$(box grant "$U5" 2>&1)"; rc=$?
if [ "$rc" -eq 0 ]; then
ok "(o) box grant converges an incus-admin-only member (rc=0)"
else
no "(o) box grant FAILED for the admin-only member (rc=$rc) — #99 is still closed:"
printf '%s\n' "$out" | tail -4 | sed 's/^/ /'
fi
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
&& ok "(o) the grant put them in 'incus' — the group that owns unix.socket.user" \
|| no "(o) still not in 'incus': the pinned touch cannot connect() to incus-user's socket"
incus project show "$p5" >/dev/null 2>&1 \
&& ok "(o) $p5 exists — the lazy touch really reached incus-user AS them" \
|| no "(o) $p5 was never created — the touch never reached incus-user (the EACCES this phase is for)"
# The socket, directly: the connect() that used to fail, measured as them.
# Resolved by incus's own directory rule, not hardcoded.
sockdir=/var/lib/incus; [ -e /run/incus/unix.socket ] && sockdir=/run/incus
as_u "$U5" env INCUS_SOCKET="$sockdir/unix.socket.user" incus --project "$p5" profile show box-net >/dev/null 2>&1 \
&& ok "(o) they can open unix.socket.user and read $p5's box-net profile" \
|| no "(o) EACCES/unreachable on $sockdir/unix.socket.user — the #101 blocker is back"
acc5="$(incus project get "$p5" restricted.networks.access 2>/dev/null)"
[ "$acc5" = boxnet ] \
&& ok "(o) $p5 is narrowed to boxnet like any other granted project" \
|| no "(o) $p5 restricted.networks.access = '$acc5' — the admin-only grant converged half a project"
# Grant's own closing promise, measured: "gpasswd -d <user> incus-admin (no
# re-grant needed; the project is ready)". True only because they were left in
# 'incus' — under the old no-op this drop left them in NEITHER group, box_tier
# 'none', and a ready project they could not open. So drop it and look.
gpasswd -d "$U5" incus-admin >/dev/null 2>&1
projects5="$(as_u "$U5" incus project list --format csv 2>/dev/null | cut -d, -f1)"
if [ "$(printf '%s\n' "$projects5" | grep -c .)" = 1 ] && printf '%s' "$projects5" | grep -q "$p5"; then
ok "(o) dropping incus-admin lands them in $p5 with NO re-grant — the promise holds"
else
no "(o) after dropping incus-admin they see: '$(printf '%s' "$projects5" | tr '\n' ' ')' — grant's no-re-grant promise is false"
fi
aud "o. incus-admin-only grant: in-'incus'=$(id -nG "$U5" 2>/dev/null | tr ' ' '\n' | grep -cx incus), project '$p5' access='$acc5', post-drop projects='$(printf '%s' "$projects5" | tr '\n' ' ')'"
echo echo
echo "════════════════════════════════════════════" echo "════════════════════════════════════════════"
echo " $pass passed, $fail failed" echo " $pass passed, $fail failed"

View file

@ -117,10 +117,39 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086 # the table spec is two words by design # shellcheck disable=SC2086 # the table spec is two words by design
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t" sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
done done
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then # Every ufw read is CAPTURED before it is matched, never piped into a reader
# that exits on its first match (#102, #107).
#
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
# held — the defect was latent here, never live. It was also one line from
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
# this into #102, skipping every UFW removal on a host the operator was told
# is wiped, with no error and no red X to see. Captured and matched with
# [[ ]], it is correct under any future 'set' line.
#
# The numbered loop had the same defect for a different reason: its condition
# was also an early-exit reader, so it could end while rules remained, and it
# re-read un-captured to get the number. It now reads one capture per
# iteration and breaks on absence — the re-scan is still per-delete (numbers
# shift after each removal), just no longer racing.
ufw_status=""
if command -v ufw >/dev/null; then
# '|| true': ufw exits non-zero when it cannot read its config, and "no
# usable ufw here" is nothing-to-clean, not a failure to report.
ufw_status="$(sudo ufw status 2>/dev/null || true)"
fi
if [[ "$ufw_status" == *"Status: active"* ]]; then
for net in boxnet claudenet; do for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do while :; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')" numbered="$(sudo ufw status numbered 2>/dev/null || true)"
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
[ -n "$line" ] || break
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
[ -n "$n" ] || break
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net" sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done done
done done

57
drills/0.9.0.md Normal file
View file

@ -0,0 +1,57 @@
# box 0.9.0 — release drill
**WAIVED. No drill was run for this release.**
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
gate requires a *record*, not a passing result — so a skip is a line in a diff
somebody reviewed, rather than the silence that let 0.7.0, 0.8.0 and every
release before them ship unproven.
## Why
The drill harness is not in a state to produce a trustworthy run. box has the
only real harness in the family (`drill/drill.sh`, ~85 probes), but the
procedure around it — standing up the substrate, pinning the release
candidates, capturing the result — was undocumented and assembled by hand.
rig and cast have no harness at all. A run improvised under those conditions
produces numbers nobody should rely on, and `drills/` is worth nothing if it
fills with records like that.
Deferred deliberately, and tracked: #155.
## What this release therefore does NOT prove
Stated plainly so it is not rediscovered as a surprise:
- **The VM trust boundary.** `ci.yml` says it in its own words: *"What
container mode canNOT validate is the VM trust boundary itself; that stays a
real-hardware ritual."* That is exactly the delta a release drill adds, and
it is exactly what is missing here.
- **The isolation contract on real hardware** — egress, the sibling drop, DNS
enumeration, IPv6 off, inbound drop — as measured by `drill/drill.sh` against
real VMs rather than containers.
- **The combination users receive.** A released box still resolves `RIG_REF` to
`main` (#150), so even a passing drill would have proven a pair that drifts
the moment rig's main moves.
## What CI did prove on this tree
Not nothing, and worth recording so the gap is precise rather than total:
| Job | Result |
|---|---|
| `rehearsal` — multi-user tier on a real Incus | pass |
| `scope` | pass |
| `reconcile` | pass |
| `test/cli.sh` | 653 passed, 0 failed |
| `test/release.sh` | 173 passed, 0 failed |
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
| `shellcheck -x bin/* **/*.sh` | clean |
The tier's semantics are covered. The boundary is not.
## The commitment
The next release carries a real drill record at `drills/<version>.md` — what
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
failed drill is a valid record; another waiver is not. Tracked in #155.

82
drills/README.md Normal file
View file

@ -0,0 +1,82 @@
# drills/ — release evidence, one file per version
This directory holds the **evidence that a release was proven on real
hardware**. One file per shipped version, named exactly for the version:
```
drills/0.9.0.md
drills/0.9.0-rc1.md
```
The name must match the contents of `VERSION` exactly.
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
refuses any tree with a bare `VERSION` that has no such file, or whose file is
blank. A `-dev` tree passes with nothing to assert.
Because each version owns a file, `0.9.0` and `0.9.0-rc1` can never be
confused for one another — they are simply different paths. That used to take
careful whole-version field matching inside one shared file; now it is free.
## This is not `drill/RUNS.md`
Two different artifacts, and the distinction is load-bearing:
| | what it is |
|---|---|
| [`drill/RUNS.md`](../drill/RUNS.md) | the **harness's own history** — every run of `drill/drill.sh`, the traps table, the lore about what broke and why. It is not release-scoped and it is not going anywhere. |
| `drills/<version>.md` | **release evidence** — the record that *this version* was drilled before it shipped. Release-scoped, one file, gated by CI. |
Appending to `drill/RUNS.md` does not satisfy the release gate, and is not
meant to. Keep using it for what it has always been for.
## What a record should contain
- **What ran** — which drill, how many probes, `drill/drill.sh` invocation.
- **On what host** — the machine, the OS, the Incus version. "Real hardware"
is the claim; name the hardware.
- **The pinned candidate refs** — the exact `BOX_REF` / `RIG_REF` /
`CAST_REF` under test, and the other repos' commit SHAs. A drill that does
not say what it drilled proves nothing later.
- **The shared run ID**, so this record reconciles with the sibling repos'.
- **The numbers** — passed, failed, how long it took.
- **What failed**, plainly.
**A failed drill is still a valid record.** The gate wants *evidence*, not
success. A record saying "83/85, criterion (m) regressed, here is the issue"
is a good record. So is a maintainer's written waiver explaining why this
release shipped without a full drill. What the gate refuses is silence — #95,
#114 and #148 all shipped unproven because a skip left no trace.
## Worked example
The version below is a **placeholder that can never be a real release**.
Copy the shape, not the number.
```markdown
# Release drill — 9.9.9
- **Run ID:** `drill-9.9.9-20260721-01` (shared with rig, cast)
- **Host:** bare Debian 13, Ryzen 7 5800X / 64 GB, Incus 6.0.2
- **Date:** 2026-07-21
- **Candidate refs:**
- box `release/9.9.9` @ `abc1234`
- rig `release/4.4.4` @ `def5678` (minted with `RIG_REF=release/4.4.4`)
- cast `release/2.2.2` @ `9abcdef`
## What ran
`bash drill/drill.sh --ref release/9.9.9` — the full end-to-end: install the
stack, mint every template cold, snapshot and restore, uninstall to zero
residue. Then `drill/multiuser.sh` for the two-user grant matrix.
## Result
**84/85 passed, 1 failed.** 41 minutes wall clock.
- Failed: `multiuser.sh` criterion (m) — the raw instance kept a stale route
after teardown. Filed as #999. Judged not release-blocking: it affects
teardown residue on a host that is about to be wiped, not the trust
boundary itself.
- The VM boundary probes (the 85-probe isolation contract) passed clean,
which is the assertion this repo's drill exists to make.
```

View file

@ -19,7 +19,35 @@ NET=boxnet
# ('|| true': under pipefail an absent bridge would kill the script here.) # ('|| true': under pipefail an absent bridge would kill the script here.)
GW="$(ip -4 -o addr show dev "$NET" 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)" GW="$(ip -4 -o addr show dev "$NET" 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)"
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then # Read `ufw status` ONCE, into a variable, instead of piping it at a matcher.
# The pipe it replaces — `ufw status | grep -q "Status: active"` — was a latent
# branch-flipper, and the branch it flips is the whole firewall. "Status:
# active" is the FIRST line ufw prints, so `grep -q` matches it and exits
# immediately, closing the read end while ufw is still writing the rest of the
# table; ufw then dies of SIGPIPE (141). `grep` reported 0, but under the
# `set -o pipefail` at the top of this file the PIPELINE reports 141, so the
# `if` reads false and a host with UFW plainly active takes the no-UFW branch
# below — installing the nft fallback table and never building the DNS
# carve-out its persisted rules are counting on. It is a pure scheduling race
# between two processes, which is the worst possible property for a decision
# this load-bearing: measured at ~2% per invocation under test/cli.sh's shims
# (#102, where it surfaced as an intermittent four-assertions-red test and got
# read as flakiness for exactly as long as it was cheaper to re-run than to
# diagnose). Real ufw is a Python program with a slower, longer write than the
# shim's single printf, so there is no reason to think production is safer.
# A variable has no reader that can exit early, so the race cannot exist.
# The capture doubles as the snapshot the converge loop below reads, so the
# branch decision and the stale-rule scan are made against the same text
# rather than two reads that could disagree across an intervening change.
# ('|| true': ufw exits non-zero when it cannot read its config, and under
# pipefail+errexit that would kill the script instead of falling through to
# the nft branch, which is the correct answer for "ufw is not usable here".)
UFW_STATUS=""
if command -v ufw >/dev/null; then
UFW_STATUS="$(ufw status 2>/dev/null || true)"
fi
if [[ "$UFW_STATUS" == *"Status: active"* ]]; then
if [ -z "$GW" ]; then if [ -z "$GW" ]; then
echo "box-firewall: $NET has no address yet — UFW DNS carve-out left as-is (no rule beats a wrong one; the persisted rules survive boots, and setup-host or a service restart converges them once the bridge is addressed)" >&2 echo "box-firewall: $NET has no address yet — UFW DNS carve-out left as-is (no rule beats a wrong one; the persisted rules survive boots, and setup-host or a service restart converges them once the bridge is addressed)" >&2
else else
@ -32,7 +60,9 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
# anywhere else, then ensure the live set: ufw skips a rule that already # anywhere else, then ensure the live set: ufw skips a rule that already
# exists, so the re-run is a no-op and a fresh host gets exactly the # exists, so the re-run is a no-op and a fresh host gets exactly the
# rules it always did. # rules it always did.
for stale in $(ufw status | awk -v net="$NET" -v gw="$GW" ' # Scanned off the same $UFW_STATUS snapshot the branch was decided from —
# see the capture above for why this is not a second `ufw status` call.
for stale in $(printf '%s\n' "$UFW_STATUS" | awk -v net="$NET" -v gw="$GW" '
$2 ~ /^53\// && $3 == "on" && $4 == net && $1 != gw { print $1 }' | sort -u); do $2 ~ /^53\// && $3 == "on" && $4 == net && $1 != gw { print $1 }' | sort -u); do
ufw delete allow in on "$NET" to "$stale" port 53 proto tcp || true ufw delete allow in on "$NET" to "$stale" port 53 proto tcp || true
ufw delete allow in on "$NET" to "$stale" port 53 proto udp || true ufw delete allow in on "$NET" to "$stale" port 53 proto udp || true

View file

@ -9,7 +9,10 @@
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md. # Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
# #
# So granting is a per-user CONVERGENCE, and it must be run by an admin: # So granting is a per-user CONVERGENCE, and it must be run by an admin:
# 1. put the user in the 'incus' group (not incus-admin — that is the tier) # 1. put the user in the 'incus' group (not incus-admin — that is the tier).
# An incus-admin member goes in too, and NOT for privilege: incus-user's
# socket is a FILE, group 'incus', mode 0660, so the membership is the
# only thing that lets step 2 connect() at all (#99, #101 review)
# 2. touch incus-user AS the user, so the lazy project exists to converge # 2. touch incus-user AS the user, so the lazy project exists to converge
# 3. unpin the private bridge (drop eth0 from the project's default profile) # 3. unpin the private bridge (drop eth0 from the project's default profile)
# 4. restrict the project's network access to boxnet and ONLY boxnet — # 4. restrict the project's network access to boxnet and ONLY boxnet —
@ -63,12 +66,34 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2;
uid="$(id -u "$user")" uid="$(id -u "$user")"
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; } [ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
# An incus-admin member already holds the full socket; "granting" them the # An incus-admin member gets the full convergence anyway (#99). This used to
# restricted tier would not restrict anything (admin membership wins at the # be a hard refusal, on the reasoning that admin membership wins at the socket
# socket), it would only mislead whoever reads the group list later. # so nothing here could restrict them. True — and beside the point, because it
# conflates the two separate things a grant hands over:
# · PERMISSION — the 'incus' group. At the DAEMON API they already hold
# strictly more through incus-admin, so this group adds no privilege. It
# is still required, because the two sockets are two FILES with two
# different owning groups (Debian 13 / Incus 6.0.4, measured):
# /var/lib/incus/unix.socket group incus-admin 0660
# /var/lib/incus/unix.socket.user group incus 0660
# incus-admin opens the first and not the second, and the second is the
# only one that provisions a user-<uid> project. An earlier revision of
# this script skipped the usermod for an admin member, reasoning that
# 'incus' is a subset of incus-admin — true of the API, false of the
# filesystem: the pinned touch below took EACCES, the '|| true' swallowed
# it, no project appeared, and the grant died blaming a healthy
# incus-user. So the group step is a real convergence for everyone.
# · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
# snapshot and backup allowances, the box-net profile installed INTO that
# project. An incus-admin member has none of it: box_tier() resolves them
# to 'admin' (bin/box), so they work in the SHARED default project next to
# root and every other admin, with no world of their own. This script is
# the only thing that provisions one, and refusing left them unable to get
# it without first being taken out of incus-admin.
# So provision, and say plainly at the end what the provisioning does not do.
admin_member=0
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2 admin_member=1
exit 1
fi fi
# The stack the tier converges ONTO must exist first. Checked via the daemon, # The stack the tier converges ONTO must exist first. Checked via the daemon,
@ -93,7 +118,11 @@ fi
# admin re-runs. Backing out the group closes that window completely for a # admin re-runs. Backing out the group closes that window completely for a
# fresh grant (their existing sessions predate the membership, so no process # fresh grant (their existing sessions predate the membership, so no process
# holds it yet). A user who was already in the group keeps it: not ours to # holds it yet). A user who was already in the group keeps it: not ours to
# take on a re-run's failure. # take on a re-run's failure. An incus-admin member now takes those same two
# paths (#101): the membership IS added for them and so IS backed out, with
# one thing extra to say either way — the rollback closes incus-user's socket
# and never their daemon access, which outlives this failure by a route the
# script never granted and must not pretend to control.
added_group=0; was_member=0 added_group=0; was_member=0
backout() { backout() {
if [ "$added_group" -eq 1 ]; then if [ "$added_group" -eq 1 ]; then
@ -106,6 +135,16 @@ backout() {
exit 1 exit 1
fi fi
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2 echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
if [ "$admin_member" -eq 1 ]; then
# The rollback is real and verified, but for an admin member it is not a
# lockout and must not read as one: what came back was incus-user's
# socket key, not the daemon. Say what survives, and what would end it.
echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2
echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2
echo " neither granted nor removed, so every project on this host stays open to them." >&2
echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2
echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2
fi
# The one window the database cannot close: a login STARTED between our # The one window the database cannot close: a login STARTED between our
# usermod and this backout keeps the group in its session credentials. # usermod and this backout keeps the group in its session credentials.
# For a fresh grant that is a rare race, but rare is not never — name it # For a fresh grant that is a rare race, but rare is not never — name it
@ -122,6 +161,14 @@ backout() {
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2 echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2 echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2 echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
if [ "$admin_member" -eq 1 ]; then
# Same correction as above, for the member who was in BOTH groups before
# this run: 'box revoke' takes the 'incus' key back, and still leaves
# them the whole daemon.
echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2
echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2
echo " access: gpasswd -d $user incus-admin" >&2
fi
fi fi
} }
trap backout EXIT trap backout EXIT
@ -132,17 +179,63 @@ if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
else else
$SUDO usermod -aG incus "$user" $SUDO usermod -aG incus "$user"
added_group=1 added_group=1
if [ "$admin_member" -eq 1 ]; then
# Say why, because the group list alone would imply a restriction that is
# not in force — the concern the old no-op was built around. It was a
# cosmetic concern and this is where it gets carried: in output, not in a
# skipped mutation that broke the mechanism.
echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon,"
echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is"
echo " group 'incus' mode 0660, and nothing below can provision $user without it"
else
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)" echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
fi fi
fi
project="user-$uid" project="user-$uid"
# The incus CLI picks its socket by WRITABILITY, not by intent: with no
# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and
# only falls back to unix.socket.user when it is not (client/connection.go,
# stable-6.0 — the same branch that then defaults the project to user-<uid>).
# For a plain 'incus' member the fallback fires and every command below lands
# where we want it. For an incus-admin member the daemon socket IS writable,
# so an unpinned client sails straight past incus-user — the touch would not
# provision anything and the grant would die claiming incus-user was
# unhealthy. Pin the socket for them, by incus's own directory rule.
# INCUS_DIR first, then /run/incus if the daemon socket lives there, else
# /var/lib/incus: incus's resolution order, not an approximation of it — the
# pinned path has to name the same directory the client would have chosen.
user_socket=""
if [ "$admin_member" -eq 1 ]; then
incus_dir="${INCUS_DIR:-}"
if [ -z "$incus_dir" ]; then
incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
fi
user_socket="$incus_dir/unix.socket.user"
# $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement
# that /var/lib/incus is not traversable by a non-root admin, so an
# unprivileged stat answers "absent" for a socket that is very much there —
# and this check EXITS on absent. Same discipline, same reason (#101 review).
$SUDO test -e "$user_socket" \
|| { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; }
fi
# Run the incus CLI as the granted user, on the socket that will actually
# serve them: pinned to incus-user for an admin member, left to the CLI's own
# resolution for everyone else (whose fallback already gets it right).
run_as_incus() {
if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@"
else run_as "$user" "$@"
fi
}
# 2. The project is created LAZILY, on the user's first contact with # 2. The project is created LAZILY, on the user's first contact with
# incus-user — an admin cannot pre-create it (incus-user would fight over # incus-user — an admin cannot pre-create it (incus-user would fight over
# it), so make that first contact happen now, as the user. # it), so make that first contact happen now, as the user.
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "project: touching incus-user as $user to create $project..." echo "project: touching incus-user as $user to create $project..."
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
incus project show "$project" >/dev/null 2>&1 </dev/null \ incus project show "$project" >/dev/null 2>&1 </dev/null \
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; } || { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
echo "project: $project created" echo "project: $project created"
@ -205,10 +298,53 @@ echo "profile: box-net installed in $project"
# Prove the grant from the USER's side of the socket — the only side that # Prove the grant from the USER's side of the socket — the only side that
# matters. This catches the failure the steps above cannot see one at a time: # matters. This catches the failure the steps above cannot see one at a time:
# a converged project the user still cannot reach. # a converged project the user still cannot reach. For an admin member the
# project is named explicitly: an unqualified profile show over the pinned
# incus-user socket asks about 'default', and over their own admin socket it
# would answer from the shared default project — a green that proves the
# convergence nothing at all.
if [ -n "$user_socket" ]; then
run_as_incus timeout 30 incus --project "$project" profile show box-net >/dev/null 2>&1 \
|| { echo "box grant: converged, but $user cannot reach $project's box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
else
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \ run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; } || { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
fi
trap - EXIT # converged and verified: the grant stands trap - EXIT # converged and verified: the grant stands
if [ "$admin_member" -eq 1 ]; then
# The honest version of what the old refusal was gesturing at. The project
# is real, converged and theirs — that is what they were missing and what
# this run supplied. What it is NOT is confinement, in two distinct ways
# that both come from incus-admin winning at the socket, and both belong in
# the output rather than in a hard exit:
# · nothing here binds them. Every restriction converged above describes
# project $project; the default project and every other user's instances
# stay one flag away, and no setting inside $project can say otherwise
# while they hold that group.
# · nothing here even routes them, yet. Their unpinned CLI resolves to the
# writable daemon socket and so to the DEFAULT project (the socket rule
# cited at step 2), so their 'box new' still lands beside the other
# admins' until they either drop incus-admin — at which point this
# project becomes their automatic home, no re-run needed — or pin
# INCUS_SOCKET at incus-user by hand.
# That "no re-run needed" is a real promise only because the group step
# above put them in 'incus' (#101): dropping incus-admin leaves them a
# plain 'incus' member, which is exactly the tier whose client falls back
# to unix.socket.user and lands in $project. Under the old no-op they would
# have been left in NEITHER group — box_tier 'none', no socket at all, and
# a converged project they could not open.
echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)."
echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a"
echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and"
echo " every other user's instances whenever they choose to."
echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT"
echo " project — the admin socket is the one their client picks. To make $project theirs"
echo " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so"
echo " their client falls straight back to incus-user and $project is already ready)."
echo " 'box revoke $user' unwinds this provisioning and takes the 'incus' membership back;"
echo " it cannot touch their admin access."
else
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet." echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)" echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
fi

View file

@ -64,7 +64,11 @@ legacy_boxes() {
} }
# Re-home one box. Legacy boxes are all claude boxes (the only template the old # Re-home one box. Legacy boxes are all claude boxes (the only template the old
# tool minted), so the new metadata is the claude template's. # tool minted), so the new metadata is the claude-box template's. The stamp
# names the template as it is called TODAY, not as it was called when the box
# was minted: it is what 'box shell' and the mint hints read, so a re-homed
# box should look like a fresh claude-box mint, not like a fossil (rig#76's
# family suffix — the template is named for the role it converges).
rehome_one() { rehome_one() {
local b="$1" st local b="$1" st
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; } incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
@ -75,7 +79,7 @@ rehome_one() {
# 1. TAG FIRST — additive and reversible. A box that stops here is still a # 1. TAG FIRST — additive and reversible. A box that stops here is still a
# valid legacy box (the old tag is untouched) AND now a new one. # valid legacy box (the old tag is untouched) AND now a new one.
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \ incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
|| { warn "$b: could not set new metadata — left untouched"; return 1; } || { warn "$b: could not set new metadata — left untouched"; return 1; }
# 2. Stop, reassign the profile (this is the network move), restart. Incus # 2. Stop, reassign the profile (this is the network move), restart. Incus

View file

@ -47,7 +47,11 @@ if [ "$purge" -eq 1 ]; then
if [ -z "${BOX_YES:-}" ]; then if [ -z "${BOX_YES:-}" ]; then
if [ -t 0 ]; then if [ -t 0 ]; then
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project" printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
read -r reply # EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read'
# returns non-zero and errexit ends the run right here — before the
# 'case' below can name the abort, so the most destructive prompt box
# has would go silent at the moment it asked (#111).
read -r reply || { echo "box revoke: aborted." >&2; exit 1; }
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
else else
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2 echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
@ -56,14 +60,42 @@ if [ "$purge" -eq 1 ]; then
fi fi
fi fi
# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
# put them in 'incus' — not for privilege, but because incus-user's socket is
# a file owned by that group (#101) — so revoke has a real membership to take
# back here. What it does not have is a lockout: 'incus-admin' opens the
# daemon and is not this script's to remove, so "revoked" would still be the
# script claiming something it did not perform. Recorded here, spoken below.
admin_member=0
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
admin_member=1
fi
# The group, first — access ends even if a purge step below trips. # The group, first — access ends even if a purge step below trips.
dropped_group=0
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
$SUDO gpasswd -d "$user" incus >/dev/null $SUDO gpasswd -d "$user" incus >/dev/null
dropped_group=1
if [ "$admin_member" -eq 1 ]; then
echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which"
echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here"
else
echo "group: removed $user from 'incus'" echo "group: removed $user from 'incus'"
fi
elif [ "$admin_member" -eq 1 ]; then
echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch"
else else
echo "group: $user was not in 'incus'" echo "group: $user was not in 'incus'"
fi fi
# Say it once, plainly, before either path's summary: for an admin member the
# group step above closed nothing, so nothing this script does — including
# --purge — ends their access to the daemon. Only the admin group itself does.
if [ "$admin_member" -eq 1 ]; then
echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host"
echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin"
fi
# Supplementary groups are fixed AT LOGIN: the database change above does # Supplementary groups are fixed AT LOGIN: the database change above does
# nothing to a session the user already holds — a leftover tmux keeps the # nothing to a session the user already holds — a leftover tmux keeps the
# socket until it dies. For a bare revoke that is an honest warning. For # socket until it dies. For a bare revoke that is an honest warning. For
@ -95,8 +127,28 @@ fi
if [ "$purge" -eq 0 ]; then if [ "$purge" -eq 0 ]; then
if incus project show "$project" >/dev/null 2>&1 </dev/null; then if incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)" echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
if [ "$admin_member" -eq 1 ]; then
# "restores access" would be the wrong promise here: no access was lost.
echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership"
else
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access" echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
fi fi
fi
if [ "$admin_member" -eq 1 ]; then
# Not "revoked", still: what came back is the 'incus' membership grant
# added for incus-user's socket, and their access to this host was never
# riding on it. The project is kept. So say exactly what was taken and
# name the two real options, instead of a summary that reads as a lockout.
if [ "$dropped_group" -eq 1 ]; then
echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added."
echo " $user is NOT locked out: 'incus-admin' still opens every project on this host."
else
echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken."
fi
echo " to remove their access: gpasswd -d $user incus-admin"
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
exit 0
fi
echo "revoked: $user no longer has the restricted tier." echo "revoked: $user no longer has the restricted tier."
exit 0 exit 0
fi fi
@ -148,10 +200,23 @@ fi
# and a promise the header makes is a promise this block checks. The # and a promise the header makes is a promise this block checks. The
# incus-user state directory too — it was purged for releases without being # incus-user state directory too — it was purged for releases without being
# re-checked, which is exactly the gap this block exists to close. # re-checked, which is exactly the gap this block exists to close.
# The trust store is read into a capture rather than piped into a reader that
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
# would read as "no leftover cert" on a host that still trusts the revoked
# user's certificate and the purge would report success. Fail-open, on the
# cleanup path whose entire job is to prove access is gone.
# Un-racy in practice today — the trust store is small and likely one write —
# so this is defensive, not a live defect. Captured so it cannot become one.
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
leftover="" leftover=""
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project" incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge" incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \ # Leading newline so the first CSV row anchors like the `^` this replaces.
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
&& leftover="$leftover cert:incus-user-$uid" && leftover="$leftover cert:incus-user-$uid"
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \ $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
&& leftover="$leftover /var/lib/incus/users/$uid" && leftover="$leftover /var/lib/incus/users/$uid"
@ -160,4 +225,13 @@ if [ -n "$leftover" ]; then
exit 1 exit 1
fi fi
if [ "$admin_member" -eq 1 ]; then
# "is out" would be false: the purge unmade the provisioning, and their
# incus-admin membership — untouched by design, it is not this script's to
# take — still opens every project on the host, including a fresh one they
# can build by hand a second from now.
echo "purged: everything 'box grant' created for $user is gone — but $user is NOT out."
echo " 'incus-admin' still gives them the whole daemon: gpasswd -d $user incus-admin"
else
echo "revoked: $user is out, and everything the tier created is gone." echo "revoked: $user is out, and everything the tier created is gone."
fi

View file

@ -28,7 +28,21 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
if [ "$yes" -eq 1 ]; then if [ "$yes" -eq 1 ]; then
echo "(confirmed non-interactively: --yes/BOX_YES)" echo "(confirmed non-interactively: --yes/BOX_YES)"
else else
read -rp "Continue? [y/N] " a # No terminal to ask on, and no consent given: refuse and say how to proceed,
# rather than fall into 'read', hit instant EOF and abort with nothing but
# "aborted" (#113). This must stay BELOW the --yes/BOX_YES arm above — the
# order is the contract: consent given non-interactively still runs headless
# (CI's uninstall drill and 'box uninstall --purge-host --force' depend on
# it), consent NOT given without a terminal is a usage error, exit 2, the
# same shape as host/revoke-user.sh and install.sh. It also lands before the
# first 'incus' call below, so the refusal needs no daemon.
if [ ! -t 0 ]; then
echo "teardown-host: refusing to run without a terminal to confirm on. --yes (or BOX_YES=1) means yes." >&2
exit 2
fi
# EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
# this line and the 'aborted' below would never print (#111).
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
fi fi
@ -57,10 +71,37 @@ sudo systemctl daemon-reload
# Firewall crumbs — UFW rules mentioning either network (numbers shift after # Firewall crumbs — UFW rules mentioning either network (numbers shift after
# each delete, so re-scan and remove the first match until none remain) # each delete, so re-scan and remove the first match until none remain)
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then # Every ufw read is CAPTURED before it is matched, never piped into a reader
# that can exit early — the same discipline box-firewall.sh now uses, and for
# the same measured reason (#102). This file sets `pipefail` (line 12), so
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
# and the pipeline yields 141. A plainly-active UFW then reads as inactive
# and this entire block silently skips, leaving stale boxnet/claudenet rules
# on a host the operator was told is clean. It is a branch condition, so
# errexit never fires — there is no error to see, which is exactly why it
# went unnoticed here while the same shape was being measured next door.
#
# The numbered loop had the same defect for a different reason: its condition
# was also an early-exit reader, so it could end while rules remained. It now
# reads one capture per iteration and breaks on absence — the re-scan is still
# per-delete (numbers shift after each removal), just no longer racing.
ufw_status=""
if command -v ufw >/dev/null; then
# '|| true': ufw exits non-zero when it cannot read its config, and under
# pipefail+errexit that would kill a teardown instead of correctly deciding
# "no usable ufw here, nothing to clean".
ufw_status="$(sudo ufw status 2>/dev/null || true)"
fi
if [[ "$ufw_status" == *"Status: active"* ]]; then
for net in boxnet claudenet; do for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do while :; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')" numbered="$(sudo ufw status numbered 2>/dev/null || true)"
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
[ -n "$line" ] || break
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
[ -n "$n" ] || break
sudo ufw --force delete "$n" sudo ufw --force delete "$n"
done done
done done

View file

@ -143,12 +143,39 @@ flip_current() {
mv -Tf "$DEST/current.new.$$" "$DEST/current" mv -Tf "$DEST/current.new.$$" "$DEST/current"
} }
# Whether ANY version was installed before this run — computed BEFORE the
# migration below, which is the whole point. It gates the host-setup offer: a
# host that already ran box has made that decision (and may have live boxes the
# stack must not be rebuilt under, #66); after an upgrade, 'box setup-host'
# re-applies stack changes on purpose.
#
# Order is load-bearing (#115). The migration converts a pre-0.7.0 flat tree
# into versions/<flat_ver>, so computing this AFTER it made the test true by
# its own doing: a flat host looked "already installed", setup-host was
# skipped, and every host-side artifact stayed at the old version while
# 'box --version' reported the new one. A tree that needs migrating has by
# definition never been converged by THIS version's setup-host, so it must
# read as had_install=0. A genuinely versioned tree still reads 1 — the
# directory it is testing predates this run.
had_install=0
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
had_install=1
fi
# --- migrate a pre-0.7.0 flat install -------------------------------------- # --- migrate a pre-0.7.0 flat install --------------------------------------
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under # 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an # it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
# upgrade from 0.6.0 is seamless and the version comparison below sees the # upgrade from 0.6.0 is seamless and the version comparison below sees the
# truth. The move is two renames inside one parent directory — no copying, no # truth. The move is two renames inside one parent directory — no copying, no
# window with no install — and the operator's tree is preserved bit for bit. # window with no install — and the operator's tree is preserved bit for bit.
#
# What the migration LEAVES is the operator's to decide (#117): the old tree
# becomes a first-class 'box versions' entry — a rollback target if the new
# version misbehaves, garbage otherwise. Deleting it here is the wrong default
# (it is the only thing to roll back TO, at exactly the moment that matters),
# so name it instead — and name it AGAIN in the closing summary, because a
# line ~250 lines of output above 'done' is a line the operator scrolled past.
migrated_from=""
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)" flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
# The flat tree's VERSION is data from disk, not from this installer — the # The flat tree's VERSION is data from disk, not from this installer — the
@ -164,15 +191,9 @@ if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
mkdir -p "$BINDIR" mkdir -p "$BINDIR"
ln -sfn "$DEST/current/bin/box" "$BINDIR/box" ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)" log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
fi log " it is a normal version entry now — 'box versions' lists it. Keep it as a"
log " rollback target ('box use $flat_ver'), or reap it: box uninstall $flat_ver"
# Whether ANY version was installed before this run — read before we add one. migrated_from="$flat_ver"
# It gates the host-setup offer below: a host that already ran box has made
# that decision (and may have live boxes the stack must not be rebuilt under,
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
had_install=0
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
had_install=1
fi fi
# --- temp workspace -------------------------------------------------------- # --- temp workspace --------------------------------------------------------
@ -386,6 +407,15 @@ esac
# 'box setup-host' re-applies stack changes deliberately, after an upgrade. # 'box setup-host' re-applies stack changes deliberately, after an upgrade.
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set # BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# up by hand); BOX_YES answers "yes". # up by hand); BOX_YES answers "yes".
#
# It runs $VDIR's script, NOT $DEST/current's. They are usually the same tree,
# but 'current' does not always flip: the #66 guard above keeps the default
# where it is when the host has existing boxes, so on such a host 'current'
# still names the OLD version. Going through it would converge the host with
# the old release's host-side scripts — reinstating exactly the stale artifacts
# #115 is about, in the one case where the operator's boxes make it costly.
# $VDIR is unambiguously the version this run installed, which is the version
# whose host contract we are being asked to satisfy.
setup_ok="" setup_ok=""
setup_declined="" setup_declined=""
if [ "$had_install" -eq 1 ]; then if [ "$had_install" -eq 1 ]; then
@ -396,7 +426,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
setup_declined=1 setup_declined=1
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
warn "cannot set up the host: it needs root and sudo was not found." warn "cannot set up the host: it needs root and sudo was not found."
warn " run this as root to finish: $DEST/current/host/setup-host.sh" warn " run this as root to finish: $VDIR/host/setup-host.sh"
setup_declined=1 setup_declined=1
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
# </dev/null because under 'curl … | bash' this script IS stdin: a child that # </dev/null because under 'curl … | bash' this script IS stdin: a child that
@ -404,7 +434,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
# it prompts on /dev/tty, so an interactive host can still authenticate. # it prompts on /dev/tty, so an interactive host can still authenticate.
# setup-host re-execs itself under sg incus-admin if it must add you to the # setup-host re-execs itself under sg incus-admin if it must add you to the
# group; that re-exec is a child here and completes the whole setup in one go. # group; that re-exec is a child here and completes the whole setup in one go.
if bash "$DEST/current/host/setup-host.sh" </dev/null; then if bash "$VDIR/host/setup-host.sh" </dev/null; then
setup_ok=1 setup_ok=1
else else
warn "host setup did not complete — box is installed, the host is not ready." warn "host setup did not complete — box is installed, the host is not ready."
@ -422,3 +452,12 @@ elif [ -n "$setup_declined" ]; then
else else
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test" log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
fi fi
# Re-state the migration where the operator is actually looking (#117). The
# line at the migration itself is true but ~250 lines of output ago; this is
# the last thing printed, and it is where an unexplained 'box versions' entry
# stops being a surprise.
if [ -n "$migrated_from" ]; then
log "note: your pre-0.7.0 install was migrated to versions/$migrated_from and 'box versions' now lists it."
log " keep it to roll back ('box use $migrated_from'), or remove it: box uninstall $migrated_from"
fi

View file

@ -1,7 +1,11 @@
# The claude template — a thin, creds-free seed (#81): Debian 13, the # The claude-box template — a thin, creds-free seed (#81): Debian 13, the
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI, # 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job: # docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31). # box auto-runs 'rig bootstrap claude-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'claude' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="claude"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude" BOX_BOOTSTRAP_ROLE="claude-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig — # A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no # nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from # docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after # 'rig bootstrap claude-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run # mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role, # 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template. # once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates - ca-certificates
runcmd: runcmd:
# Preinstall rig so the box can converge — and re-converge — via # Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81): # 'rig bootstrap claude-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a # honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -1,7 +1,11 @@
# The codex template — a thin, creds-free seed (#81): Debian 13, the # The codex-box template — a thin, creds-free seed (#81): Debian 13, the
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI, # 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job: # docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31). # box auto-runs 'rig bootstrap codex-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'codex' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="codex"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex" BOX_BOOTSTRAP_ROLE="codex-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig — # A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no # nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from # docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after # 'rig bootstrap codex-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run # mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role, # 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template. # once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates - ca-certificates
runcmd: runcmd:
# Preinstall rig so the box can converge — and re-converge — via # Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81): # 'rig bootstrap codex-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a # honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -1,7 +1,11 @@
# The grok template — a thin, creds-free seed (#81): Debian 13, the # The grok-box template — a thin, creds-free seed (#81): Debian 13, the
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI, # 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
# docker, the agent-context file with its #80 guard — is rig's job: # docker, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31). # box auto-runs 'rig bootstrap grok-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'grok' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="grok"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok" BOX_BOOTSTRAP_ROLE="grok-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig — # A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no # nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from # docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after # 'rig bootstrap grok-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run # mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role, # 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template. # once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates - ca-certificates
runcmd: runcmd:
# Preinstall rig so the box can converge — and re-converge — via # Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81): # 'rig bootstrap grok-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a # honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -0,0 +1,22 @@
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
# docker, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
# the role itself: heavy-duty/rig#109).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'kimi' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates (the duplication is
# deliberate and by hand) — and it is the tenant user the rig role converges
# (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="kimi"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="kimi-box"

View file

@ -0,0 +1,33 @@
#cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users:
- name: kimi
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages:
- tmux
- curl
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a
# release flow). The pin covers both the installer fetched AND the tree
# it installs, so a branch under review is testable end to end.
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
# environment, and the rig installer (set -u) reads $HOME for its DEST —
# measured live: the mint died with "HOME: unbound variable" without it.
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash

View file

@ -1,9 +1,14 @@
# The staging template — a thin, creds-free, server-class seed (#81, the # The staging-box template — a thin, creds-free, server-class seed (#81, the
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The # re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
# server posture — docker, sshd hardening — is rig's job: box auto-runs # server posture — docker, sshd hardening — is rig's job: box auto-runs
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet # 'rig bootstrap staging-box' after mint (heavy-duty/rig#31). The template is
# named for the role it converges, suffix and all (heavy-duty/rig#76): rig's
# roles carry a family suffix — '-server' for fleet machines, '-box' for box
# tenants — and a seed that named the bare 'staging' would ask a post-rename
# rig for a role that no longer exists. Server-CLASS, not a fleet machine:
# this is still a box tenant, so it takes '-box', not '-server'. The tailnet
# workload join holds a key and therefore STAYS operator-run: # workload join holds a key and therefore STAYS operator-run:
# box shell <name> # then: sudo rig bootstrap workload --hostname <name> # box shell <name> # then: sudo rig bootstrap workload-server --hostname <name>
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
@ -21,4 +26,4 @@ BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_REQUIRE_VM="1" BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1" BOX_AUTOSTART="1"
BOX_BOOTSTRAP_ROLE="staging" BOX_BOOTSTRAP_ROLE="staging-box"

View file

@ -2,9 +2,9 @@
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65), # A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
# and rig — nothing that joins a tailnet or admits credentials, no docker, # and rig — nothing that joins a tailnet or admits credentials, no docker,
# no sshd config, no keys. The server posture comes from # no sshd config, no keys. The server posture comes from
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after # 'rig bootstrap staging-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the tailnet workload join holds a pre-auth key and stays # mint; the tailnet workload join holds a pre-auth key and stays
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as # operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
# #69 designed it — box never sees the key. # #69 designed it — box never sees the key.
users: users:
- name: ops - name: ops
@ -22,7 +22,7 @@ packages:
- ca-certificates - ca-certificates
runcmd: runcmd:
# Preinstall rig so the box can converge — and re-converge — via # Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81): # 'rig bootstrap staging-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a # honest edge as rig's own unpinned box install, until rig#32 ships a

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd "$(dirname "$0")/.."
# shellcheck source=.github/scripts/labels-reconcile.sh
. .github/scripts/labels-reconcile.sh
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
pass=0 fail=0
expect() { # $1 = description, $2 = want, $3 = got
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
fi
}
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT=false REQUESTED="$BOT1
$BOT2
$BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a bot that never reviewed keeps the round open ---------------------------
REQUESTED="" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)")"
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
# ...and the author's escalation — requesting the human — flips it
REQUESTED="$HUMAN"
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
REQUESTED=""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED="$BOT1"
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
"$(rev "$BOT1" APPROVED head1 "" t2)" \
"$(rev "$BOT2" APPROVED head1 "" t3)" \
"$(rev "$BOT3" APPROVED head1 "" t4)")"
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
expect "human requested outranks bots" state:needs-human "$(decide_state)"
REQUESTED=""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
# ...and re-requesting the human hands it back to them
REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED=""
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
# ...and a live request suppresses re-requesting
REQUESTED="$HUMAN"
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
REQUESTED=""
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View file

@ -1,22 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The release flow (#83), proven offline. Run: bash test/release.sh # Box-specific release-channel coverage. Shared release/guard machinery lives
# # in heavy-duty/ceremony and is tested there; this file drives real install.sh.
# Three surfaces: the changelog-section extraction release.yml publishes
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
# CHANGELOG.md so the header format cannot drift under it), the
# latest-release tag resolution install.sh defaults to (the extracted
# function, driven against a shim curl serving canned redirects), and the
# three install channels — REAL install.sh runs against throwaway roots,
# with the shim curl standing in for GitHub. Nothing here touches the
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
# the harness asserts on failing commands.
set -u set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0 PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
# Runs cmd, asserts exit code and (if non-empty) that combined output
# contains want_substr.
check() { check() {
local desc="$1" want="$2" substr="$3"; shift 3 local desc="$1" want="$2" substr="$3"; shift 3
local out rc local out rc
@ -34,98 +22,10 @@ check() {
echo "ok: $desc"; PASS=$((PASS + 1)) echo "ok: $desc"; PASS=$((PASS + 1))
} }
NOTES="$ROOT/.github/scripts/release-notes.sh"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
# ---------------------------------------------------------------------------
# release-notes.sh — the extraction, against a fixture changelog that carries
# every boundary: an Unreleased section that must never leak into a release,
# two adjacent versions, a version that prefixes another (0.7.0 vs
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
# ---------------------------------------------------------------------------
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
FIX="$WORK/CHANGELOG.md"
cat > "$FIX" <<'EOF'
# Changelog
Intro prose that belongs to no section.
## Unreleased
- **Not yet released** — must never appear in a release body.
## 0.7.0 — 2026-07-20
### Added
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
## 0.7.0-rc1 — 2026-07-19
- **The rc entry** — must not ride along with 0.7.0.
## 0.6.0 — 2026-07-18
- **The six-oh entry** — the previous release's prose.
## 0.5.0 — 2026-07-15
EOF
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
check "extract: the version is matched WHOLE (rc1 is its own section)" \
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
# The REAL changelog: released sections must keep extracting, or release.yml
# breaks the day it runs — this is the guard against header-format drift.
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
# ---------------------------------------------------------------------------
# release.yml — a daemon-free run cannot push a tag, so the wiring is
# grepped, fail-closed (the house discipline): the VERSION assertion, the
# shared extraction script, and that the tag is verified before creation.
# ---------------------------------------------------------------------------
RY="$ROOT/.github/workflows/release.yml"
check "release.yml: exists" 0 "" test -f "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
grep -qF 'creating nothing' "$RY"
check "release.yml: the body comes from the shared extraction script" 0 "" \
grep -qF '.github/scripts/release-notes.sh' "$RY"
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
grep -qF -- '--verify-tag' "$RY"
# ---------------------------------------------------------------------------
# latest_release_tag — extracted from install.sh (the source-the-pure-function
# trick) and driven against a shim curl. The shim serves the ONE seam the
# function uses: -w '%{redirect_url}' on the releases/latest probe.
# ---------------------------------------------------------------------------
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR" SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
cat > "$SHIMDIR/curl" <<'SHIM' cat > "$SHIMDIR/curl" <<'SHIM'
#!/usr/bin/env bash #!/usr/bin/env bash
# Fake curl for the release drills: answers the releases/latest probe with
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
# assert exactly what was asked for, and in what order.
url="" out="" url="" out=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
@ -151,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
TAGFN="$(mktemp)" TAGFN="$(mktemp)"
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN" awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN" check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN" check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
ltag() { # ltag <redirect_url> [curl_rc] ltag() {
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \ FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag" PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
} }
check "resolve: reads the tag off the redirect" 0 "0.6.0" \ check "resolve: reads the tag off the redirect" 0 "0.6.0" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \ check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1" ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \ check "resolve: a repo with no releases fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases" ltag "https://github.com/heavy-duty/box/releases"
check "resolve: no redirect at all fails" 1 "" ltag "" check "resolve: no redirect fails" 1 "" ltag ""
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \ check "resolve: a curl failure fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6 ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
rm -f "$TAGFN"
# ---------------------------------------------------------------------------
# The three channels, driven through REAL install.sh runs (#83): default =
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
# top-level directory), and its log proves WHICH URLs the installer asked
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
# under test.
# ---------------------------------------------------------------------------
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin" SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box" cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
echo "9.9.9" > "$SRC/VERSION" printf '9.9.9\n' > "$SRC/VERSION"
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9 tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network ninst() {
local h="$1" b="$2"; shift 2 local h="$1" b="$2"; shift 2
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \ env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \ BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh" FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
} }
# --- channel 1: the default is the latest RELEASE ---------------------------
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log" H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \ check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \ ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \ FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz" FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "default channel: the download is the TAG tarball" 0 "" \ check "default channel: downloads the tag tarball" 0 "" \
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1" grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
check "default channel: it never asked for a branch" 1 "" \ check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
grep -q "refs/heads" "$L1" check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
cat "$H1/versions/9.9.9/INSTALLED_FROM" cat "$H1/versions/9.9.9/INSTALLED_FROM"
check "default channel: the install answers through the chain" 0 "box 9.9.9" \ check "default channel: installed binary answers" 0 "box 9.9.9" \
env HOME="$FAKEHOME" "$B1/box" --version env HOME="$FAKEHOME" "$B1/box" --version
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log" H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \ check "pinned channel: installs the requested tag" 0 "done" \
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \ ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz" FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \ check "pinned channel: skips latest-release resolution" 1 "" \
grep -q "releases/latest" "$L2" grep -q "releases/latest" "$L2"
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log" H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \ check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \ ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz" FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \ check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
head -1 "$L3" check "dev channel: then downloads the branch" 0 "" \
check "dev channel: then the branch" 0 "" \
grep -qF "archive/refs/heads/main.tar.gz" "$L3" grep -qF "archive/refs/heads/main.tar.gz" "$L3"
# --- the failure is LOUD, never a silent fall-through to main ---------------
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log" H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \ check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4" ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \ check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 ninst "$H4" "$B4" FAKE_CURL_RC=6
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \ check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
grep -q "archive/" "$L4" check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions" check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
ninst "$H4" "$B4" BOX_REF=no-such-ref ninst "$H4" "$B4" BOX_REF=no-such-ref
# --------------------------------------------------------------------------- check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
# The -dev convention (#83): main's VERSION carries -dev between releases, so check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
# a dev install lands beside releases in versions/ instead of impersonating check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
# one — and the docs keep the promises this PR makes.
# ---------------------------------------------------------------------------
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
check "README documents the default (latest release) channel" 0 "" \
grep -qF 'latest release' "$ROOT/README.md"
check "README documents the pinned channel" 0 "" \
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
check "README documents the dev channel" 0 "" \
grep -qF 'BOX_REF=main' "$ROOT/README.md"
echo "---" echo "---"
echo "$PASS passed, $FAIL failed" echo "$PASS passed, $FAIL failed"