Compare commits

..

No commits in common. "main" and "0.7.0" have entirely different histories.
main ... 0.7.0

47 changed files with 1395 additions and 4109 deletions

View file

@ -1,49 +0,0 @@
# 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.

View file

@ -1,76 +0,0 @@
# 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.

View file

@ -1,98 +0,0 @@
# 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.

View file

@ -1,14 +0,0 @@
# .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).

View file

@ -1,70 +0,0 @@
# 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.

View file

@ -1,95 +0,0 @@
# 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 .ceremony/LABELS.md's scope
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
# 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.
"scope:cli":

7
.github/labels.conf vendored
View file

@ -1,7 +0,0 @@
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

228
.github/scripts/labels-reconcile.sh vendored Normal file
View file

@ -0,0 +1,228 @@
#!/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

30
.github/scripts/release-notes.sh vendored Normal file
View file

@ -0,0 +1,30 @@
#!/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,105 +8,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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
# -x follows `source`/`.` directives; box has no lib split today, but the
# flag costs nothing and keeps the invocation identical to rig's.
# globstar so a script in a new subdirectory is linted without anyone
# 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.
#
# 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: |
shopt -s globstar dotglob
shopt -s globstar
files=(bin/* **/*.sh)
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[@]}"
- name: cli tests
run: bash test/cli.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
- name: release-flow tests
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
# disposable VM, which is exactly the substrate the rehearsal needs. It runs

View file

@ -1,13 +1,56 @@
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:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
schedule:
- cron: "*/15 * * * *"
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
scope:
if: github.event_name == 'pull_request_target'
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,18 +1,42 @@
name: release
# Triggers and permissions MUST live here (a called workflow cannot define them):
# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set
# 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:
# 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:
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# never be skipped by a shape filter that didn't match
branches: [main]
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
# below, not be silently skipped by a pattern that didn't match.
tags: ["**"]
permissions:
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
contents: write # gh release create
jobs:
release:
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
with:
version-source: file
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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"

View file

