Compare commits

..

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

53 changed files with 723 additions and 7639 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.

22
.github/labeler.yml vendored
View file

@ -1,22 +0,0 @@
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:cli":
- changed-files:
- any-glob-to-any-file: ["bin/**", "test/cli.sh"]
"scope:installer":
- changed-files:
- any-glob-to-any-file: ["install.sh"]
"scope:host":
- changed-files:
- any-glob-to-any-file: ["host/**"]
"scope:tiers":
- changed-files:
- any-glob-to-any-file:
["host/grant-user.sh", "host/revoke-user.sh", "drill/multiuser.sh"]
"scope:templates":
- changed-files:
- any-glob-to-any-file: ["templates/**", "profiles/**"]
"scope:drill":
- changed-files:
- any-glob-to-any-file: ["drill/**"]

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

View file

@ -8,105 +8,19 @@ 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: 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
@ -119,94 +33,27 @@ jobs:
# release still proves the boundary.
rehearsal:
runs-on: ubuntu-latest
timeout-minutes: 40
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: install incus
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
- name: global install, via install.sh itself (the #71 layout, versioned)
# install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this
# checkout, so CI proves the INSTALLER under review — the versioned
# layout, the current symlink, the PATH chain — not a hand-built
# imitation of it. Setup is run explicitly in the next step, so its
# output is its own CI section.
- name: global install, from this checkout (the #71 layout)
# Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove
# the code under review. Same tree, same layout, same a+rX.
run: |
sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f /usr/local/bin/box | grep '^/opt/box/versions/'
/usr/local/bin/box --version
/usr/local/bin/box versions
sudo cp -r . /opt/box
sudo rm -rf /opt/box/.git
sudo chmod -R a+rX /opt/box
sudo ln -sf /opt/box/bin/box /usr/local/bin/box
- name: setup-host
run: sudo bash /opt/box/current/host/setup-host.sh
run: sudo bash /opt/box/host/setup-host.sh
- name: doctor — the baseline is provable before anything is judged
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh
- name: multi-user rehearsal (criteria a-l, container mode)
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container
# The #70 round-trip, on the SAME live daemon: a box's state must
# survive 'box rm' via export → import. Container mode for the same
# reason the rehearsal uses it — export/import are backup mechanics
# (tarball out, tarball in, re-stamp), identical across instance types;
# the VM trust boundary stays a real-hardware ritual. Every assertion
# is state observed AFTER the original box was deleted: the file
# written pre-export, the snapshot, the boundary tag, a live agent.
- name: export/import round-trip — state survives 'box rm' (#70)
run: |
set -eux
sudo box new --name keeper --container
sudo box exec keeper -- sh -c 'echo survives > /home/dev/proof'
sudo box snapshot keeper pre-export
sudo box down keeper
sudo box export keeper /tmp/keeper.tar.gz
sudo test -s /tmp/keeper.tar.gz
sudo box rm keeper --force
sudo box import /tmp/keeper.tar.gz --name keeper2
test "$(sudo incus config get keeper2 user.box)" = 1
sudo incus exec keeper2 -- true
sudo box exec keeper2 -- cat /home/dev/proof | grep -qx survives
sudo incus snapshot list keeper2 --format csv | grep -q '^pre-export'
# the collision boundary, live: the name is taken, import must refuse
if sudo box import /tmp/keeper.tar.gz --name keeper2; then
echo 'collision was not refused'; exit 1
fi
sudo box rm keeper2 --force
- name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue
# The full-removal order, end to end on the real daemon: revoke a
# granted user (--purge asserts its own absence, incl. the incus-user
# state dir), tear the stack down, uninstall the tree — then assert
# NOTHING survived: no networks, profiles, ACLs, nft tables, systemd
# units, files or symlinks. The uninstall was flaky exactly because
# nobody measured this.
run: |
set -x
sudo useradd -m -s /bin/bash uninstdrill
sudo BOX_YES=1 /usr/local/bin/box grant uninstdrill
uid="$(id -u uninstdrill)"
sudo BOX_YES=1 /usr/local/bin/box revoke uninstdrill --purge
sudo test ! -e "/var/lib/incus/users/$uid"
! sudo incus project show "user-$uid"
! sudo incus config trust list --format csv | grep -q "incus-user-$uid"
# The COMBINED verb, --force only, deliberately no BOX_YES and no
# TTY: this is the exact invocation that used to die at teardown's
# own prompt when consent was not forwarded (--purge-host now
# passes --yes through under --force/BOX_YES).
sudo /usr/local/bin/box uninstall --all --purge-host --force
# zero residue: the daemon's state...
! sudo incus network show boxnet
! sudo incus profile show box-net
! sudo incus network acl show box-isolate
# ...the firewall and its boot persistence...
! sudo nft list table inet box
! sudo nft list table bridge box
sudo test ! -e /etc/systemd/system/box-firewall.service
sudo test ! -e /usr/local/sbin/box-firewall
# ...and the install itself: files AND symlinks, both name generations
sudo test ! -e /opt/box
sudo test ! -e /usr/local/bin/box
sudo test ! -L /usr/local/bin/box
sudo test ! -e /usr/local/bin/claudebox
sudo test ! -L /usr/local/bin/claudebox
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole
# surface — cold template mints, expose, migration — and wants a real host

View file

@ -1,13 +0,0 @@
name: labels
on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0

View file

@ -1,18 +0,0 @@
name: release
# Triggers and permissions MUST live here (a called workflow cannot define them):
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]
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
jobs:
release:
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
with:
version-source: file

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

@ -5,161 +5,124 @@ 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)
### Changed
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
the tenant content (#81, heavy-duty/rig#31)
### 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
## 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 +133,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 +0,0 @@
# 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.
Only triage mints issues. Everyone else opens or extends a discussion when
they find work outside an existing issue contract. Only humans merge.
## 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.
## 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`.
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.
### What a box drill proves
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).
`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.
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.
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

410
README.md
View file

@ -2,12 +2,9 @@
**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)
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
— you log in and work; destroying it loses nothing you didn't push.
ship a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
Codex), `grok` (xAI Grok). The box is the product — you log in and work;
destroying it loses nothing you didn't push.
**Strictly creds-free.** A box ships with everything installed and **no**
credentials — no agent token, no git PAT, nothing. You authenticate
@ -45,66 +42,29 @@ design rationale.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
By default that installs the **latest release** — the installer resolves the
release tag off GitHub's `releases/latest` redirect (no API, no token) and
downloads exactly that tree, so two operators running it get the same box.
If the resolution fails it says so and stops — it never silently hands out
`main`. `BOX_REF` picks another channel (a set ref is tried as a tag first,
then as a branch — [#83](https://github.com/heavy-duty/box/issues/83)):
It asks first — **"Install box?"** — then, if box is not already installed,
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
asks a second question: **"Set up this machine as a box host now?"** Say yes and
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
you can run `box setup-host` later. (No `git clone` needed.)
```sh
curl -fsSL .../install.sh | bash # the latest release (default)
curl -fsSL .../install.sh | BOX_REF=0.6.0 bash # pin a release
curl -fsSL .../install.sh | BOX_REF=main bash # the development tip
```
(A dev tree's `VERSION` carries a `-dev` suffix, so it lands beside your
releases under `versions/`, never on top of one.)
It asks first — **"Install box?"** — then downloads the tree into a
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
your `PATH`, and on a fresh host asks a second question: **"Set up this
machine as a box host now?"** Say yes and it builds the whole isolation stack
for you (it may ask for `sudo`); say no and you can run `box setup-host`
later. (No `git clone` needed.)
The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
root install):
```
versions/<version>/ one full tree per installed version
current -> versions/<v> the tracked default
$BINDIR/box -> current/bin/box the PATH entry, riding the chain
```
**Re-running is a safe converge.** Installing a version you already have
changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree);
a stray re-run can never clobber your install or rebuild the stack under your
boxes. Installing a **new** version lands it side by side and flips `current`
only when you have **no boxes** — under existing boxes the flip is refused
(never change versions under a user's boxes,
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
deliberate act: preserve what you care about — `box down <box>`, then
`box export <box>` (one portable file per box, snapshots included —
**Re-running is a safe no-op.** If box is already installed, the installer tells
you so and changes nothing — a stray re-run can never clobber your install or
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
what you have and install fresh. Preserve any boxes first — `box down <box>`,
copy out anything you need (a portable `box export` is
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box _and_ its snapshots) — then:
```sh
box versions # what is installed, which is current, which is running
box use <version> # flip the default (same refusal while boxes exist)
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
next installer run — the tree is moved, not re-downloaded, and your boxes are
untouched. After switching versions (and `box setup-host`, if the stack was
torn down), `box import <file>` brings each exported box back — snapshots,
logins and all. A version-aware upgrade that migrates boxes instead of asking
you to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
installs (CI, images), `BOX_YES=1` answers every prompt yes,
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
downloading (how CI proves the installer under review, and how the drill can
install an unpushed branch).
A version-aware upgrade that migrates boxes instead of asking you to is
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
declines the host-setup step.
### Global vs per-user install
@ -123,9 +83,7 @@ box's tree is _executed by other users_ — so it cannot hide in one user's home
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
install under `/root` would be `0700` and unreadable to everyone else — which
is exactly the bug the root branch fixes. When both tiers are installed, PATH
order decides which `box` wins — the installer warns when it sees the other
tier's tree.
is exactly the bug the root branch fixes.
## One-time host setup (Ubuntu 24.04 / Debian 13)
@ -145,24 +103,6 @@ re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
`sudo apt-get install -y dnsmasq-base`.
The stack's subnet is `10.88.0.0/24` when free. setup-host **never builds on
a subnet something else already claims** — most tellingly when this machine's
own default gateway sits inside it, which means it is being run *inside a
box*: a nested `boxnet` on the guest's own uplink subnet captures its gateway
address and blackholes the guest's egress in intermittent,
maddening-to-attribute blackouts
([#80](https://github.com/heavy-duty/box/issues/80)). Instead of refusing, a
bare `box setup-host` decides for itself: an existing `boxnet` bridge is
converged on as-is (the bridge is the pin — it is never re-addressed), and a
claimed default triggers an auto-pick of the first free `/24` from
`10.89.0.0/24` through `10.127.0.0/24`, announced loudly — so drills and
rehearsals *inside a box* work with zero flags. `BOX_SUBNET=<a.b.c.0/24>`
pins the subnet explicitly for scripted hosts (the bridge address, the ACL's
gateway carve-out and the firewall all derive from it); a pin is honored or
refused, never silently overridden. `box doctor` recognizes the poisoned
state (a gateway held as a local address, duplicate uplink routes) on the
machine it runs on and inside every box it probes.
A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes`
re-homes each legacy box onto `boxnet` (authed state preserved), and
`box migrate-host --retire-legacy` removes the old bridge and profile once no
@ -198,8 +138,7 @@ user to a private _unhardened_ NAT bridge, it blocks snapshots, and it cannot
see the `box-net` profile. Granting rewires all three: the user's project is
restricted to `boxnet` **and only boxnet** — the hardened network is not their
default placement but the only one their certificate can express — snapshots
and backups are allowed (the clone and `box export` workflows), and the
shipped profile is installed into their project. Re-run
are allowed, and the shipped profile is installed into their project. Re-run
`box grant <user>` after upgrading box to refresh the profile, like
`setup-host` for the stack.
@ -217,14 +156,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,68 +177,23 @@ 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's in it |
| -------- | --------------------------------------------------------- |
| `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 |
**Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
directory under `templates/`: a `box.env` (image, user, resources, boot
demands, tenant role — parsed against a strict allowlist, never sourced) and
a `user-data.yaml` (cloud-init, passed to Incus verbatim except the two rig
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,
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>` →
`sudo rig bootstrap <role>`). The agent-context file carries the
[#80](https://github.com/heavy-duty/box/issues/80) guard — never run
`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
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept).
**The rig pin point** (`RIG_REPO` / `RIG_REF`). The seeds preinstall rig,
which inverts the rig→box install edge
([rig#28](https://github.com/heavy-duty/rig/issues/28): rig installs box on
host-class machines; box guests now install rig). The seed's install line
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
RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude-box # a rig branch under review
```
Both directions of that edge track `main` unpinned today — said honestly,
the same way rig documents box's unpinned install
([rig#29](https://github.com/heavy-duty/rig/issues/29)) — until the release
flow lands ([rig#32](https://github.com/heavy-duty/rig/issues/32),
[#83](https://github.com/heavy-duty/box/issues/83)). The pin covers both the
installer fetched and the tree it installs, and the values are
allowlist-validated on the host before they touch the YAML.
A template is a directory under `templates/`: a `box.env` (image, user,
resources — parsed against a strict allowlist, never sourced) and a
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
templates are all the same shape — install the CLI, put it on PATH, drop an
agent-context file; none of them carry credentials.
```sh
box templates # list what this install can mint
box new --name scratch # the DEFAULT template is blank: bare Debian,
# same isolation, nobody home — no rig, no role
# same isolation, nobody home
```
A template **cannot** name a network, a profile, or a `security.*` flag —
@ -325,124 +218,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
snapshots. `box new --from` clones — but the clone still lives on the same
host, under the same stack. `box export` is the way out
([#70](https://github.com/heavy-duty/box/issues/70)): one portable file that
outlives the box, the host stack, and the machine.
```sh
box down work # export wants a settled disk
box export work # → work-<UTC stamp>.tar.gz, snapshots included
box rm work # nothing is lost anymore
# ...upgrade box / rebuild the host / carry the file to another machine...
box import work-<stamp>.tar.gz # the box is back — snapshots, logins and all
box import work-<stamp>.tar.gz --name work2 # or under a new name
```
This is what makes the upgrade flow humane
([#66](https://github.com/heavy-duty/box/issues/66)): stop, export, remove
every box, upgrade, re-import. Everything `incus import` restores is the
artifact's truth (disk, config, snapshots); what box re-stamps on import is
_this_ host's truth — the `user.box=1` boundary tag, the `box-net` placement
(re-assigned if the artifact's differs), and a fresh machine identity, the
same move a clone gets, so an imported box can never collide with the box it
was exported from. Import refuses a name any existing instance already holds.
`--instance-only` exports the live state without the snapshots.
**The file is a credential.** A box's disk carries everything inside it —
agent logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image would be a lie) and shouts instead, every time. Store
and move the file like the secret it is.
## See a dev server: `box expose`
The isolation contract says no inbound path exists — which is one "no" too
@ -469,21 +249,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 export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>]
# mint a box back from an exported file, re-stamped
box restore <box> <snap> # roll back to a snapshot
box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box
@ -510,82 +281,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
@ -692,29 +387,12 @@ documentation, not a host-executed script. See
## Uninstall
`box uninstall` is the real uninstall, and it runs in the safe order — boxes
first, then the stack, then the tree — and **ends with an absence assert**:
every path it removed is re-checked, and any survivor makes it exit 1 naming
the leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as `box revoke --purge`).
```sh
box uninstall <version> # one non-current version (side-by-side cleanup)
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
# boxnet stack, the firewall), then every
# version, the symlinks, legacy claudebox crumbs
box uninstall # just the install — refuses while boxes exist
# (and names them); run teardown-host first,
# or use --purge-host
box teardown-host # boxes, network, ACL, profile, firewall
box teardown-host --purge-incus # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
```
The full-removal order on a multi-user host: `box revoke <user> --purge` each
granted user (it asserts its own zero-residue, including the incus-user state
under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus`
to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then
`box uninstall`. CI drills exactly this sequence and asserts zero residue —
no networks, profiles, nft tables, systemd units, files or symlinks.
## Non-goals
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask

View file

@ -1 +1 @@
0.9.1-dev
0.6.0

1456
bin/box

File diff suppressed because it is too large Load diff

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,82 +43,13 @@ 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
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
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.
## Thin templates: box mints, rig converges (#81)
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 —
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
`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
template, ever again.
## The box announces itself to the agent
Every coding-agent box gets a global agent-context file
cloud-init installs a global agent-context file in every coding-agent box
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
execution. The file is rendered by rig's tenant roles from one shared
template (#81) — including the #80 guard: never run `box setup-host`,
`box teardown-host` or the drill inside a box; a nested box stack claims the
guest's own uplink subnet and silently breaks its networking.
execution.
## `.box/` is optional, agent-facing documentation

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

@ -1,134 +0,0 @@
# box export / import — a box's state that survives the box and the host (#70)
**Status: implemented.** Asked for by @danmt on #66, as the prerequisite for
the upgrade flow the installer wants to enforce: _stop, export, remove every
box before you upgrade — then re-import_. This doc records the design
decisions, the facts they rest on, and what holds them.
## The gap #70 named
Nothing a box held could outlive a host teardown:
- `box snapshot` is an **in-box** checkpoint, and `box rm` deletes the box
and every snapshot it has — a snapshot cannot outlive its box.
- `box new --from` clones to an independent box, but the clone still lives
**on the same host**, under the same stack. It is not an artifact you can
carry off the machine or keep across a teardown.
So #66's refusal-to-upgrade-over-live-boxes could only say "copy things out
by hand" — honest, but lossy. `box export` upgrades that instruction to
"export, keep the file, re-import after".
## The shape
`incus export` / `incus import` are the primitives: a backup tarball of an
instance and (by default) its snapshots, and instance creation from that
tarball. box wraps them where it must enforce what incus cannot see.
- **`box export <box> [<file>]`** — the box must be tagged `user.box=1` (the
boundary, as everywhere) and **stopped**. Default filename
`<box>-<UTC stamp>.tar.gz` (sortable, collision-free, and it answers the
question you will ask the file later: _when is this state from?_).
Snapshots ride along by default; `--instance-only` opts out, passed to
incus verbatim. Refuses to overwrite an existing file without `--force`.
- **`box import <file> [--name <box>]`** — reads the artifact's own instance
name from `backup/index.yaml` up front, refuses any name an existing
instance already holds (box or not — `resolve_box`'s boundary from the
other side), pre-flights the stack (`require_stack`, factored out of
`cmd_new` now that it has two callers), imports, then re-stamps, starts,
and hands over.
## The three decisions, and why they fell where they did
**1. Require `box down` first — no live export, no snapshot-then-export.**
Incus _can_ back up a running instance, but a live root disk is a moving
target, and this artifact's entire job is to be trusted later, on a host
that no longer has the box to compare against. The refusal reuses
`require_stopped` with an honest reason parameter: rename is stopped because
_incus_ insists; export is stopped because _we_ decided — the message should
not claim otherwise.
**2. Snapshots included by default.** box's reuse workflow (log in once,
snapshot, clone forever) lives in snapshots; an artifact that quietly
dropped the authed checkpoint would defeat the verb's purpose. The opt-out
is explicit and named for what it does (`--instance-only`).
**3. Credentials: shout, don't scrub.** A box's disk carries agent logins,
git PATs, SSH keys, shell history, deleted-but-unwiped blocks. "Scrubbing" a
disk-image tarball is a promise no tarball surgery can keep, and handing
someone a file labeled sanitized that is not would be worse than the risk it
hides. So export prints a loud, unconditional stderr warning: the file _is_
a credential; store and move it as one. Import repeats the point — auth
state came back by design, the same trust boundary as cloning an authed
snapshot.
## Import re-stamps the host's truth, not the artifact's
The split is the design. Everything `incus import` restores is the
artifact's truth: disk, config, devices, snapshots. Everything box then
re-stamps is the current host's:
- **The boundary tag.** `user.*` keys ride inside the artifact, so a box
export brings `user.box.template` / `user.box.user` back on its own, and a
legacy `user.claudebox=1` stays honored as it is everywhere else. An
instance carrying neither tag is stamped `user.box=1` — importing is
minting, and a minted box is ours to manage.
- **The 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 is re-assigned (`incus profile
assign` — the same move `migrate-host` makes re-homing a legacy box). An
artifact naming a profile the host lacks fails inside `incus import` with
incus's own error naming it. A fresh host without the stack at all is
refused before the import, tier-aware (`require_stack`: admins are sent to
`setup-host`, restricted users to `box grant`).
- **The identity — host side and guest side.** The artifact's `volatile.*`
config comes back verbatim, _including the NIC's MAC_: importing an
artifact twice, or beside the box it was exported from, collided at start
with `MAC address already defined on another NIC` (measured live on Incus
6.0.4 — `incus copy` regenerates the MAC on clone; `incus import` does
not). So import unsets every volatile hwaddr before the start and lets
incus mint fresh ones. Then, in-guest: the artifact's machine-id rides in
its disk, and `reset_identity` runs before handover, exactly like a clone
— machine-id → DHCP client-id → lease, the collision that function's
comment documents. Verified live: two imports of one artifact running side
by side with distinct MACs, distinct machine-ids, both holding the
pre-export file and snapshot.
## The restricted tier: measured, then converged
`incus export` rides the backup API (an export _is_ "create a backup,
download it, delete it"), and a restricted project blocks it by default:
`restricted.backups=block` the moment `restricted=true` — read from incus
6.0's `internal/server/project/permissions.go` (the default table, and
`AllowBackupCreation` enforcing it). Import needs no key of its own:
restoring a backup file is plain instance creation.
So the honest answer was not an `expose`-style refusal — the limitation is a
project key, not daemon-global state — but the same convergence grant
already performs for snapshots: `box grant` now also sets
`restricted.backups allow`. Re-run `box grant <user>` after upgrading, as
the grant contract already says.
## What holds it
- **`test/cli.sh`** (dependency-free, no incus): driven usage errors
(missing box/file/name-value, unknown box, missing file, a non-artifact
file refused by the pure tar+awk parse), and grep/line-order guards for
every daemon-gated invariant — `require_stopped` before `incus export`,
snapshots-by-default, the credential shout, `user.box=1` re-stamping, the
collision guard before `incus import`, `require_stack` in both `cmd_new`
and `cmd_import`, `reset_identity` after the start, and grant's
`restricted.backups allow`. All fail-closed: a deleted guard cannot ship
green.
- **CI's `rehearsal` job**, on the runner's live Incus (container mode — the
round-trip is backup mechanics, identical across instance types; the VM
boundary stays a real-hardware ritual): mint → write a file → snapshot →
down → export → `rm` → import under a new name → assert the tag, the
agent, the file, the snapshot survived, and that a colliding re-import is
refused.
## Related
- #66 — the installer refusal this makes humane (its message is reconciled
when both land).
- #67 — the version-aware upgrade that would migrate instead of asking.

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

@ -1,154 +0,0 @@
# Versioned installs, and a real uninstall (0.7.0 core)
**Status: implemented and tested.** 154/154 in `test/cli.sh` (which now
*drives* real installer runs, not greps of them), shellcheck clean, and CI's
rehearsal job installs via `install.sh` itself and ends with a zero-residue
uninstall drill on a live Incus. This doc records the design and why each
decision fell where it did.
## What was asked
Two maintainer requests, one PR:
1. **Install should be versioned** — each box version goes to its own folder
and a tracked default names the one you run, like plenty of CLIs manage
theirs. Before this, `install.sh` refused to touch an existing install at
all: changing versions meant uninstalling by hand (`rm -rf` two paths from
the README) and re-running the installer.
2. **Uninstall is flaky** — there was no uninstall verb at all, only prose;
`teardown-host.sh` deliberately leaves the install tree; and nothing
encoded the safe full-removal order (revoke users → teardown-host → remove
trees/symlinks). Add thorough tests for uninstall, and for grant/revoke
that they are clean.
## The layout
```
<root>/ /opt/box (root) or ~/.local/share/box (user);
BOX_HOME overrides — both unchanged from #71
versions/<version>/ one full tree per version, each with its own
VERSION + INSTALLED_FROM
current -> versions/<v> the tracked default (a relative symlink, so the
root can move as a unit)
$BINDIR/box -> <root>/current/bin/box
```
The version key is the tree's own `VERSION` file — the identity of what was
installed, and the name `box versions` lists. `bin/box` needed **no change**
to run from here: line 8 already derives `$root` via `readlink -f`, which
resolves the whole `$BINDIR/box → current → versions/<v>` chain, so
`VERSION`, `templates/`, `host/` and `drill/` all resolve inside the version
tree that is actually running. That same fact is how the new verbs detect
their world: a versioned install always runs from `.../versions/<v>`; a git
checkout does not, and the verbs refuse instead of uninstalling somebody's
working copy.
## Install semantics (#66's stance, kept — at the flip)
#66 established: a stray installer re-run must never clobber a working
install or rebuild the stack under existing boxes. The old enforcement was a
blanket "refuse if anything is installed", which also blocked upgrades. The
versioned layout splits the two concerns:
- **Same version present** → converging no-op ("already installed", exit 0);
`BOX_REINSTALL=1` replaces that version's tree via two renames (never a
partial overlay). A converge/reinstall of a non-current version never
moves the default — switching is `box use`, a deliberate act.
- **Different version** → installs side-by-side, then flips `current` **only
when no boxes exist**. With boxes present (both tag generations, checked at
the caller's tier via a shared `existing_boxes()` — byte-identical in
`install.sh` and `bin/box`, diffed by the tests so the two #66 stances
cannot drift), the flip is refused loudly, the boxes are *named*, and the
operator is pointed at the remedy: down/copy-out/rm, then `box use <v>`.
A daemon that is absent or not answering has no boxes to protect — the
stance guards boxes, not daemons.
- **Pre-0.7.0 flat tree** → migrated before anything else: `mv` the root
aside, `mkdir versions/`, `mv` it to `versions/<its-VERSION>`, link
`current` and `$BINDIR/box`. Two renames inside one parent directory — no
copy, no window with no install, the operator's tree preserved bit for bit
(the tests assert the migrated tree's own `INSTALLED_FROM` survives).
- **Wedged symlinks** → healed, never trusted. The old no-op check keyed off
`$BINDIR/box` *or* `$DEST/bin/box` existing, so a stale symlink (or a
half-removed tree) faked "already installed" forever. Installed-ness is now
judged from `versions/<v>` itself; `ln -sfn` converges the links.
- **Tier coexistence** → a root and a per-user install shadow each other by
PATH order alone; the installer warns when it sees the other tier's tree.
- Host setup is offered on **fresh** hosts only — an upgraded host has made
that decision (and may have live boxes the stack must not be rebuilt
under); `box setup-host` re-applies stack changes deliberately.
`BOX_INSTALL_SOURCE=<dir-or-tarball>` bypasses the download (a directory is
tar-copied with `--exclude=.git`). This exists for CI and the drill — the
code under review is what lands — and it is what turned the test suite's
install coverage from greps into real runs.
## The new verbs
Table rows like every other verb (the CMDS table stays the single source of
truth); `uninstall` joins the host-verb flag passthrough so `--all` /
`--purge-host` reach it.
- `box versions` — lists `versions/*`, marking the current default and the
tree answering the command (they differ when another install shadows yours
on PATH).
- `box use <version>` — same existing-boxes refusal as the installer's flip
(shared helper, boxes named), then repoints `current`, converges every PATH
symlink that resolves into this install root (never one that is somebody
else's), and **asserts the effective result**: `current` must resolve to
the asked-for version and `current/bin/box --version` must answer it. A
flip that "worked" while the operator still runs the old tree is exactly
the flakiness this verb exists to end.
- `box uninstall [<version>] [--all] [--purge-host]`
- one version: refuses the current one; removes the dir; re-checks it.
- full: the safe order — refuse while boxes exist (naming them) unless
`--purge-host` runs `teardown-host.sh` first (its own confirmation; a
note names granted users' surviving projects and `revoke --purge` as the
clean path); confirm (`--force` / `BOX_YES=1` — installer-family consent,
deliberately *not* the lifecycle `confirm()`, which must never
auto-accept from the environment); gather the removal set (root, every
PATH symlink pointing into it, claudebox crumbs of both name
generations); remove; then **the absence assert**: every path re-checked
for file/dir/symlink existence, any survivor → exit 1
`uninstall INCOMPLETE` naming the leftovers. `rm`'s exit code is not the
verdict — the re-check is (a half-removed tree is INCOMPLETE, not a
crash).
## Grant/revoke cleanliness
Reading `revoke-user.sh` against its own absence assert found the gap: the
purge removes `/var/lib/incus/users/<uid>` but never re-checks it — and the
stat was a bare `[ -d ]`, which lies for a non-root admin (`/var/lib/incus`
is not traversable, so the directory reads as absent while it is there).
Both fixed: the check rides `$SUDO test -d`, and the absence block now covers
the state dir. Grepped-and-guarded in `test/cli.sh`; drilled live in CI.
## Tests (the heart of this PR)
`test/cli.sh` stays dependency-free, non-root, daemon-free. New machinery: a
fake `incus` on PATH whose `list` prints `$FAKE_BOXES`, throwaway
`BOX_HOME`/`BOX_BIN` roots, and fabricated second/third sources with
different `VERSION`s. Driven end to end: fresh layout + chain
(`box --version` through both symlinks), no-op/canary, `BOX_REINSTALL`,
side-by-side + no-boxes flip, all three #66 refusals (install flip, `use`,
`uninstall` — boxes named, remedies named), `versions` markers, `use`
flip-and-assert, flat-tree migration (alone, and combined with an upgrade),
dangling- and stale-symlink healing, single-version uninstall (current
refused), full uninstall with planted legacy crumbs and a zero-residue
assert (files *and* symlinks *and* legacy names), the INCOMPLETE scream
(a chmod-pinned survivor), and refusals from a working tree. The existing
DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged).
CI's rehearsal job now installs via `install.sh`
(`BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE`), asserts the layout it left, runs
the stack from `/opt/box/current/...`, and appends the uninstall drill:
grant + `revoke --purge` a throwaway user (asserting the incus-user state
dir is gone), `teardown-host` (new `--yes`/`BOX_YES` support), `box
uninstall --all`, then zero residue — networks, profiles, ACLs, nft tables,
systemd units, files, symlinks, both name generations.
## What this is not
- Not #67: boxes still do not migrate across versions — this PR delivers the
version-agnostic upgrade *path* (side-by-side installs, an explicit flip);
data migration remains #67.
- Not a release: `VERSION` is untouched (a release PR bumps it).

View file

@ -35,108 +35,7 @@ no() { printf ' \033[31mDIRTY\033[0m %s\n' "$*"; bad=$((bad + 1)); }
inf() { printf ' %s\n' "$*"; }
head_() { printf '\n\033[1m%s\033[0m\n' "$*"; }
# --- The #80 signature: a nested box stack squatting on the gateway ---------
# setup-host run INSIDE a box builds a nested boxnet on the guest's own uplink
# subnet. The measured mechanism, and the two lines this function reads for:
# hold your own gateway's address and the kernel's local table eats packets
# meant for the real gateway (DNS, unicast DHCP renewals); carry two connected
# routes for the uplink subnet and whichever link last has carrier wins — a
# nested bridge gaining carrier blackholes egress instantly. Pure text in,
# findings out (one per line, silence is clean), so test/cli.sh drives it
# against synthetic route tables and the guest probe can feed it routes read
# INSIDE a box. Inputs: `ip -4 route show` and `ip -4 -o addr show` output.
gw_squat_signature() {
local routes="$1" addrs="$2" gw updev
gw="$(printf '%s\n' "$routes" | awk '$1 == "default" { for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }')"
updev="$(printf '%s\n' "$routes" | awk '$1 == "default" { for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
[ -n "$gw" ] || return 0 # no default route: nothing to squat on
printf '%s\n' "$addrs" | awk -v gw="$gw" '
{ split($4, a, "/")
if (a[1] == gw) { print "the default gateway " gw " is held as a LOCAL address (on " $2 ") — the kernel delivers packets meant for the gateway to this machine itself"; exit } }'
printf '%s\n' "$routes" | awk -v updev="$updev" '
/ proto kernel / && $2 == "dev" {
cnt[$1]++; devs[$1] = devs[$1] (devs[$1] ? ", " : "") $3
if ($3 == updev) up = $1
}
END { if (up != "" && cnt[up] > 1)
print "duplicate connected routes for the uplink subnet " up " (" devs[up] ") — whichever link last gains carrier wins, and a nested bridge with carrier blackholes egress" }'
}
# --- The UFW half of the gateway carve-out (#86 review) ---------------------
# setup-host converges the ACL's gateway allow and box-firewall converges
# UFW's — but a doctor that reads only the ACL hands a remapped UFW host a
# clean bill while a stale 'allow … to <old-gw> port 53' quietly drops
# box→gateway DNS. This reads UFW's own table. Pure text in (`ufw status`
# output, the network, the live gateway), findings out (one per line,
# silence is agreement) — the gw_squat_signature seam, so test/cli.sh drives
# it against canned tables. Judged only where UFW is active: the no-UFW nft
# carve-out is interface-scoped (no gateway address to go stale).
ufw_dns_findings() {
local status="$1" net="$2" gw="$3" allows stale
allows="$(printf '%s\n' "$status" | awk -v net="$net" '
$2 ~ /^53\// && $3 == "on" && $4 == net { print $1 }' | sort -u)"
if [ -z "$allows" ]; then
# No DNS allow at all is a drop only if OUR deny is there to do the
# dropping — a UFW host box-firewall never touched has nothing to judge.
printf '%s\n' "$status" | grep " on $net" | grep -q "DENY" \
&& printf 'UFW denies in on %s with NO DNS allow at all — box DNS to the gateway is dropped\n' "$net"
return 0
fi
if ! printf '%s\n' "$allows" | grep -qxF "$gw"; then
printf "UFW's DNS allow points at %s — NOT %s's live gateway (%s): box DNS dies at UFW's deny\n" \
"$(printf '%s' "$allows" | tr '\n' ' ')" "$net" "$gw"
return 0
fi
stale="$(printf '%s\n' "$allows" | grep -vxF "$gw")" || true
[ -n "$stale" ] \
&& printf "stale UFW DNS allow(s) for %s left beside the live gateway's — box-firewall converges these away now\n" \
"$(printf '%s' "$stale" | tr '\n' ' ')"
return 0
}
# The signature, probed INSIDE a box: its routes, read where they live. A
# poisoned guest looks healthy from every host-side config check — the nested
# bridge and the captured gateway exist only in the guest's kernel.
probe_sig() {
local b="$1" routes addrs sig line
routes="$(timeout -k 5 20 incus exec "$b" -- ip -4 route show </dev/null 2>/dev/null)"
addrs="$(timeout -k 5 20 incus exec "$b" -- ip -4 -o addr show </dev/null 2>/dev/null)"
if [ -z "$routes" ]; then
inf "could not read routes inside '$b' — the #80 signature was not probed"
return 0
fi
sig="$(gw_squat_signature "$routes" "$addrs")"
if [ -n "$sig" ]; then
while IFS= read -r line; do no "inside '$b': $line"; done <<<"$sig"
inf "a box stack was installed INSIDE this box — its nested bridge claims the"
inf "box's own uplink subnet, and egress blacks out intermittently (issue #80)."
inf "fix, inside the box: sudo incus network set boxnet ipv4.address 10.89.0.1/24"
inf " (or remove the nested stack there: box teardown-host)"
else
ok "no #80 signature inside '$b' — nothing is squatting on its gateway"
fi
}
command -v incus >/dev/null || { echo "doctor: incus is not installed on this host."; exit 1; }
# THIS MACHINE first, both tiers, before anything that needs the daemon: the
# #80 signature is a fact about the kernel's routing tables, not about incus —
# and a poisoned guest is exactly where the daemon answering below may be the
# WRONG (nested) one, judging its own impostor stack clean.
head_ "This machine — is a nested box stack squatting on the gateway? (#80)"
sig="$(gw_squat_signature "$(ip -4 route show 2>/dev/null)" "$(ip -4 -o addr show 2>/dev/null)")"
if [ -n "$sig" ]; then
while IFS= read -r line; do no "$line"; done <<<"$sig"
inf "a box stack was built on a machine whose uplink already owns its subnet —"
inf "run inside a box, that is issue #80: egress blacks out intermittently while"
inf "everything looks healthy. setup-host now auto-picks a free subnet for the"
inf "nested case, so this stack predates the fix (or was pinned onto the uplink)."
inf "fix: move the nested bridge off the uplink's subnet:"
inf " sudo incus network set boxnet ipv4.address 10.89.0.1/24"
inf " (or remove the nested stack: box teardown-host)"
else
ok "the default gateway is not held locally, and the uplink subnet has one connected route"
fi
timeout 10 incus list >/dev/null 2>&1 || {
echo "doctor: the incus daemon is not answering (see issue #26 for recovery):"
echo " sudo pkill -9 -f 'incusd shutdown'"
@ -186,21 +85,12 @@ if [ "$TIER" = restricted ]; then
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
if [ -n "$probe" ]; then
inf "probing inside '$probe':"
if timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null; then
routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine"
else
routing=0; no "cannot reach 1.1.1.1 by address — egress routing is broken (an admin problem: box doctor as admin)"
fi
if timeout -k 5 25 incus exec "$probe" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
ok "resolves deb.debian.org — DNS works"
# Egress broken while DNS resolves is #80's fingerprint: an impostor
# dnsmasq on a captured gateway address answers names happily (it
# forwards upstream via the default route) while direct IP egress dies.
[ "$routing" = 0 ] && inf "…egress broken while DNS resolves is #80's fingerprint — the signature probe below answers whether something inside this box squats on its gateway"
else
no "CANNOT resolve deb.debian.org — an admin problem (the resolver pin lives on the host): box doctor as admin"
fi
probe_sig "$probe"
timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null \
&& ok "reaches 1.1.1.1 by address — egress routing is fine" \
|| no "cannot reach 1.1.1.1 by address — egress routing is broken (an admin problem: box doctor as admin)"
timeout -k 5 25 incus exec "$probe" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1 \
&& ok "resolves deb.debian.org — DNS works" \
|| no "CANNOT resolve deb.debian.org — an admin problem (the resolver pin lives on the host): box doctor as admin"
else
inf "no running box to probe with (mint one: box new --name work)"
fi
@ -276,26 +166,6 @@ else
inf " (or: sudo systemctl restart box-firewall.service)"
fi
# The UFW blind spot: the ACL check further down compares the ACL's carve-out
# to the live gateway, but on a UFW host the SAME stale-carve-out failure can
# live in UFW's own table — and did, invisibly (#86 review). Judge it here,
# from `ufw status`, wherever UFW is the active firewall and a bridge exists
# to compare against (a fresh host has neither).
if command -v ufw >/dev/null 2>&1; then
ufw_out="$(sudo ufw status 2>/dev/null)"
ufw_gw="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1)"
if printf '%s\n' "$ufw_out" | grep -q "Status: active" && [ -n "$ufw_gw" ]; then
findings="$(ufw_dns_findings "$ufw_out" boxnet "$ufw_gw")"
if [ -n "$findings" ]; then
while IFS= read -r line; do no "$line"; done <<<"$findings"
inf "the bridge moved (#80's escape hatch) and UFW did not follow"
inf "fix: sudo /usr/local/sbin/box-firewall (it converges the UFW allows off the live bridge now)"
else
ok "no stale UFW DNS carve-out — box DNS to the gateway ($ufw_gw) survives UFW"
fi
fi
fi
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
# pre-rename ancestor and may linger while legacy boxes still reference it.
# Check whichever exist — an unisolated NIC is a fault on either.
@ -339,22 +209,6 @@ if incus network acl show box-isolate >/dev/null 2>&1; then
no "an @internal rule survived phase D"
[ "$FIX" = 1 ] && { incus network acl rule remove box-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
fi
# The gateway carve-out must track the BRIDGE. #80's escape hatch moves
# boxnet off a colliding subnet — and the stale /32 then strands box DNS
# inside the 10.0.0.0/8 drop, which presents as a dead resolver, never as
# a stale ACL. Compare the allow rule to boxnet's actual gateway.
gwaddr="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1)"
carve="$(incus network acl show box-isolate 2>/dev/null \
| awk '/- action: allow/ { hit = 1; next } hit && /destination:/ { sub("/32", "", $2); print $2; exit } { hit = 0 }')"
if [ -n "$gwaddr" ] && [ -n "$carve" ]; then
if [ "$carve" = "$gwaddr" ]; then
ok "the gateway carve-out matches boxnet's gateway ($gwaddr) — box DNS survives the 10/8 drop"
else
no "the gateway carve-out ($carve/32) does NOT match boxnet's gateway ($gwaddr) — box DNS to the gateway dies inside the 10.0.0.0/8 drop"
inf "the bridge moved (#80's escape hatch) and the ACL did not follow"
inf "fix: BOX_SUBNET=${gwaddr%.*}.0/24 box setup-host (it converges the ACL now)"
fi
fi
else
inf "box-isolate does not exist (a fresh host)"
fi
@ -475,11 +329,6 @@ if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
if timeout -k 5 25 incus exec "$probe" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
ok "resolves deb.debian.org — DNS works"
# The OTHER split from the one below: egress broken while DNS resolves is
# #80's fingerprint — an impostor dnsmasq on a captured gateway address
# keeps answering names (it forwards upstream via the default route)
# while direct IP egress dies. The signature probe underneath answers it.
[ "$routing" = 0 ] && inf "…egress broken while DNS resolves is #80's fingerprint — see the signature probe below"
else
no "CANNOT resolve deb.debian.org — this is exactly what kills cloud-init on every cold mint"
# Egress by address was probed above. If it worked, the fault is purely
@ -492,8 +341,6 @@ if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
inf "…and it cannot reach 1.1.1.1 by address either — so egress itself is broken, not just DNS."
fi
fi
probe_sig "$probe"
else
inf "no box to probe with (mint one, or run without --fix after a run)"
fi

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
@ -94,17 +94,14 @@ in_box() {
# · and the interface is NOT called eth0. The PROFILE names the device eth0,
# but inside a VM guest predictable naming renames it enp5s0. Six runs of
# A3 "not probed" were this, not the network.
# So: read it from inside the box, and select by SUBNET (what boxnet hands
# out — read off the network, never hardcoded: BOX_SUBNET moves it, #80)
# rather than by interface name — docker0 (172.17.x) is the decoy, and the
# NIC's name is the guest's business, not ours.
boxnet_gw() { incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1; }
# So: read it from inside the box, and select by SUBNET (10.88.x, what boxnet
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
# and the NIC's name is the guest's business, not ours.
boxnet_ip() {
local b="$1" ip _i pfx
pfx="$(boxnet_gw)"; pfx="${pfx%.*}."
local b="$1" ip _i
for _i in $(seq 1 15); do
ip="$(in_box "$b" ip -4 -o addr show scope global \
| awk -v p="$pfx" '{ for (i = 1; i < NF; i++) if ($i == "inet" && index($(i+1), p) == 1) { split($(i+1), a, "/"); print a[1]; exit } }')"
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.88\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
sleep 2
done
@ -205,11 +202,10 @@ EOF
export BOX_SKIP_SETUP_HOST=1
fi
# The installer converges when a version is already installed (0.7.0's
# versioned layout: re-running the same version is a no-op, and an upgrade
# lands side-by-side without flipping under boxes). The drill re-proves a
# tree from SCRATCH every run — a fresh host, not a converged one — so it
# removes the whole install root and symlink first.
# The installer is a no-op when box is already installed — upgrading is
# uninstall-then-install, by design. The drill re-proves a tree from scratch
# every run, so it does the uninstall itself: clear any prior tree and symlink
# before installing, or install.sh would correctly refuse to touch them.
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
@ -228,7 +224,7 @@ EOF
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
# the wrong tree while reporting success. A drill that silently drills the
# wrong code is worse than one that fails.
got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
if [ "$got" != "$REPO@$REF" ]; then
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
@ -306,7 +302,7 @@ if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then
echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2
echo " is supposed to converge in one run (#63). One of those did not happen." >&2
echo " reproduce with the output visible:" >&2
echo " ~/.local/share/box/current/host/setup-host.sh" >&2
echo " ~/.local/share/box/host/setup-host.sh" >&2
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
exit 1
fi
@ -365,7 +361,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
# proves is idempotency: a second run over a cleaned host is a no-op that
# restores the stack rather than a fresh build.
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
echo " it should take seconds on a host that already has incus. usual causes:" >&2
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
@ -378,7 +374,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 +382,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
@ -473,7 +469,7 @@ phase "B. The box surface"
# ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number —
# a pinned literal here would fail the drill on every release.
expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')"
expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')"
v="$(box --version 2>&1)"
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
@ -492,17 +488,17 @@ 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"
# The one rule that keeps templates honest: no key can name a network. Plant a
# bad template in the installed tree (the drill owns this host), expect the
# parser to reject it BY NAME, remove it.
badt="$HOME/.local/share/box/current/templates/cbdrill-bad"
badt="$HOME/.local/share/box/templates/cbdrill-bad"
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
@ -551,22 +547,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 +579,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
@ -699,11 +692,10 @@ fi
# nothing serves and read refused-vs-dropped — refused would mean the box's
# packet reached the host's stack, which is the thing the firewall must prevent.
# (No background listener: one less process to leak, one less way to wedge.)
gw="$(boxnet_gw)"
hv="$(box_probe archive "http://$gw:8099")"
hv="$(box_probe archive http://10.88.0.1:8099)"
case "$hv" in
reachable|refused)
no "THE BOX'S PACKETS REACH THE HOST on $gw:8099 [$hv] — the firewall rules are not holding"
no "THE BOX'S PACKETS REACH THE HOST on 10.88.0.1:8099 [$hv] — the firewall rules are not holding"
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
dropped)
ok "box → host is blocked (no path to the machine's sockets)"
@ -754,7 +746,7 @@ elif [ -n "$PEER_IP" ]; then
fi
else
no "could not read peer's boxnet address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no boxnet address on peer)"
aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
fi
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
@ -802,7 +794,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
@ -897,7 +889,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)"
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
# migrate-host.sh moves it onto the new stack with its identity intact, and
# retires the legacy stack only once it is empty.
MIG="$HOME/.local/share/box/current/host/migrate-host.sh"
MIG="$HOME/.local/share/box/host/migrate-host.sh"
if [ ! -f "$MIG" ]; then
no "migrate-host.sh not installed — cannot drill the transition"
else
@ -943,7 +935,7 @@ else
|| no "migrate: legacy box is NOT on box-net"
lip="$(boxnet_ip legacybox)"
[ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \
|| no "migrate: legacy box has no boxnet address — the move did not take"
|| no "migrate: legacy box has no 10.88 address — the move did not take"
in_box legacybox getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "migrate: re-homed box resolves + reaches the internet on its new leg" \
|| no "migrate: re-homed box cannot resolve on boxnet"
@ -993,5 +985,5 @@ fi
echo
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)"
inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]"
[ "$fail" -eq 0 ]

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).
@ -108,16 +105,11 @@ probe_up() { # probe_up <user> <box> <url>
echo "$r"
}
# The hardened network's gateway and prefix, read off the network — never
# hardcoded, because BOX_SUBNET moves the whole subnet now (#80).
boxnet_gw() { incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1; }
boxnet_pfx() { local gw; gw="$(boxnet_gw)"; printf '%s.' "${gw%.*}"; }
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
@ -159,13 +151,6 @@ snaps="$(incus project get "$p1" restricted.snapshots 2>/dev/null)"
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|| no "restricted.snapshots = '$snaps' — box snapshot will refuse"
# The same shape for backups (#70): export rides the backup API, which
# restricted projects block by default exactly like snapshots. A grant that
# missed this key strands every post-upgrade 'box export' at the tier.
bkups="$(incus project get "$p1" restricted.backups 2>/dev/null)"
[ "$bkups" = allow ] && ok "backups allowed in $p1 (box export works at this tier)" \
|| no "restricted.backups = '$bkups' — box export will refuse"
incus --project "$p1" profile device get default eth0 type >/dev/null 2>&1 \
&& no "(h) $p1's default profile still carries the private-bridge eth0" \
|| ok "(h) $p1's default profile places no network — box-net is the only door"
@ -202,42 +187,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" \
as_u "$U1" box info mine 2>/dev/null | grep -q '10\.88\.' \
&& ok "(g) box info shows a boxnet (10.88.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).
@ -263,14 +217,14 @@ phase "g. the isolation contract, measured from INSIDE the boxes"
ip1="$(incus --project "$p1" list mine --format csv --columns 4 2>/dev/null | tr -d '"' | sed 's/ (.*//' | head -n1)"
ip2="$(incus --project "$p2" list mine --format csv --columns 4 2>/dev/null | tr -d '"' | sed 's/ (.*//' | head -n1)"
inf "$U1's mine: ${ip1:-<no ip>} $U2's mine: ${ip2:-<no ip>}"
case "$ip1" in "$(boxnet_pfx)"*) ok "(g) $U1's box holds a boxnet lease" ;; *) no "(g) $U1's box is NOT on boxnet: '$ip1'" ;; esac
case "$ip1" in 10.88.*) ok "(g) $U1's box holds a boxnet lease" ;; *) no "(g) $U1's box is NOT on boxnet: '$ip1'" ;; esac
r="$(probe_up "$U1" mine https://1.1.1.1)"
[ "$r" = reachable ] && ok "(g) egress to the public internet works (curl 1.1.1.1: $r)" || no "(g) public egress broken: $r"
as_u "$U1" timeout -k 5 20 incus exec mine -- getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "(g) public DNS resolves (via the pinned resolver)" || no "(g) DNS broken inside the box"
r="$(probe_from "$U1" mine "http://$(boxnet_gw):22")"
r="$(probe_from "$U1" mine "http://10.88.0.1:22")"
[ "$r" = dropped ] && ok "(g) box → host is dropped (gateway :22: $r)" || no "(g) box can reach the HOST: $r"
r="$(probe_from "$U1" mine "http://192.168.0.1")"
[ "$r" = dropped ] && ok "(g) box → RFC1918 is dropped ($r)" || no "(g) box reaches private space: $r"
@ -503,65 +457,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

@ -5,68 +5,11 @@
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
set -euo pipefail
GW=10.88.0.1
NET=boxnet
# The gateway is read off the live bridge, not hardcoded: the subnet is an
# input now (BOX_SUBNET, setup-host.sh — #80), and a bridge moved off a
# colliding subnet must keep its firewall. setup-host runs us after the
# bridge exists, so the live read is the truth at install time. At boot the
# bridge may not be addressed yet — then GW is EMPTY and the UFW carve-out
# below is left alone rather than built for a guessed gateway: the old
# GW=10.88.0.1 fallback was wrong on every BOX_SUBNET host that hit the
# no-bridge window, a latent DNS drop (#86 review). Failing closed costs
# nothing: UFW rules persist across boots on their own, and nothing else in
# this script needs the gateway (the nft carve-out is interface-scoped).
# ('|| 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 [ -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
# Converge, don't create-once (the ACL's own #86 lesson, applied to UFW):
# gating this block on "a DENY on boxnet exists" pinned every 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 our own
# deny while every config looked right. Drop the DNS allows aimed
# 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" '
$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
done
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
if ! ufw status | grep "on $NET" | grep -q "DENY"; then
ufw insert 1 deny in on "$NET"
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto tcp
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto udp

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 —
@ -20,8 +17,7 @@
# '--network' flag away from any box they mint
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
# on them)
# 6. allow backups (blocked too; 'box export' rides the backup API — #70)
# 7. install the shipped box-net profile into their project
# 6. install the shipped box-net profile into their project
#
# Idempotent: every step converges, so re-running (including after a box
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
@ -66,34 +62,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 +92,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 +105,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 +121,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 +131,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"
@ -277,18 +183,7 @@ echo "network: $project restricted to boxnet (the private $bridge is unreference
incus project set "$project" restricted.snapshots allow </dev/null
echo "snapshots: allowed"
# 6. Backups. 'box export' rides incus's backup API — an export IS "create a
# backup, download it, delete it" — and a restricted project blocks that by
# default: restricted.backups=block the moment restricted=true (incus 6.0,
# internal/server/project/permissions.go, enforced by AllowBackupCreation).
# 'box import' needs no key of its own — restoring a backup file is plain
# instance creation. Same convergence as snapshots, for the same reason: the
# tier is the same workflows on your own boxes, and export/import are
# workflows (#70).
incus project set "$project" restricted.backups allow </dev/null
echo "backups: allowed ('box export' rides them, #70)"
# 7. The placement contract itself, installed into their project. Created if
# 6. The placement contract itself, installed into their project. Created if
# missing, refreshed unconditionally — same convergence discipline as
# setup-host's own profile handling, so a box upgrade propagates by re-run.
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
@ -298,53 +193,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

@ -3,7 +3,7 @@
#
# The zero-ceremony transition is just install.sh + setup-host.sh: that leaves
# a DUAL-STACK host where legacy boxes (tag user.claudebox=1, claudenet/10.87,
# claude-dev) keep working while new mints land on boxnet. This script is
# claude-dev) keep working while new mints land on boxnet/10.88. This script is
# the two things that path does not do:
#
# migrate-host.sh --box <name> re-home ONE legacy box onto the new stack
@ -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
@ -92,19 +88,17 @@ rehome_one() {
incus start "$b" >/dev/null 2>&1 || { warn "$b: did not restart — start it by hand"; return 1; }
# 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box
# must be on boxnet's subnet (read off the network — BOX_SUBNET moves it,
# #80) and actually resolve+reach the internet on its new leg before we
# call it migrated.
local _i ip pfx
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1)"; pfx="${pfx%.*}."
# must be on 10.88 and actually resolve+reach the internet on its new leg
# before we call it migrated.
local _i ip
ip=""
for _i in $(seq 1 30); do
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
| awk -v p="$pfx" '{for(i=1;i<NF;i++) if($i=="inet" && index($(i+1),p)==1){split($(i+1),a,"/"); print a[1]; exit}}')"
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
[ -n "$ip" ] && break
sleep 2
done
[ -n "$ip" ] || { warn "$b: never got a boxnet address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
if incus exec "$b" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
# LAST, and only once the move is VERIFIED: drop the legacy tag. Until this
# point the box wears both tags, so a failure anywhere above leaves it a

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
@ -187,51 +135,22 @@ done < <(incus config trust list --format csv --columns nf 2>/dev/null)
# incus-user's per-user client state (their key pair). Removed so a future
# re-grant starts clean instead of trusting a key the purge revoked.
# $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a
# non-root admin, so an unprivileged stat answers "absent" for a directory
# that is very much there — the same lie the absence assert below must dodge.
if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; then
if [ -d "/var/lib/incus/users/$uid" ]; then
$SUDO rm -rf "/var/lib/incus/users/$uid"
echo "purge: incus-user state for uid $uid removed"
fi
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
# The certificate included: its removal above is set -e-exempt (left of &&),
# 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)"
# and a promise the header makes is a promise this block checks.
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"
if [ -n "$leftover" ]; then
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
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

@ -45,159 +45,6 @@ else
exit 1
fi
# --- The subnet: never build on one something already owns ------------------
# (#80.) The stack's subnet was hardcoded, and running setup-host INSIDE a box
# gave the guest a nested boxnet claiming the exact subnet and gateway of its
# own uplink: the guest then held its gateway's address as a LOCAL address,
# carried two connected routes for the subnet, and suffered intermittent,
# self-recovering egress blackouts nobody could attribute — the host looked
# clean the whole time. The flagship use case funnels agents toward doing
# exactly this (working on box, in a box), so the decision must happen BEFORE
# any mutation. An explicit BOX_SUBNET is honored or refused, never overridden;
# with no pin, choose_subnet below converges on an existing bridge or picks a
# free /24 itself — a drill inside a box now just works, zero flags.
# BOX_SUBNET must be a /24 with a zero host octet — a.b.c.0/24. Everything
# the stack derives (the bridge address, the gateway carve-out, the firewall)
# assumes that shape, and a garbage value must die HERE, never inside an
# incus create or an nft rule.
valid_subnet() {
local o a="" b="" c="" rest=""
case "$1" in *.0/24) ;; *) return 1 ;; esac
IFS=. read -r a b c rest <<<"${1%/24}"
[ "$rest" = 0 ] || return 1
for o in "$a" "$b" "$c"; do
case "$o" in ''|*[!0-9]*) return 1 ;; esac
[ "${#o}" -le 3 ] && [ "$o" -le 255 ] || return 1
done
}
# Who, other than box's own bridge, already owns an address inside $1?
# Prints the claimant and succeeds when the subnet is claimed by a FOREIGNER;
# stays silent and fails when it is free — or held only by boxnet, which is
# the legitimate re-run, converging a stack this script built before. The
# most telling claimant is the default route's gateway: if it sits inside the
# target subnet, this machine's own uplink lives there — i.e. this is almost
# certainly the inside of a box. Pure over `ip` output, so test/cli.sh can
# drive it against canned tables with a shim ip.
subnet_claimant() {
local pfx hit
pfx="${1%0/24}"
hit="$(ip -4 route show default 2>/dev/null | awk -v p="$pfx" '
{ gw = ""; dev = ""
for (i = 1; i < NF; i++) { if ($i == "via") gw = $(i+1); if ($i == "dev") dev = $(i+1) }
if (index(gw, p) == 1 && dev != "boxnet") {
print "this machine\047s own DEFAULT GATEWAY (" gw " via " dev ")"; exit } }')"
if [ -z "$hit" ]; then
hit="$(ip -4 -o addr show 2>/dev/null | awk -v p="$pfx" '
$2 != "boxnet" && index($4, p) == 1 { print "interface " $2 " (" $4 ")"; exit }')"
fi
[ -n "$hit" ] && printf '%s\n' "$hit"
}
# The one place the stack's subnet is decided. Four deliberate cases (#80's
# fix #1, completed — the refusal shipped first, this adds the auto-pick):
# 1. explicit BOX_SUBNET — use it; a foreign claimant or a disagreeing
# bridge still REFUSES. An operator's pin is never silently overridden:
# a script that says 10.90 gets 10.90 or a loud stop, never a surprise.
# 2. no pin, boxnet exists — converge to the bridge's own subnet: the
# bridge IS the pin (boxes hold leases on it; setup-host never
# re-addresses it). What used to be an agree-gate refusal on a bare
# re-run against a moved bridge is now plain convergence. A FOREIGN
# claimant on the bridge's own subnet still refuses — that is #80's
# poisoned state, and converging would rebuild on it.
# 3. no pin, no bridge, 10.88.0.0/24 free — the default, as always.
# 4. no pin, no bridge, default claimed — the nested case (a drill or
# rehearsal inside a box): scan 10.89.0.0/24 … 10.127.0.0/24 in order,
# take the first free candidate, and say so loudly; refuse only when
# EVERY candidate is claimed. The scan only ever runs bridge-less —
# an existing bridge is case 2, which precedes it.
# Prints the chosen subnet on stdout, explains itself on stderr, fails when
# it refuses. Everything downstream (BOX_GW, the bridge, the ACL carve-out,
# the firewall, the doctor's expectations) derives from the choice, which is
# why it happens here, before any of them. Pure over `ip` (via
# subnet_claimant and the bridge read), so test/cli.sh drives every case
# against canned tables with a shim ip.
choose_subnet() {
local pin="$1" have_gw have_sub hit cand b
# ('|| true': under pipefail, `ip … dev boxnet` on a fresh host — no such
# device — would kill the script here instead of answering "no bridge".)
have_gw="$(ip -4 -o addr show dev boxnet 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)"
have_sub="${have_gw:+${have_gw%.*}.0/24}"
if [ -n "$pin" ]; then
if ! valid_subnet "$pin"; then
echo "ERROR: BOX_SUBNET='$pin' is not a sane subnet — the stack takes a" >&2
echo " /24 with a zero host octet, e.g. BOX_SUBNET=10.89.0.0/24" >&2
return 1
fi
if hit="$(subnet_claimant "$pin")"; then
echo "ERROR: refusing to build boxnet on $pin — that subnet is already" >&2
echo " claimed here by $hit." >&2
echo " If that is this machine's uplink, you are INSIDE a box: a nested" >&2
echo " stack on the guest's own subnet captures its gateway address and" >&2
echo " blackholes its egress, intermittently (issue #80)." >&2
echo " Nothing was changed. Drop the pin to let setup-host auto-pick a" >&2
echo " free subnet, or pick one yourself: BOX_SUBNET=<a.b.c.0/24> box setup-host" >&2
return 1
fi
if [ -n "$have_sub" ] && [ "$have_sub" != "$pin" ]; then
echo "ERROR: boxnet already exists on $have_sub and the target is $pin" >&2
echo " setup-host converges an existing bridge, it never re-addresses one." >&2
echo " Re-run with the bridge's own subnet (a bare 'box setup-host'" >&2
echo " converges on it automatically):" >&2
echo " BOX_SUBNET=$have_sub box setup-host" >&2
echo " (or move the bridge first: incus network set boxnet ipv4.address ${pin%.0/24}.1/24)" >&2
return 1
fi
printf '%s\n' "$pin"
return 0
fi
if [ -n "$have_sub" ]; then
if hit="$(subnet_claimant "$have_sub")"; then
echo "ERROR: boxnet lives on $have_sub, but that subnet is ALSO claimed here" >&2
echo " by $hit — the #80 poisoned state. Converging would rebuild on it." >&2
echo " Move the bridge off the claimed subnet first:" >&2
echo " incus network set boxnet ipv4.address 10.89.0.1/24" >&2
echo " then re-run: box setup-host" >&2
return 1
fi
if [ "$have_sub" != 10.88.0.0/24 ]; then
echo "boxnet already lives on $have_sub — converging to it." >&2
echo "(pin it explicitly with BOX_SUBNET=$have_sub if you script this host)" >&2
fi
printf '%s\n' "$have_sub"
return 0
fi
if ! hit="$(subnet_claimant 10.88.0.0/24)"; then
printf '10.88.0.0/24\n'
return 0
fi
for b in {89..127}; do
cand="10.$b.0.0/24"
subnet_claimant "$cand" >/dev/null && continue
echo "10.88.0.0/24 is claimed here by $hit" >&2
echo "most likely this machine IS a box (a nested drill or rehearsal, issue #80)." >&2
echo "auto-picked $cand for this stack instead." >&2
echo "(pin it explicitly with BOX_SUBNET=$cand if you script this host)" >&2
printf '%s\n' "$cand"
return 0
done
echo "ERROR: refusing to build boxnet — 10.88.0.0/24 is already claimed here by" >&2
echo " $hit, and so is every candidate through 10.127.0.0/24." >&2
echo " If that first claimant is this machine's uplink, you are INSIDE a" >&2
echo " box: a nested stack on the guest's own subnet captures its gateway" >&2
echo " address and blackholes its egress, intermittently (issue #80)." >&2
echo " Nothing was changed. Pick a free subnet yourself:" >&2
echo " BOX_SUBNET=<a.b.c.0/24> box setup-host" >&2
return 1
}
BOX_SUBNET="$(choose_subnet "${BOX_SUBNET:-}")" || exit 1
BOX_GW="${BOX_SUBNET%.0/24}.1"
# apt, unattended-safe. install.sh now runs us without a human watching, and
# a fresh cloud image has apt-daily/unattended-upgrades holding the dpkg lock
# for the first minutes of its life — plain 'apt-get install' then waits on it
@ -298,46 +145,22 @@ PRESEED
fi
# Isolated NAT network. IPv6 off: one less egress path to reason about.
# The default is 10.88 — not 10.87: a pre-rename host may still carry
# claudenet on 10.87 with legacy boxes attached — two bridges must not claim
# one subnet. BOX_SUBNET holds whatever choose_subnet decided above (an
# explicit pin, the existing bridge, the default, or an auto-picked free
# /24); the gateway and every rule below derive from it.
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
# legacy boxes attached — two bridges must not claim one subnet.
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
ipv4.address="$BOX_GW/24" ipv4.nat=true ipv6.address=none
ipv4.address=10.88.0.1/24 ipv4.nat=true ipv6.address=none
# ACL: default egress allow (internet), explicit drops for private space.
# Gateway carve-out first so instance DNS (dnsmasq on the gateway) survives.
# 'edit' the full shipped ruleset, not create-once: the carve-out derives
# from BOX_SUBNET now, and a bridge moved off a colliding subnet (#80's
# escape hatch) left the OLD /32 behind — box DNS to the new gateway then
# died inside the 10.0.0.0/8 drop, looking like a dead resolver, not a stale
# ACL. A conditional 'rule add' cannot converge that (the stale carve-out
# would survive beside the new one); replacing the ruleset does, idempotently.
incus network acl show box-isolate >/dev/null 2>&1 || incus network acl create box-isolate
incus network acl edit box-isolate <<ACL
description: ""
egress:
- action: allow
destination: $BOX_GW/32
state: enabled
- action: drop
destination: 10.0.0.0/8
state: enabled
- action: drop
destination: 172.16.0.0/12
state: enabled
- action: drop
destination: 192.168.0.0/16
state: enabled
- action: drop
destination: 169.254.0.0/16
state: enabled
- action: drop
destination: 100.64.0.0/10
state: enabled
ingress: []
ACL
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
if ! incus network acl show box-isolate >/dev/null 2>&1; then
incus network acl create box-isolate
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
fi
incus network set boxnet security.acls=box-isolate \
security.acls.default.egress.action=allow \
security.acls.default.ingress.action=drop