@ -1,7 +0,0 @@
# 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,163 +3,381 @@
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.
## 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
### Added
- The installer defaults to the latest release, and releases publish
themselves (#83)
- `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
(#80)
- `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
- `box doctor` knows the #80 signature: a gateway held as a local address, and
duplicate connected routes for the uplink subnet
- The `staging` template — a server-class, creds-free seed (#81)
- The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
- The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
- Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
- The template test suite discovers `templates/*/` instead of hardcoding the
list (#81)
- `box export` / `box import` — a box's state that survives the box and the
host (#70)
- Versioned installs at `<root>/versions/<v>`, with `box versions` and
`box use` (#66)
- A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
in an absence assert
- `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
rehearsal drills the uninstall to zero residue
- `test/cli.sh` drives real installs against throwaway roots and a fake incus
(154 checks)
- **The installer defaults to the latest release, and releases publish
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at
that second: the 0.6.0 release was a bookmark, not a package, and two
operators "on 0.6.0" could be running different trees. `install.sh` now
resolves the latest release tag by following GitHub's `releases/latest`
redirect (one HEAD request — no API, no token, no rate-limit pain) and
downloads that tag's tarball; a failed resolution refuses loudly, naming
`BOX_REF` as the way out — it never hangs and never silently falls back to
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
publishes the GitHub release with that version's `CHANGELOG.md` section as
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
generated PR list; no assets, the source tarball for the tag IS the
package). And `main`'s `VERSION` now carries `-dev` between releases
(this PR: `0.6.1-dev`): the versioned layout names install trees after
`VERSION`, so a `main` install without the bump would land in
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
drives all of it offline — the extraction against fixtures and the real
changelog, the resolution and every channel against a shim curl.
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
flags** (#80, completing its fix #1: "refuse … or automatically select a
non-colliding subnet"). A bare `box setup-host` now decides the subnet
itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or
refused, never silently overridden (scripted hosts keep exact semantics);
an existing `boxnet` bridge is converged on as-is — the bridge IS the pin —
turning the old bare-re-run agree-gate refusal into plain convergence
(unless a foreigner *also* claims the bridge's subnet: that is #80's
poisoned state, and converging would rebuild on it, so it still refuses and
names the bridge move); a free `10.88.0.0/24` stays the default; and a
*claimed* default — the nested case: a drill or rehearsal running inside a
box, whose own uplink owns 10.88 — scans `10.89.0.0/24``10.127.0.0/24`
in order, takes the first free candidate, announces the pick and the
claimant loudly, and only refuses when every candidate is claimed. The
decision happens before any mutation, and everything downstream (the
bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's
expectations) derives from it.
- **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another**
(#80) — run inside a box, `setup-host` used to build a nested `boxnet` on
the exact subnet and gateway of the guest's own uplink: the guest then held
its gateway's address as a *local* address, carried duplicate connected
routes for its uplink subnet, and suffered intermittent, self-recovering
egress blackouts that looked like flaky internet (measured live: ~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
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
the tenant content (#81, heavy-duty/rig#31)
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
agent CLI installs, docker, node, the per-template agent-context heredocs)
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
testable end to end instead of parse-only YAML. What remains per template
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
preinstalled — nothing that joins a tailnet or admits credentials. The #80
agent-context guard ("never run `box setup-host` or the drill inside a
box") now lives once, in rig's roles, not copy-pasted per template. The
template test sweep grew the contract's teeth: per-template seed asserts
(user matches, rig pinned via both tokens) and fail-closed **absence
greps** over effective cloud-init lines — no agent CLI, no docker, no
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
cannot quietly grow back.
### Fixed
- A wedged `incus launch` fails loudly, not forever: the launch phase is
narrated and time-boxed (#93)
- UFW's gateway carve-out converges with the bridge, and the doctor can see it
(#86)
- The boot-time gateway fallback is gone — an unaddressed bridge leaves the
persisted UFW rules alone (#86)
- `revoke --purge` re-checks the incus-user state, and stats it through
`$SUDO`
- A wedged `$BINDIR/box` no longer blocks installing
- **A wedged `incus launch` fails loudly, not forever — the mint's launch
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
the child `incus launch` under `box new` hung with *no server-side
operation*: `incus operation list` empty, the instance never created, the
daemon journal quiet — one wedge ran 56 minutes before being killed by
hand, and an immediate retry of the identical command succeeded in
minutes, both times. `box new` inherited that as an indefinite silent
hang, indistinguishable from a cold mint working. It now prints
`launching instance …` before the call, and the call rides
`timeout -k 5 $BOX_LAUNCH_TIMEOUT` (seconds, default 600 — generous: the
coldest measured mint is minutes, never an hour; the same scripting-knob
shape as `BOX_CPU`/`BOX_MEMORY`), with stdin pinned per the drill's own
trap list. On the budget firing it probes whether the instance was ever
registered and tells the two stories apart — the measured #93 wedge (no
server-side operation; an immediate retry has been observed to succeed)
vs a slow launch that overran the budget with the instance already
created — then best-effort deletes either way, so the retry advice is
clean in both worlds, and points at `box doctor` for the host. The
`--from` clone path is untouched: `incus copy` of a local instance is a
different operation and has never been observed to wedge this way.
- **UFW's gateway carve-out converges with the bridge, and the doctor can
see it** (the #86 review's blind spot) — `box-firewall` gated its whole
UFW block behind "a `DENY on boxnet` rule exists", pinning every UFW host
to the gateway of the *first* run: a bridge remapped off a colliding
subnet (#80's escape hatch) kept its stale `allow … to <old-gw> port 53`
and never gained the live gateway's, so box→gateway DNS died at box's own
deny — while the doctor's carve-out check read only the incus ACL (which
setup-host converges) and called the host clean. The UFW allows now
converge off the live bridge address on every run (stale DNS allows
deleted, the live set ensured — ufw skips existing rules, so a fresh host
gets the identical rule set and a re-run is a no-op), and `box doctor`
reads UFW's own table wherever UFW is active, flagging a DNS allow that
does not match `boxnet`'s gateway (and stale allows left beside a live
one). The no-UFW nft carve-out never had this failure mode: it is
interface-scoped, no gateway address to go stale.
- **The boot-time gateway fallback is gone — no rule beats a wrong one**
with the bridge not yet addressed when `box-firewall.service` ran,
`box-firewall` guessed `GW=10.88.0.1`; on a `BOX_SUBNET` host that hit
that window the UFW carve-out was built for the wrong gateway, a latent
DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves
the persisted UFW rules exactly as they are (they survive boots on their
own, and nothing else in the script needs the gateway) and says so on
stderr; the next setup-host run or service restart converges them once
the bridge is addressed.
- **`revoke --purge` re-checks the incus-user state** — the purge removed
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
path its own absence block did not cover; and the stat now rides
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
so a bare `[ -d ]` answered "absent" for a directory that was there).
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
no-op-if-installed check keyed off the symlink's existence OR the tree's,
so a stale symlink (or a half-removed tree) could fake "already installed"
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
are converged with `ln -sfn`, never trusted as the signal.
## 0.6.0 — 2026-07-18
### Added
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
on the shared hardened `boxnet` (#74)
- CI runs the multi-user rehearsal on a real Incus
- Global / root install — one world-readable tree at `/opt/box` (#71)
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
runs `box grant <user>` and that user gets their own boxes on the same
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
(`--purge` deletes their world, and asserts the absence). The tier rides
incus-user, whose defaults miss box's contract three measured ways (Debian
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
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
which does not exist
- `box tmux` works on every template — tmux is in each template's package list
(#65)
- `box setup-host` finishes in one run, re-execing itself under
`sg incus-admin` (#63)
- `setup-host` works as root, with or without `sudo`
- `setup-host` grants `incus-admin` to the human, not to root
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
- `setup-host`'s apt calls can no longer hang on the dpkg lock
- **`box restore` never worked against Incus 6** — the command table
dispatched `incus restore`, a subcommand that does not exist (Incus 6
spells it `incus snapshot restore`), so every restore died on "unknown
command". Found by #74's rehearsal exercising the full lifecycle as a
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
`test/cli.sh` now hold it.
- **`box tmux` works on every template** (#65) — `box tmux` runs
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
it failed with `tmux: command not found`. `tmux` is now in each template's
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
- **`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
- `drill.sh` asserts the post-install stack instead of building it itself
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
- `install.sh` never overwrites an existing install
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
`setup-host` itself right after installing, so the stack existed by its own
hand and a run passed identically whether or not `install.sh` had done a
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
@ -170,27 +388,61 @@ installer retires — but nothing current carries the old name.
### Added
- `codex` and `grok` templates
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
inside a box
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
`box migrate-host`
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
read)
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
creds-free like every template. The template mechanic (image + user +
resources, never a network or a `security.*` key) now has three tenants
beside `blank`, and the drill mints all of them cold.
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
door to a port inside a box, for seeing a dev server in your browser. The
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
is per-port, `--list`/`--remove` manage it, and `box info` shows open
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
- VM mints no longer hang at GRUB — boxes launch with
`security.secureboot=false`
- `box expose` actually delivers packets
- Firewall rules converge on upgrade instead of pinning a host to the release
that first ran there
- Failed mints tell you why
- `grok` installs the binary it actually ships
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
shim signature" forever. Boxes now launch with `security.secureboot=false`;
the VM boundary, not boot attestation, is the box threat model.
- **`box expose` actually delivers packets** — a trilogy of drill-found
absences: the NAT proxy needs the box's boxnet lease pinned as a static
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
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
- Debrand complete — env vars, install dir, docs, template descriptions and
the README all say `box`; the install URL is `heavy-duty/box`
- The drill grew from 47 to 84 checks
- **Debrand complete** — env vars, install dir, docs, template descriptions
and the README all say `box`; the install URL is
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
- **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,83 +1,87 @@
# Contributing
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
[`.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.
How change lands in this repo. The short version: PRs are born as drafts,
three reviewer bots take the first rounds, a human takes the last word — and
labels tell you where everything is without opening anything.
Only triage mints issues. Everyone else opens or extends a discussion when
they find work outside an existing issue contract. Only humans merge.
## The PR loop
## Review panel
The review panel is:
- `claude-bot-andresmgsl`
- `codex-bot-andresmgsl`
- `grok-bot-andresmgsl`
- `kimi-bot-andresmgsl`
Every PR needs a current-head verdict from the whole panel minus its author.
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
invisible to the panel; when ready, request every eligible reviewer.
## Code and verification
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
because they assert failing commands.
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
CI also runs the Incus multi-user rehearsal.
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
- Comments preserve the incident that bought a rule, including its issue
number.
## Changelog
Every behavior-changing PR adds one concise line under `## Unreleased`,
above the shipped heading below it. Cite the issue or PR. Never replace or
duplicate a shipped heading; the shared armed and monotonic guards enforce
both halves of this rule.
1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
deserves one.
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
bots on purpose.
3. **When it's ready**: mark ready-for-review and request all three bots —
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
They poll roughly every 15 minutes.
4. **Rounds are answered whole.** Wait until all three have reviewed, then
answer the entire round in a **single reply**, push the fixes, and
re-request the bots that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
5. **Reviews end in a verdict.** A reviewer — bot or human — either
**approves** or **requests changes**, never a bare comment. A
comment-only review is a non-verdict: it doesn't say whether the round
passed, and the state machine (and anyone scanning the board) has to
guess. The verdict carries *blockingness only*, the body carries the
feedback: non-blocking nits ride an **approval** and the author addresses
them at their discretion; anything blocking — including a question that
gates the verdict — is **request changes**, saying what unblocks it. The
reconciler treats a comment-only review as not-approved, so commenting
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
judgment belongs to the **author** — escalate by requesting the
maintainer's review (step 6), and the reconciler flips the label on that
request, because an explicit request is a fact it can trust.
6. **When the round passes, the author hands the PR to the maintainer** by
requesting their review — that request is what flips `state:needs-human`.
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
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`.
A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83)):
Box uses the `file` version backend and has no artifact hook: for this
pure-Bash tree, GitHubs source tarball for the tag is the package, and
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
`drills/<version>.md` remain box-owned release inputs.
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
date (feature PRs land their changelog entry as part of the PR, so the
section is already written). This PR is where the release ritual hangs:
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.
### What a box drill proves
## Labels — who sets what
The box drill is the 85-probe VM isolation contract: it exercises the trust
boundary on real hardware. The lighter Incus container rehearsal in CI proves
the tier mechanics but cannot substitute for that boundary measurement. The
record format and operating procedure live in [drills/README.md](drills/README.md).
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
who sets each kind — most of it is machinery, and hand-moving a
machine-owned label just gets corrected on the next pass:
`drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
different artifacts. The former is per-release evidence read by the release
guard; the latter is the harnesss ongoing run log and lore. Updating one
never satisfies the purpose of the other.
| Labels | Set by |
|---|---|
| `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. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `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. |
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.
## Issues
A known gap remains from box#81: released box templates still default
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
the one drilled. This conversion does not change that behavior or claim the
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
Give issues the same care as PR titles: say the surface in the title, apply a
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
you open one, and `blocked` when it waits on something — that is what keeps
the board navigable as the issue count grows.

87
LABELS.md Normal file
View file

@ -0,0 +1,87 @@
# 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
```

215
README.md
View file

@ -2,8 +2,8 @@
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the coding-agent templates
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
converges**: the template is a thin seed, and the agent tooling lands via a
creds-free `rig bootstrap` role auto-run at mint
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
@ -217,14 +217,13 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
## Quick start
```sh
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 new --name work --template claude # a creds-free coding-agent box (~10 min cold)
box shell work # enter as the template's user
```
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
`claude-box` template looks like this; the others follow the same shape with
their own login step:
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
`blank` for none. Inside the box, authenticate as needed. The `claude` template
looks like this; the others follow the same shape with their own login step:
```sh
claude # then run /login — copy the URL (press c), open it
@ -239,14 +238,13 @@ claude # if the repo has .box/, the agent reads it and
No coding agent is special — each is one template among several, and adding
another is just another directory. What ships today:
| Template | What it becomes |
| ------------- | ------------------------------------------------------------------ |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude-box` | Claude Code, creds-free — where this project started |
| `codex-box` | OpenAI Codex CLI, creds-free |
| `grok-box` | xAI Grok CLI, creds-free |
| `kimi-box` | Moonshot Kimi CLI, creds-free |
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
| Template | What it becomes |
| --------- | ---------------------------------------------------------------------- |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex CLI, creds-free |
| `grok` | xAI Grok CLI, creds-free |
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
**Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
@ -257,11 +255,8 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
joins a tailnet or admits credentials — and after cloud-init settles, box
auto-runs the template's **creds-free** tenant role inside the guest
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
[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,
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
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
an *existing* box to a newer spec (`box shell <box>` →
@ -270,9 +265,9 @@ an *existing* box to a newer spec (`box shell <box>` →
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
from rig's roles, instead of copy-pasted per template.
**Anything that joins or admits stays operator-run.** The `staging-box`
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-server` — and
**Anything that joins or admits stays operator-run.** The `staging` box'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
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept).
@ -284,9 +279,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
environment:
```sh
box new --name work --template claude-box # heavy-duty/rig @ main
box new --name work --template claude # heavy-duty/rig @ main
RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude-box # a rig branch under review
box new --name trial --template claude # a rig branch under review
```
Both directions of that edge track `main` unpinned today — said honestly,
@ -325,92 +320,11 @@ 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
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` — 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).
a box's live state, or roll a box back with `box restore work authed`.
Forgotten what you called a checkpoint? `box info work` prints the box's
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`
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
@ -469,17 +383,12 @@ 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 templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, exposures, provenance, snapshots
box info <box> # one box: state, IP, exposures, snapshot labels
box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
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 restore <box> <snap> # roll back to a snapshot
box export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>]
@ -510,82 +419,6 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
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
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes

View file

@ -1 +1 @@
0.9.1-dev
0.7.0

709
bin/box
View file

@ -9,27 +9,10 @@ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
cpu=""; memory=""; disk=""; instance_only=0
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
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
# 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
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
# 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
@ -52,7 +35,7 @@ box_tier() {
# ---------------------------------------------------------------------------
# The command table.
#
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
#
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
#
@ -61,8 +44,7 @@ box_tier() {
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
# arg2 a second positional is required
# stopped the box must not be running
# confirm destructive: prompt unless --force. The row supplies the words
# (last field) — see 'confirm prompt' below.
# confirm destructive: prompt unless --force
#
# action:
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
@ -70,19 +52,6 @@ box_tier() {
#
# 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
# doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
@ -90,18 +59,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^"
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
"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^"
"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^"
"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"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
"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^"
"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 {}"
"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 {}^delete {} and all its snapshots"
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
"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^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
@ -123,12 +92,12 @@ is_command() { cmd_row "$1" >/dev/null 2>&1; }
# 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.
field() {
local r f_syn f_pre f_sum f_act f_ok f_cnf
local r f_syn f_pre f_sum f_act f_ok
r="$(cmd_row "$1")" || return 1
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
case "$2" in
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
esac
}
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
@ -178,7 +147,7 @@ COMMANDS
EOF
local r v sum
for r in "${CMDS[@]}"; do
IFS='^' read -r v _ _ sum _ _ _ <<<"$r"
IFS='^' read -r v _ _ sum _ _ <<<"$r"
printf ' %-13s %s\n' "$v" "$sum"
done
cat <<'EOF'
@ -192,8 +161,7 @@ OPTIONS
dev/test only. Default where /dev/kvm is absent.
--json Emit Incus JSON instead of a table (list, info)
--instance-only Export the live state only, no snapshots (export)
--force, -f Destroy without asking (rm, restore); overwrite the
file (export)
--force, -f Delete without asking (rm); overwrite the file (export)
--remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command
--version, -V Print the box version
@ -202,7 +170,7 @@ OPTIONS
EXAMPLES
# mint a claude box and log in inside it — the tool never handles your token
box new --name work --template claude-box
box new --name work --template claude
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
@ -259,13 +227,13 @@ help_cmd() {
case "$1" in
new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default:
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
blank — bare Debian 13, nobody home; --template claude gets Claude Code
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,
isolation is preserved, and the clone knows its template's user without
being told.
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
A tenant template (claude, codex, grok, staging) is a THIN seed — the user,
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
agent CLI / server posture and the agent-context file. rig is preinstalled
@ -302,40 +270,9 @@ there is no flag for a network or a security key, on purpose.
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
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 work --template claude-box
box new --name lean --template claude-box --cpu 2 --memory 3GiB
box new --name work --template claude
box new --name lean --template claude --cpu 2 --memory 3GiB
box new --name feature --from work/authed
EOF
;;
@ -414,19 +351,6 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have.
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
;;
restore) cat <<'EOF'
@ -434,51 +358,6 @@ 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.
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
;;
export) cat <<'EOF'
@ -523,17 +402,6 @@ snapshots). What box then re-stamps is THIS host's truth:
differs, the same move migrate-host makes re-homing a legacy box
· a fresh machine identity (reset_identity, exactly like a clone), so its
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,
logins included. That is the point (log in once, keep the file), and the same
@ -580,7 +448,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
that is safe: nothing but this proxy can reach the port.
box new --name web --template claude-box
box new --name web --template claude
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
EOF
@ -674,17 +542,6 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
rides snapshots, 'box export' rides backups — #70)
· 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/
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
@ -704,15 +561,6 @@ recreate the project, unhardened, afterwards — measured, not theoretical),
then their boxes, images, project, private bridge and trust-store
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 --purge # ...or end their sessions and delete everything
EOF
@ -916,23 +764,12 @@ need_arg2() {
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.
if [ "$force" -eq 1 ]; then return 0; fi
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
local reply
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
# 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."
read -r reply
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
@ -1109,16 +946,8 @@ load_template() {
# 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
# 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() {
local f="$1" repo data ref
repo="$(rig_repo)"; ref="$(rig_ref)"
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|| die "RIG_REPO must look like owner/repo: $repo"
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
@ -1141,144 +970,6 @@ cmd_templates() {
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() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
require_stack
@ -1290,78 +981,10 @@ cmd_new() {
case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
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"
wait_agent "$instance"
reset_identity "$instance"
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
local t="${template:-blank}" m extra=()
load_template "$t"
@ -1398,47 +1021,6 @@ cmd_new() {
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
# 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
# release drill the child 'incus launch' wedged before the create was
# even accepted — 'incus operation list' empty, the instance never
@ -1457,7 +1039,6 @@ cmd_new() {
--config user.box=1 \
--config user.box.template="$t" \
--config user.box.user="$T_USER" \
"${stamp[@]}" \
--config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
@ -1495,20 +1076,6 @@ cmd_new() {
# Not a wedge: incus refused and said why on stderr, right above.
die "incus launch failed (exit $rc)"
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"
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)"
@ -1541,19 +1108,6 @@ cmd_new() {
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"
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,
# 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
@ -1569,57 +1123,20 @@ cmd_new() {
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 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"
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
# The login hint belongs to the claude-box template — read the EFFECTIVE
# template off the instance, so a clone of a claude-box box gets it too and
# 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.
# The login hint belongs to the claude template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a
# blank box is not told to run a binary it doesn't have.
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-box
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
elif [ "$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 shell $name # then: sudo rig bootstrap workload-server --hostname $name"
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name"
else
echo "box: ready — 'box shell $name'."
fi
@ -1702,85 +1219,6 @@ cmd_list() {
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() {
local box="${args[0]}" row
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
@ -1802,12 +1240,6 @@ cmd_info() {
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
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
case "${snaps:-0}" in
''|0)
@ -1845,18 +1277,13 @@ box_user() {
}
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
# 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}"; }
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
# 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'
# 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
# bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
# caller-supplied command payload; its session name is validated below.
# bare on purpose — two verbs, two contracts.
cmd_tmux() {
local session="${args[1]:-main}"
case "$session" in
@ -1962,69 +1389,6 @@ cmd_import() {
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 ] || 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
# 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
@ -2233,8 +1597,7 @@ 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)"
local reply
printf 'box: %s? [y/N] ' "$1"
# Same EOF cure as confirm() above — Ctrl-D must abort out loud.
read -r reply || die "aborted."
read -r reply
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
@ -2525,19 +1888,12 @@ cmd_expose() {
# --- dispatch: driven by the table, not by a hand-written case --------------
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
IFS='^' read -r _ _ pre _ action ok cnf <<<"$row"
IFS='^' read -r _ _ pre _ action ok <<<"$row"
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
case ",$pre," in *,arg2,*) need_arg2 ;; esac
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; 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 ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
case "$action" in
fn:*)
@ -2549,7 +1905,8 @@ case "$action" in
# shellcheck disable=SC2086
incus $sub "$inst" "${args[@]:1}"
if [ -n "$ok" ]; then
echo "box: $(fill "$ok" "${args[0]}")"
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
echo "box: $msg"
fi
;;
esac

View file

@ -1,8 +1,8 @@
# box design
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
with a coding agent installed** (`claude`, `codex`, `grok`, 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
design decisions.
@ -43,33 +43,6 @@ snapshots, not a secrets store:
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
clone still lives on the same host. The off-host mechanism is `box export` /
`box import` (#70) — one portable backup tarball, snapshots included by
@ -77,13 +50,7 @@ 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
(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
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
machine identity via the same `reset_identity` a clone gets). Auth state
rides along deliberately — and because scrubbing a disk image is a promise
tarball surgery cannot keep, export shouts that the file is a credential
instead of pretending to sanitize it.
@ -93,17 +60,15 @@ instead of pretending to sanitize it.
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
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-box`
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
`-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 —
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
`codex` / `grok` / `staging`), 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
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
holds a key (the staging-box tenant's workload join) stays operator-run
through `box shell`; box prints it as a next step and never sees the key. The
seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
holds a key (staging's workload join) stays operator-run through
`box shell`; box prints it as a next step and never sees the key. The 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),
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

View file

@ -2,9 +2,9 @@
`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
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
host-run setup script.
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows
**nothing** about your project. There is no `install` step and no host-run
setup script.
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

View file

@ -127,13 +127,11 @@ It had never worked.
## Rehearsal and CI
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
criteria (a)(f) from #74 plus the measured extensions (g)(o): the in-box
criteria (a)(f) from #74 plus the measured extensions (g)(n): the in-box
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
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;
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure
injections (n). Real users, real grants, real mints, probes from inside;
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
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.
#
# Two traps, both hit for real:
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
# needless machinery for a probe.
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
# all). Fine for a person, needless machinery for a probe.
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
# 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
@ -378,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
fi
inf "host setup complete"
# A real server has room for the claude-box template's resources (8GiB/4cpu), and
# A real server has room for the claude template's resources (8GiB/4cpu), and
# 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
# 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)"
if [ "$ram" -lt 20 ]; then
export BOX_MEMORY=3GiB BOX_CPU=2
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)"
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)"
else
inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
fi
KVM=0; [ -e /dev/kvm ] && KVM=1
@ -492,10 +492,10 @@ fi
# --- templates: the mint surface is itself a surface to test ----------------
tpl_missing=""
for t in blank claude-box codex-box grok-box kimi-box; do
for t in blank claude codex grok; do
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
done
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
|| no "templates listing is missing:$tpl_missing"
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"
@ -551,22 +551,19 @@ else
fi
# The generic mechanic (metadata, placement, user, isolation parity) is proven
# once by blank+claude-box and needs no per-template repeat. What a NEW template
# once by blank+claude and needs no per-template repeat. What a NEW template
# still has to prove is its own payload: the CLI installs, lands on the
# 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
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"
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$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
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
else
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
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
@ -586,10 +583,10 @@ for t in codex grok; do
fi
done
printf '\n minting a claude-box box (cold, ~10 min)…\n'
printf '\n minting a claude box (cold, ~10 min)…\n'
t0=$SECONDS
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
if mint_box /tmp/mint-drill.log --name drill --template claude; then
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
else
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
@ -802,7 +799,7 @@ fi
# ===========================================================================
phase "E. box expose — a deliberate loopback door (#55)"
# ===========================================================================
# archive is a running claude-box box (node is installed). Start a DETACHED
# archive is a running claude box (node is installed). Start a DETACHED
# 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
# NON-exposed port still obeys the ingress drop — the feature must not

View file

@ -30,9 +30,6 @@
# host-owned control — the scoped guarantee, measured (#75 review)
# n. a grant that fails is fail-closed: fresh user backed out (verified),
# 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
# same one drill.sh is built on (and the reason for the SC2015 disable).
@ -117,7 +114,7 @@ cleanup() {
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
echo
echo "── cleanup"
for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do
for u in "$U1" "$U2" boxdrill3 boxdrill4; do
id "$u" >/dev/null 2>&1 || continue
# A half-failed purge followed by userdel leaves a project owned by
# nobody — and doctor's leftover check keys on the USER existing. Keep
@ -202,42 +199,11 @@ fi
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 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)" \
&& 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"
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
# --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 restore mine s1 >/dev/null 2>&1 && ok "(b) box restore mine s1 (the incus 6 'snapshot restore' spelling)" || 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"
# 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).
@ -503,65 +469,6 @@ else
fi
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 " $pass passed, $fail failed"

View file

@ -117,39 +117,10 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# 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"
done
# 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
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
for net in boxnet claudenet; do
while :; do
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
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done
done

View file

@ -1,57 +0,0 @@
# 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.

View file

@ -1,82 +0,0 @@
# 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,35 +19,7 @@ NET=boxnet
# ('|| 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)"
# 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 command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; 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
else
@ -60,9 +32,7 @@ if [[ "$UFW_STATUS" == *"Status: active"* ]]; then
# 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
# rules it always did.
# 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" '
for stale in $(ufw status | awk -v net="$NET" -v gw="$GW" '
$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 udp || true

View file

@ -9,10 +9,7 @@
# 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:
# 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)
# 1. put the user in the 'incus' group (not incus-admin — that is the tier)
# 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)
# 4. restrict the project's network access to boxnet and ONLY boxnet —
@ -66,34 +63,12 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2;
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; }
# An incus-admin member gets the full convergence anyway (#99). This used to
# be a hard refusal, on the reasoning that admin membership wins at the socket
# 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
# An incus-admin member already holds the full socket; "granting" them the
# restricted tier would not restrict anything (admin membership wins at the
# socket), it would only mislead whoever reads the group list later.
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
admin_member=1
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2
exit 1
fi
# The stack the tier converges ONTO must exist first. Checked via the daemon,
@ -118,11 +93,7 @@ fi
# admin re-runs. Backing out the group closes that window completely for a
# 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
# 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.
# take on a re-run's failure.
added_group=0; was_member=0
backout() {
if [ "$added_group" -eq 1 ]; then
@ -135,16 +106,6 @@ backout() {
exit 1
fi
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
# 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
@ -161,14 +122,6 @@ backout() {
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 " 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
}
trap backout EXIT
@ -179,63 +132,17 @@ if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
else
$SUDO usermod -aG incus "$user"
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)"
fi
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
fi
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
# incus-user — an admin cannot pre-create it (incus-user would fight over
# it), so make that first contact happen now, as the user.
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "project: touching incus-user as $user to create $project..."
run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true
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 "project: $project created"
@ -298,53 +205,10 @@ echo "profile: box-net installed in $project"
# 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:
# 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 \
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
fi
# a converged project the user still cannot reach.
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; }
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 " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
fi
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)"

View file

@ -64,11 +64,7 @@ legacy_boxes() {
}
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
# 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).
# tool minted), so the new metadata is the claude template's.
rehome_one() {
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; }
@ -79,7 +75,7 @@ rehome_one() {
# 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.
incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
# 2. Stop, reassign the profile (this is the network move), restart. Incus

View file

@ -47,11 +47,7 @@ if [ "$purge" -eq 1 ]; then
if [ -z "${BOX_YES:-}" ]; 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"
# 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; }
read -r reply
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
else
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
@ -60,42 +56,14 @@ if [ "$purge" -eq 1 ]; then
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.
dropped_group=0
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
$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'"
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"
echo "group: removed $user from 'incus'"
else
echo "group: $user was not in 'incus'"
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
# 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
@ -127,27 +95,7 @@ fi
if [ "$purge" -eq 0 ]; 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)"
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"
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
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
fi
echo "revoked: $user no longer has the restricted tier."
exit 0
@ -200,23 +148,10 @@ fi
# 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
# 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=""
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"
# Leading newline so the first CSV row anchors like the `^` this replaces.
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
&& leftover="$leftover cert:incus-user-$uid"
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
&& leftover="$leftover /var/lib/incus/users/$uid"
@ -225,13 +160,4 @@ if [ -n "$leftover" ]; then
exit 1
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."
fi
echo "revoked: $user is out, and everything the tier created is gone."

View file

@ -28,21 +28,7 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
if [ "$yes" -eq 1 ]; then
echo "(confirmed non-interactively: --yes/BOX_YES)"
else
# 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; }
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
fi
@ -71,37 +57,10 @@ sudo systemctl daemon-reload
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
# each delete, so re-scan and remove the first match until none remain)
# 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
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
for net in boxnet claudenet; do
while :; do
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
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n"
done
done

View file

@ -143,39 +143,12 @@ flip_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 --------------------------------------
# 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
# 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
# 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
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
@ -191,9 +164,15 @@ if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
mkdir -p "$BINDIR"
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 " 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"
migrated_from="$flat_ver"
fi
# Whether ANY version was installed before this run — read before we add one.
# 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
# --- temp workspace --------------------------------------------------------
@ -407,15 +386,6 @@ esac
# '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
# 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_declined=""
if [ "$had_install" -eq 1 ]; then
@ -426,7 +396,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
setup_declined=1
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 " run this as root to finish: $VDIR/host/setup-host.sh"
warn " run this as root to finish: $DEST/current/host/setup-host.sh"
setup_declined=1
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
@ -434,7 +404,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.
# 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.
if bash "$VDIR/host/setup-host.sh" </dev/null; then
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
setup_ok=1
else
warn "host setup did not complete — box is installed, the host is not ready."
@ -452,12 +422,3 @@ elif [ -n "$setup_declined" ]; then
else
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
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,11 +1,7 @@
# The claude-box template — a thin, creds-free seed (#81): Debian 13, the
# The claude template — a thin, creds-free seed (#81): Debian 13, the
# '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:
# 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.
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31).
# 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.
@ -18,4 +14,4 @@ BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude-box"
BOX_BOOTSTRAP_ROLE="claude"

View file

@ -2,7 +2,7 @@
# 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 claude-box' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap claude' (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.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap claude-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap claude'. @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

View file

@ -1,11 +1,7 @@
# The codex-box template — a thin, creds-free seed (#81): Debian 13, the
# The codex template — a thin, creds-free seed (#81): Debian 13, the
# '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:
# 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.
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31).
# 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.
@ -18,4 +14,4 @@ BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex-box"
BOX_BOOTSTRAP_ROLE="codex"

View file

@ -2,7 +2,7 @@
# 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 codex-box' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap codex' (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.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap codex-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap codex'. @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

View file

@ -1,11 +1,7 @@
# The grok-box template — a thin, creds-free seed (#81): Debian 13, the
# The grok template — a thin, creds-free seed (#81): Debian 13, the
# '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:
# 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.
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31).
# 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.
@ -18,4 +14,4 @@ BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok-box"
BOX_BOOTSTRAP_ROLE="grok"

View file

@ -2,7 +2,7 @@
# 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 grok-box' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap grok' (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.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap grok-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap grok'. @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

View file

@ -1,22 +0,0 @@
# 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

@ -1,33 +0,0 @@
#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,14 +1,9 @@
# The staging-box template — a thin, creds-free, server-class seed (#81, the
# The staging 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
# server posture — docker, sshd hardening — is rig's job: box auto-runs
# '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
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet
# workload join holds a key and therefore STAYS operator-run:
# box shell <name> # then: sudo rig bootstrap workload-server --hostname <name>
# box shell <name> # then: sudo rig bootstrap workload --hostname <name>
# 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.
@ -26,4 +21,4 @@ BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1"
BOX_BOOTSTRAP_ROLE="staging-box"
BOX_BOOTSTRAP_ROLE="staging"

View file

@ -2,9 +2,9 @@
# 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,
# no sshd config, no keys. The server posture comes from
# 'rig bootstrap staging-box' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
# mint; the tailnet workload join holds a pre-auth key and stays
# operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
# #69 designed it — box never sees the key.
users:
- name: ops
@ -22,7 +22,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap staging-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap staging'. @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

File diff suppressed because it is too large Load diff

150
test/labels-reconcile.sh Normal file
View file

@ -0,0 +1,150 @@
#!/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,10 +1,22 @@
#!/usr/bin/env bash
# Box-specific release-channel coverage. Shared release/guard machinery lives
# in heavy-duty/ceremony and is tested there; this file drives real install.sh.
# The release flow (#83), proven offline. Run: bash test/release.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
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
@ -22,17 +34,105 @@ check() {
echo "ok: $desc"; PASS=$((PASS + 1))
}
NOTES="$ROOT/.github/scripts/release-notes.sh"
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"
cat > "$SHIMDIR/curl" <<'SHIM'
#!/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=""
while [ $# -gt 0 ]; do
case "$1" in
-o|--output) out="$2"; shift 2 ;;
-o|--output) out="$2"; shift 2 ;;
-w|--write-out) shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
[ -n "${FAKE_CURL_LOG:-}" ] && printf '%s\n' "$url" >> "$FAKE_CURL_LOG"
@ -51,77 +151,105 @@ chmod +x "$SHIMDIR/curl"
TAGFN="$(mktemp)"
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
ltag() {
ltag() { # ltag <redirect_url> [curl_rc]
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
}
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
check "resolve: a repo with no releases fails" 1 "" \
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases"
check "resolve: no redirect fails" 1 "" ltag ""
check "resolve: a curl failure fails" 1 "" \
check "resolve: no redirect at all fails" 1 "" ltag ""
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
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"
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
printf '9.9.9\n' > "$SRC/VERSION"
echo "9.9.9" > "$SRC/VERSION"
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
ninst() {
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
local h="$1" b="$2"; shift 2
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
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"
check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
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"
check "default channel: downloads the tag tarball" 0 "" \
check "default channel: the download is the TAG tarball" 0 "" \
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
check "default channel: it never asked for a branch" 1 "" \
grep -q "refs/heads" "$L1"
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
cat "$H1/versions/9.9.9/INSTALLED_FROM"
check "default channel: installed binary answers" 0 "box 9.9.9" \
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
env HOME="$FAKEHOME" "$B1/box" --version
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
check "pinned channel: installs the requested tag" 0 "done" \
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
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"
check "pinned channel: skips latest-release resolution" 1 "" \
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
grep -q "releases/latest" "$L2"
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
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"
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
check "dev channel: then downloads the branch" 0 "" \
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
head -1 "$L3"
check "dev channel: then the branch" 0 "" \
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"
check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
ninst "$H4" "$B4" FAKE_CURL_RC=6
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
grep -q "archive/" "$L4"
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
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
check "README documents the 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"
# ---------------------------------------------------------------------------
# The -dev convention (#83): main's VERSION carries -dev between releases, so
# a dev install lands beside releases in versions/ instead of impersonating
# 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 "$PASS passed, $FAIL failed"