View file

@ -3,48 +3,20 @@
# ancestor created, so one teardown cleans a host of any generation: all boxes
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
# profiles, and both generations of firewall units and nft tables.
# Usage: ./host/teardown-host.sh [--purge-incus] [--yes]
# Usage: ./host/teardown-host.sh [--purge-incus]
# --purge-incus also apt-purge Incus itself (skipped if non-box
# instances still exist on this host)
# --yes skip the confirmation (BOX_YES=1 does the same) — for
# automation: CI's uninstall drill and 'box uninstall
# --purge-host' run this unattended
set -euo pipefail
purge=false; yes=0
for arg in "$@"; do
case "$arg" in
--purge-incus) purge=true ;;
--yes|-y) yes=1 ;;
*) echo "teardown-host: unknown option: $arg" >&2; exit 2 ;;
esac
done
[ -n "${BOX_YES:-}" ] && yes=1
purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true
echo "This removes ALL boxes (uncommitted work in them is lost), the"
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)."
$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; }
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
fi
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
# Instances — both tag generations, one delete at a time (a multi-name
# 'incus delete' aborts at the first missing name).
@ -71,37 +43,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
@ -127,4 +72,4 @@ if $purge; then
fi
fi
echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)"
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)"

View file

@ -3,32 +3,13 @@ set -euo pipefail
# box installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
# installs it into the VERSIONED layout under $DEST:
#
# $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/box -> $DEST/current/bin/box the PATH entry
#
# Versions install side by side, the way plenty of CLIs manage theirs: `box
# versions` lists them, `box use <v>` switches the default, `box uninstall`
# removes them. Re-running with an already-installed version is a converging
# no-op (BOX_REINSTALL=1 replaces that version's tree); a NEW version installs
# beside the old one and becomes the default only when NO boxes exist — #66's
# stance (never change versions under a user's boxes) now guards the FLIP, not
# the whole install. A pre-0.7.0 flat tree is migrated in place, so upgrading
# from 0.6.0 is seamless. (GitHub redirects the repo's pre-rename URLs, so an
# old install script keeps working; BOX_REPO overrides.)
#
# BOX_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
# downloading — for CI and the drill, so what lands is the code under review.
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
# script keeps working; BOX_REPO overrides.)
REPO="${BOX_REPO:-heavy-duty/box}"
# Three install channels, one knob (#83): BOX_REF unset installs the LATEST
# RELEASE (the tag resolved from GitHub's releases/latest redirect, below);
# BOX_REF=<tag> pins a release; BOX_REF=<branch> (say, main) is the dev
# channel. A set ref is tried as a tag first, then as a branch.
REF="${BOX_REF:-}"
REF="${BOX_REF:-main}"
# Root installs GLOBALLY, non-root installs per-user. box's install tree is
# EXECUTED by other users (the multi-user host path: rig installs box once, every
# incus-group operator runs it) — unlike rig, which is root-only and can hide in
@ -66,134 +47,30 @@ confirm() { # $1 = question
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. bin/box carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
case "$1" in
''|.*|-*) return 1 ;;
*[!A-Za-z0-9._+-]*) return 1 ;;
esac
return 0
}
# Which boxes exist on this host, at THIS caller's tier? Prints their names
# (both tag generations) and succeeds when at least one exists; fails when
# none are visible — including when incus is absent or not answering, because
# #66's stance protects BOXES from a version change, and a daemon that cannot
# answer has none to protect. bin/box carries a byte-identical copy (the CLI
# needs the same gate for 'box use' / 'box uninstall'); test/cli.sh diffs the
# two so they cannot drift.
existing_boxes() {
command -v incus >/dev/null 2>&1 || return 1
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}
# The latest release, resolved the no-API way (#83): GitHub answers
# https://github.com/<repo>/releases/latest with a redirect to
# .../releases/tag/<tag>, so one HEAD request reads the tag off the Location
# header — no API, no token, no rate-limit pain. Prints the bare tag; fails
# when the redirect does not answer or does not name a tag (a repo with no
# releases redirects to /releases), so the caller can refuse LOUDLY instead
# of silently installing main. test/release.sh drives this against a shim
# curl serving canned redirects.
latest_release_tag() {
local loc
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$REPO/releases/latest")" || return 1
case "$loc" in
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
# --- prerequisites ---------------------------------------------------------
# curl only when something must be downloaded — a local BOX_INSTALL_SOURCE
# needs none, which is what lets test/cli.sh drive REAL installs offline.
if [ -z "${BOX_INSTALL_SOURCE:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
fi
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $BOX_INSTALL_SOURCE"
else
SRCDESC="$REPO@${REF:-latest release}"
fi
# --- confirm first ---------------------------------------------------------
# --- confirm, then no-op if already installed ------------------------------
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
# ask whether you meant to. Everything after this converges: re-running with a
# version that is already installed changes nothing and says so, which
# dissolves the whole "curl clobbered my working install / rebuilt the stack
# under my boxes" class of failures (#66).
confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed."
# ask whether you meant to. Then, if box is already installed, this run changes
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
# "curl clobbered my working install / rebuilt the stack under my boxes" class
# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall
# what you have (dealing with your boxes as you do) and install fresh.
confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed."
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
# rename over. Plain ln -sfn is unlink+create — a window where current names
# nothing and a concurrent 'box' invocation dies mid-chain. bin/box's cmd_use
# flips with the same pattern.
flip_current() {
ln -sfn "versions/$1" "$DEST/current.new.$$"
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
# same trust boundary as the new_ver check, so the same gate: a corrupted
# (or hostile) VERSION must not steer the mv/ln below out of versions/.
valid_version "$flat_ver" || die "the flat install's VERSION is not a sane directory name: '$flat_ver' — fix $DEST/VERSION (one line, e.g. 0.6.0), then re-run"
log "found a pre-0.7.0 flat install at $DEST (version $flat_ver) — migrating it into the versioned layout"
staging="$DEST.migrating.$$"
mv "$DEST" "$staging"
mkdir -p "$DEST/versions"
mv "$staging" "$DEST/versions/$flat_ver"
flip_current "$flat_ver"
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"
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
log "box is already installed ($cur, version $cur_ver) — nothing to do."
log "To install a different version, remove the current one first:"
log " · preserve any boxes you care about — 'box down <box>', then keep them"
log " (a portable 'box export' is #70; for now copy what you need OUT via"
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
log " · then re-run this installer"
exit 0
fi
# --- temp workspace --------------------------------------------------------
@ -201,149 +78,50 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
# --- acquire the tree ------------------------------------------------------
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
SRC="$BOX_INSTALL_SOURCE"
INSTALLED_FROM="local:$SRC"
if [ -d "$SRC" ]; then
log "copying local tree $SRC"
mkdir -p "$TMPDIR/tree"
# tar, not cp -a: --exclude=.git, so a working checkout never carries its
# VCS state (or its size) into the install tree.
tar -C "$SRC" --exclude=.git -cf - . | tar -xf - -C "$TMPDIR/tree"
EXTRACTED="$TMPDIR/tree"
elif [ -f "$SRC" ]; then
log "extracting local tarball $SRC"
tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC"
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
else
die "BOX_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi
else
# No BOX_REF → the latest release, resolved only now (AFTER the confirm:
# even a redirect probe is network the operator has not yet said yes to).
# A failed resolution REFUSES with the way out — it must never hang, and
# never silently hand out main when the operator asked for a release (#83).
if [ -z "$REF" ]; then
REF="$(latest_release_tag)" \
|| die "could not resolve the latest release (no release tag behind https://github.com/$REPO/releases/latest). Check the network, and that $REPO has releases — or pick the ref yourself: BOX_REF=<tag> pins a release, BOX_REF=main installs the development tip."
SRCDESC="$REPO@$REF"
log "latest release: $REF"
fi
INSTALLED_FROM="$REPO@$REF"
log "installing box from $REPO@$REF"
# A ref is a TAG first (the pinned-release channel), a branch second (the
# dev channel, BOX_REF=main) — and the fallback only exists for a ref the
# OPERATOR named: a resolved latest tag has no branch to fall through to.
URL="https://github.com/$REPO/archive/refs/tags/$REF.tar.gz"
log "downloading $URL"
if ! curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz"; then
[ -n "${BOX_REF:-}" ] || die "failed to download $URL"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "no tag '$REF' — trying it as a branch: $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download it as either — '$REF' is neither a tag nor a branch of $REPO"
fi
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "extracting archive"
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
log "installing box from $REPO@$REF"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download $URL"
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
# dashes) — deriving that name is guesswork, and it broke for real at the
# claudebox → box rename, when this glob kept looking for claudebox-* and the
# installer died on every host. The tarball has exactly ONE top-level
# directory: take the directory, whatever it is called, and let the bin/box
# check below judge whether it is the right tree.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
fi
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC"
[ -f "$EXTRACTED/bin/box" ] || die "source does not contain bin/box — is $SRCDESC correct?"
log "extracting archive"
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# The tree's own VERSION file names the directory it lands in — the version IS
# the identity of what is being installed, and 'box versions' lists these names.
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)"
[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version"
valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'"
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
# dashes) — deriving that name is guesswork, and it broke for real at the
# claudebox → box rename, when this glob kept looking for claudebox-* and the
# installer died on every host. The tarball has exactly ONE top-level
# directory: take the directory, whatever it is called, and let the bin/box
# check below judge whether it is the right tree.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- install into $DEST/versions/<version> ---------------------------------
VDIR="$DEST/versions/$new_ver"
newly_installed=0
if [ -d "$VDIR" ]; then
if [ -n "${BOX_REINSTALL:-}" ]; then
# Replace THIS version's tree, as atomically as two renames allow — never
# a partial overlay of new files onto an old tree.
log "BOX_REINSTALL=1 — replacing the installed $new_ver tree"
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
rm -rf "$stage" "$old"
chmod +x "$EXTRACTED/bin/box"
mv "$EXTRACTED" "$stage"
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
# length of the delete where current -> this version resolves to nothing.
mv "$VDIR" "$old"
mv "$stage" "$VDIR"
rm -rf "$old"
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
log "reinstalled $new_ver"
else
cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
log "box $new_ver is already installed ($cur_from) — nothing to do."
log "(BOX_REINSTALL=1 replaces this version's tree; 'box versions' lists what is installed.)"
fi
else
log "installing $new_ver into $VDIR"
mkdir -p "$DEST/versions"
chmod +x "$EXTRACTED/bin/box"
mv "$EXTRACTED" "$VDIR"
newly_installed=1
# Record WHAT was installed, so a caller can assert it got what it asked for.
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
# retired in 0.5.0) silently falls back to the defaults and installs main —
# and the caller drills the wrong tree, believing it drilled its branch.
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
fi
# --- install into $DEST ----------------------------------------------------
# Reached only on a host with no existing install (the no-op check above
# exits otherwise), so this is always a fresh tree, never an overwrite.
log "installing into $DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
# --- which version is the default? -----------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# what an operator's `box` runs. #66's stance, kept exactly here: never change
# versions under existing boxes. A fresh host (or a dangling current) is
# claimed outright; an upgrade flips only when no box exists — otherwise the
# new version sits installed side-by-side and 'box use' is the deliberate act.
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
want="$(readlink -f "$VDIR")"
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
flip_current "$new_ver"
log "default version: $new_ver"
elif [ "$cur" = "$want" ]; then
: # already the default — nothing to flip
elif [ "$newly_installed" -eq 0 ]; then
# A converge/no-op (or BOX_REINSTALL) of a version that is NOT the default
# never moves the default — a re-run must change nothing (#66); switching is
# 'box use', a deliberate act.
log "the default stays $(basename "$cur") — 'box use $new_ver' switches."
else
old_ver="$(basename "$cur")"
if names="$(existing_boxes)"; then
warn "this host has existing boxes:"
while IFS= read -r n; do warn " · $n"; done <<<"$names"
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
log "box $new_ver is installed side-by-side. To switch:"
log " · preserve what you care about — 'box down <box>', 'box export <box>'"
log " (one portable file per box, #70), then 'box rm <box>' when you are done"
log " · then flip the default: box use $new_ver"
else
flip_current "$new_ver"
log "default version switched: $old_ver -> $new_ver ('box use $old_ver' switches back)"
fi
chmod +x "$DEST/bin/box"
# A global (root) install is run by OTHER users, but mv preserves the tarball's
# root:root ownership and GitHub's archives carry no world bits on some paths — so
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
# files, +search (x) on directories only. Guarded on root so the per-user install
# stays byte-identical to before.
if [ "$(id -u)" -eq 0 ]; then
chmod -R a+rX "$DEST"
fi
# --- put box on PATH -------------------------------------------------------
# ln -sfn converges, and that includes HEALING: a stale or dangling
# $BINDIR/box (say, its tree half-removed by hand) must never block or wedge
# an install — it gets repointed at the current chain, whatever it said before.
mkdir -p "$BINDIR"
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/current/bin/box"
ln -sf "$DEST/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/bin/box"
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
# honors their legacy tag — it is only the old command name that retires.
@ -359,34 +137,6 @@ if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
fi
# A global (root) install is run by OTHER users, but mv preserves the tarball's
# root:root ownership and GitHub's archives carry no world bits on some paths — so
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
# files, +search (x) on directories only. Guarded on root so the per-user install
# stays byte-identical to before.
if [ "$(id -u)" -eq 0 ]; then
chmod -R a+rX "$DEST"
fi
# --- the OTHER tier's install, if any --------------------------------------
# A root (/opt/box) and a per-user (~/.local/share/box) install coexist by
# PATH order alone, which is easy to be surprised by — say so out loud rather
# than let two versions silently shadow each other (#71's layout, both sides).
if [ "$(id -u)" -ne 0 ]; then
if [ -e /opt/box/current/bin/box ] || [ -e /opt/box/bin/box ]; then
warn "a GLOBAL install also exists at /opt/box — PATH order decides which 'box' you run (check: command -v box)"
fi
else
sudo_home=""
if [ -n "${SUDO_USER:-}" ]; then
sudo_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" || sudo_home=""
fi
if [ -n "$sudo_home" ] && { [ -e "$sudo_home/.local/share/box/current/bin/box" ] || [ -e "$sudo_home/.local/share/box/bin/box" ]; }; then
warn "a PER-USER install also exists at $sudo_home/.local/share/box — PATH order decides which 'box' $SUDO_USER runs"
fi
fi
# --- PATH check ------------------------------------------------------------
case ":$PATH:" in
*":$BINDIR:"*) : ;;
@ -397,36 +147,29 @@ case ":$PATH:" in
;;
esac
# Record WHAT was installed, so a caller can assert it got what it asked for.
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
# retired in 0.5.0) silently falls back to the defaults and installs main —
# and the caller drills the wrong tree, believing it drilled its branch.
# Written BEFORE host setup: this records the install, which has now happened,
# and it must not hinge on whether the host stack came up.
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
# --- host setup (second prompt) --------------------------------------------
# The tool is installed; the machine is not yet a box host. Offer to finish the
# job — build Incus and the isolation stack — rather than leave 'box new' to die
# later on a host with no boxnet and no profile (#64). This is its own decision:
# you might be installing the CLI on a workstation and hosting boxes elsewhere.
# Offered on a FRESH host only: a host that already had a box install has made
# this decision (and may have live boxes the stack must not be rebuilt under);
# '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
log "this host already had a box install — skipping host setup (re-apply stack changes any time: box setup-host)"
setup_declined=1
elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
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/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 +177,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/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."
@ -446,18 +189,9 @@ else
fi
if [ -n "$setup_ok" ]; then
log "done ($SRCDESC, version $new_ver) — try: box new --name test"
log "done ($REPO@$REF) — try: box new --name test"
elif [ -n "$setup_declined" ]; then
log "done ($SRCDESC, version $new_ver) — when you want this machine to host boxes: box setup-host"
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host"
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"
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
fi

View file

@ -1,21 +0,0 @@
# The claude-box template — a thin, creds-free seed (#81): Debian 13, the
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
# 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.
# 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="Claude Code on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude-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 claude-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: claude
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 claude-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

12
templates/claude/box.env Normal file
View file

@ -0,0 +1,12 @@
# The claude template — Debian 13 + Claude Code, creds-free.
# 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 cloud-init file is
# passed to Incus verbatim, so the duplication is deliberate and by hand).
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free — where this project started"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -0,0 +1,61 @@
#cloud-config
users:
- name: claude
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
write_files:
- path: /home/claude/.claude/CLAUDE.md
owner: "claude:claude"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: claude)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login`
interactively. For git, the operator adds their own credentials (a PAT or
`gh auth login`). Never assume credentials are present; never ask for or
store secrets on disk beyond what the operator sets up.
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true
packages:
- git
- gh
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
- zsh
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker claude
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
# 'box exec <box> -- claude …' runs through a NON-interactive shell, which
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
# symlink makes claude reachable from every shell, interactive or not.
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.bashrc
- chsh -s /usr/bin/zsh claude
- sudo -u claude bash -lc 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.zshrc
- echo 'set -g mouse on' > /home/claude/.tmux.conf
- chown claude:claude /home/claude/.tmux.conf

View file

@ -1,21 +0,0 @@
# The codex-box template — a thin, creds-free seed (#81): Debian 13, the
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
# 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.
# 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="OpenAI Codex CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex-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 codex-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: codex
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 codex-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

11
templates/codex/box.env Normal file
View file

@ -0,0 +1,11 @@
# The codex template — Debian 13 + OpenAI Codex CLI, creds-free.
# 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.
BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -0,0 +1,60 @@
#cloud-config
users:
- name: codex
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
write_files:
- path: /home/codex/.codex/AGENTS.md
owner: "codex:codex"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: codex)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no OpenAI and no git
credentials. If you need to authenticate Codex, the operator runs the
login flow (`codex`) interactively. For git, the operator adds their own
credentials (a PAT or `gh auth login`). Never assume credentials are
present; never ask for or store secrets on disk beyond what the operator
sets up.
- **Isolated.** The box reaches the public internet but nothing on the host
or local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true
packages:
- git
- gh
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker codex
# Codex CLI is an npm global and needs Node 22+ (verified upstream:
# npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the
# unrelated 2012 'codex' package).
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- npm install -g @openai/codex
# 'box exec <b> -- codex …' runs a NON-interactive shell that reads no
# rc files — npm's global bin must be reachable from every shell. Symlink
# the installed binary into /usr/local/bin (the same fix the claude
# template needed for its own CLI).
- ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex
- echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> /home/codex/.bashrc

View file

@ -1,21 +0,0 @@
# The grok-box template — a thin, creds-free seed (#81): Debian 13, the
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
# 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.
# 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="xAI Grok CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok-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 grok-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: grok
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 grok-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

11
templates/grok/box.env Normal file
View file

@ -0,0 +1,11 @@
# The grok template — Debian 13 + xAI's Grok Build CLI, creds-free.
# 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.
BOX_DESCRIPTION="xAI Grok CLI on Debian 13, creds-free"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -0,0 +1,75 @@
#cloud-config
users:
- name: grok
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
write_files:
- path: /home/grok/.grok/AGENTS.md
owner: "grok:grok"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: grok)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.
- **Isolated.** The box reaches the public internet but nothing on the host
or local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true
packages:
- git
- gh
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker grok
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than
# guessed at. What it actually does:
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build`
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a
# SYMLINK into its versioned download dir (so `find -type f` misses it)
# · GROK_BIN_DIR overrides that directory
#
# Run it AS grok, not root: the binary symlink points into the invoking
# user's download dir, and root's home is 0700 — a symlink into it would be
# unreadable to the grok user, giving a CLI that exists and cannot run.
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
# 'box exec <b> -- grok …' is a NON-interactive shell: it reads no rc files,
# so ~/.grok/bin is never on its PATH. Symlink onto the system PATH — the
# same fix the claude template needed (#15). Assert the result: a CLI that
# silently is not on PATH is what cost the last drill run.
- |
if [ -e /home/grok/.grok/bin/grok ]; then
ln -sf /home/grok/.grok/bin/grok /usr/local/bin/grok
echo "grok: linked /usr/local/bin/grok -> /home/grok/.grok/bin/grok"
/usr/local/bin/grok --version >/dev/null 2>&1 \
&& echo "grok: 'grok --version' answers from the system PATH" \
|| echo "grok: WARNING - linked, but 'grok --version' does not answer" >&2
else
echo "grok: installer produced no ~/.grok/bin/grok - upstream layout changed?" >&2
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
fi
- echo 'export PATH="$HOME/.grok/bin:$PATH"' >> /home/grok/.bashrc

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,29 +0,0 @@
# The staging-box template — a thin, creds-free, server-class seed (#81, the
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
# 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
# workload join holds a key and therefore STAYS operator-run:
# box shell <name> # then: sudo rig bootstrap workload-server --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.
# The two boot demands (#68): the VM is this box's trust boundary and its
# guest runs docker, so no container fallback (BOX_REQUIRE_VM); and a server
# must return from a host reboot without an operator (BOX_AUTOSTART).
# 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="Server-class Debian 13, creds-free — box mints, rig converges, the join stays yours"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="ops"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1"
BOX_BOOTSTRAP_ROLE="staging-box"

View file

@ -1,34 +0,0 @@
#cloud-config
# 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
# mint; the tailnet workload join holds a pre-auth key and stays
# operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
# #69 designed it — box never sees the key.
users:
- name: ops
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) — and the
# operator babysits the workload join through it.
# 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 staging-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

File diff suppressed because it is too large Load diff

View file

@ -1,129 +0,0 @@
#!/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.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0
check() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
out="$("$@" 2>&1)"; rc=$?
if [ "$rc" -ne "$want" ]; then
echo "FAIL: $desc — exit $rc, wanted $want"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
echo "FAIL: $desc — output missing '$substr'"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
echo "ok: $desc"; PASS=$((PASS + 1))
}
WORK="$(mktemp -d)"
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
cat > "$SHIMDIR/curl" <<'SHIM'
#!/usr/bin/env bash
url="" out=""
while [ $# -gt 0 ]; do
case "$1" in
-o|--output) out="$2"; shift 2 ;;
-w|--write-out) shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
[ -n "${FAKE_CURL_LOG:-}" ] && printf '%s\n' "$url" >> "$FAKE_CURL_LOG"
case "$url" in
*/releases/latest)
[ "${FAKE_CURL_RC:-0}" -eq 0 ] || exit "${FAKE_CURL_RC}"
printf '%s' "${FAKE_REDIRECT:-}"; exit 0 ;;
*)
if [ -n "${FAKE_SERVE_URL:-}" ] && [ "$url" = "$FAKE_SERVE_URL" ]; then
cp "${FAKE_TARBALL:?}" "${out:?}"; exit 0
fi
exit 22 ;;
esac
SHIM
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"
ltag() {
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" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
check "resolve: a repo with no 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 "" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
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"
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
ninst() {
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"
}
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
check "default channel: 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 "" \
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" \
cat "$H1/versions/9.9.9/INSTALLED_FROM"
check "default channel: installed binary answers" 0 "box 9.9.9" \
env HOME="$FAKEHOME" "$B1/box" --version
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
check "pinned channel: installs the requested 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 "" \
grep -q "releases/latest" "$L2"
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" \
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 "" \
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
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" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
check "resolution failure: names BOX_REF as the override" 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" \
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"
echo "---"
echo "$PASS passed, $FAIL failed"
rm -rf "$WORK"
[ "$FAIL" -eq 0 ]