Compare commits
170 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33794ce70 | ||
|
|
7e05328a6d | ||
|
|
7247768465 | ||
|
|
f8ca59c22e | ||
|
|
e4d9005aca | ||
|
|
3bae42157e | ||
|
|
b0d4aff964 | ||
|
|
1c3d873bf2 | ||
|
|
c93e230396 | ||
|
|
a4684f7afa | ||
|
|
bf74744bda | ||
|
|
a17903f07c | ||
|
|
86af660818 | ||
|
|
2c3b329b8f | ||
|
|
991acd2ba6 | ||
|
|
0b5bedd6e0 | ||
|
|
1ffd20d35b | ||
|
|
f63fa952d4 | ||
|
|
115b9c026e | ||
|
|
877d9ded55 | ||
|
|
0678b22e01 | ||
|
|
92dc3a2bdc | ||
|
|
67d5890e2e | ||
|
|
683217ee42 | ||
|
|
f7058b2814 | ||
|
|
9fcd2e4a7f | ||
|
|
82c1888381 | ||
|
|
4c595eb945 | ||
|
|
050c90966e | ||
|
|
91349acac1 | ||
|
|
64127b34e6 | ||
|
|
b2afea580e | ||
|
|
24a05c3020 | ||
|
|
a091c6e496 | ||
|
|
f84e474549 | ||
|
|
8f1a281c20 | ||
|
|
256ded2e92 | ||
|
|
4fe91ef562 | ||
|
|
c3823e9a3f | ||
|
|
aed2f5bf1f | ||
|
|
be633aa5f8 | ||
|
|
aec22277cd | ||
|
|
239428bf88 | ||
|
|
9b06e29b47 | ||
|
|
1e3fb473c3 | ||
|
|
4b658b6fb0 | ||
|
|
94d830cdb3 | ||
|
|
b7954e3f9b | ||
|
|
94f6ed0047 | ||
|
|
243ccc3aa5 | ||
|
|
3155258cf5 | ||
|
|
81c44c8b02 | ||
|
|
b953d8b3c1 | ||
|
|
e6854ef62c | ||
|
|
f956a85a1c | ||
|
|
27cfc5b8c1 | ||
|
|
074aea31d8 | ||
|
|
9bf669dabc | ||
|
|
2d49a5ad42 | ||
|
|
e408b7a0ed | ||
|
|
b9527d3cc1 | ||
|
|
cbc8f4f7a8 | ||
|
|
e40d2df5f1 | ||
|
|
724f103908 | ||
|
|
aa5a6baed6 | ||
|
|
ecce72f025 | ||
|
|
b3fa94a7d4 | ||
|
|
a9e52325f4 | ||
|
|
b1968dabb7 | ||
|
|
add1c2a142 | ||
|
|
cb72c71c33 | ||
|
|
ce57070c14 | ||
|
|
cc0a1a9a79 | ||
|
|
c68c519558 | ||
|
|
0b7ad21eca | ||
|
|
4c34facefe | ||
|
|
9c78911563 | ||
|
|
86dbb0449e | ||
|
|
15b67d938e | ||
|
|
c4f62e5d87 | ||
|
|
8661067468 | ||
|
|
2c4f83814f | ||
|
|
bb51aeeca9 | ||
|
|
e27ab239f1 | ||
|
|
41bba74e10 | ||
|
|
67331ebecd | ||
|
|
1a9d96168d | ||
|
|
9ea50d21e2 | ||
|
|
531803a8c9 | ||
|
|
b38518ee54 | ||
|
|
12713c5012 | ||
|
|
90ca56cd3f | ||
|
|
57db9a56bb | ||
|
|
dc09d54cbe | ||
|
|
9649659aff | ||
|
|
7067fb03b0 | ||
|
|
8598cf96fe | ||
|
|
4a06c3ac00 | ||
|
|
f2f57cca7f | ||
|
|
c8150f2d5b | ||
|
|
b89ed144fe | ||
|
|
03e1a8c5b7 | ||
|
|
cdf4c043c9 | ||
|
|
0fbd60e6ff | ||
|
|
89345f7de3 | ||
|
|
00e36b9436 | ||
|
|
13aa499dad | ||
|
|
420e8e7f32 | ||
|
|
b58ce5524f | ||
|
|
fb7fc845bf | ||
|
|
07c0edd60a | ||
|
|
24797c9052 | ||
|
|
6fc5ba4d0a | ||
|
|
12edb1966f | ||
|
|
4da487e388 | ||
|
|
8ab9b38ba2 | ||
|
|
e40d82afa6 | ||
|
|
3122729877 | ||
|
|
3cac269b5b | ||
|
|
646c458e25 | ||
|
|
7044f8e0c1 | ||
|
|
6d4996e532 | ||
|
|
dd51b06f3a | ||
|
|
88db8cab04 | ||
|
|
ff94af93c3 | ||
|
|
5d893ba2e1 | ||
|
|
fee1929d10 | ||
|
|
a1eb2054ca | ||
|
|
9cf10d58a7 | ||
|
|
fb1f5e6f98 | ||
|
|
24e67a3c2b | ||
|
|
3a7a9f6c6b | ||
|
|
a8d0f64e48 | ||
|
|
f4f25016d6 | ||
|
|
56638d9654 | ||
|
|
ce9ec684d1 | ||
|
|
fbad141a82 | ||
|
|
ca1a759f3a | ||
|
|
a4d6388b02 | ||
|
|
b7468ecfe5 | ||
|
|
e877153e3c | ||
|
|
7339377f61 | ||
|
|
9d3fed8901 | ||
|
|
53dc2b9047 | ||
|
|
7d48822cc0 | ||
|
|
d16f230e3b | ||
|
|
b429f5b65c | ||
|
|
08d099cce2 | ||
|
|
26a218f29c | ||
|
|
b5aa21e733 | ||
|
|
8d1824551c | ||
|
|
dd1166e1ed | ||
|
|
fd600166c5 | ||
|
|
a25fbd9692 | ||
|
|
b077279850 | ||
|
|
2bbc53d1dd | ||
|
|
1ef3ae739a | ||
|
|
d879530a7a | ||
|
|
40fdfbb389 | ||
|
|
1b4594eafc | ||
|
|
4fa7b3a2d8 | ||
|
|
68a4996f0b | ||
|
|
1ddaf7082d | ||
|
|
44a474d758 | ||
|
|
34bf39be86 | ||
|
|
82f7f9d51a | ||
|
|
88243dcdbb | ||
|
|
9d3ca92ffa | ||
|
|
6e92853925 | ||
|
|
3f7a178b0d |
53 changed files with 7640 additions and 724 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# AGENTS.md — start here
|
||||||
|
|
||||||
|
You are an agent working in a repo governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||||
|
the router: find your role below, read its file, then act. The role files
|
||||||
|
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||||
|
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||||
|
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||||
|
|
||||||
|
## Your role
|
||||||
|
|
||||||
|
You were told your role when you were pointed at this repo ("you are a
|
||||||
|
reviewer here"). That one word is your whole onboarding:
|
||||||
|
|
||||||
|
| you are the… | read | your job in one line |
|
||||||
|
|---|---|---|
|
||||||
|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||||
|
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||||
|
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||||
|
|
||||||
|
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||||
|
are the shared state machine, and misusing one lies to every other agent on
|
||||||
|
the board.
|
||||||
|
|
||||||
|
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||||
|
asked to implement an issue → builder; asked to process discussions or the
|
||||||
|
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||||
|
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||||
|
pushing fixes, breaks the separation the pipeline depends on.
|
||||||
|
|
||||||
|
## The pipeline you are part of
|
||||||
|
|
||||||
|
```
|
||||||
|
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules bind every role:
|
||||||
|
|
||||||
|
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||||
|
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||||
|
a merge button.
|
||||||
|
|
||||||
|
## Repo specifics
|
||||||
|
|
||||||
|
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||||
|
label set, what a drill means, code conventions — lives in the repo's own
|
||||||
|
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||||
|
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||||
76
.ceremony/BUILDER.md
Normal file
76
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# BUILDER.md — the builder role
|
||||||
|
|
||||||
|
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||||
|
so you can succeed without asking anyone anything — if you can't, that is a
|
||||||
|
triage bug, and the move is to say so on the issue, not to guess.
|
||||||
|
|
||||||
|
## Picking
|
||||||
|
|
||||||
|
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||||
|
never an `epic` (epics organize; their children are the work).
|
||||||
|
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||||
|
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||||
|
the most other work.
|
||||||
|
- **One issue at a time.** Finish or release your claim before taking
|
||||||
|
another.
|
||||||
|
|
||||||
|
## Claiming
|
||||||
|
|
||||||
|
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||||
|
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||||
|
and no activity is what the staleness sweep reclaims.
|
||||||
|
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||||
|
push the branch if it holds anything useful, unassign, and restore
|
||||||
|
`ready`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||||
|
body. Drafts are invisible to the reviewer panel on purpose — the draft
|
||||||
|
phase is yours.
|
||||||
|
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||||
|
them as a checklist in the PR body and check them honestly as you go. If
|
||||||
|
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||||
|
amended by triage — do not silently ship less than the issue says.
|
||||||
|
- Every behavior change adds one line to `CHANGELOG.md` under
|
||||||
|
`## Unreleased` — insert **above** the heading below it, never over it
|
||||||
|
(the monotonic guard's whole reason to exist).
|
||||||
|
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||||
|
not optional: the issue's test plan is the floor, not the ceiling.
|
||||||
|
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||||
|
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||||
|
relevant issue), where triage will do its job. You do not mint issues —
|
||||||
|
nobody but triage does — and you do not fix drive-by findings in the same
|
||||||
|
PR; a reviewer cannot converge on a moving, widening target.
|
||||||
|
|
||||||
|
## The review round
|
||||||
|
|
||||||
|
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
|
||||||
|
panel roster and any repo-specific flow notes live in that repo's own
|
||||||
|
CONTRIBUTING; everything below is the shared flow.)
|
||||||
|
|
||||||
|
1. Mark ready-for-review; request **the whole panel** (the roster is in the
|
||||||
|
repo's CONTRIBUTING).
|
||||||
|
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||||
|
covering every point, then push the fixes, then re-request exactly the
|
||||||
|
reviewers who did not approve. Prefer verification over argument: when a
|
||||||
|
reviewer doubts behavior, add the test that settles it.
|
||||||
|
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||||
|
A blocking point you disagree with is answered with evidence or escalated
|
||||||
|
in the PR — a maintainer can be asked for a ruling; silence and
|
||||||
|
force-forward are not options.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
When the round passes — every panel verdict approves the **current head**,
|
||||||
|
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||||
|
this is a release PR) — hand it to the human, in order:
|
||||||
|
|
||||||
|
1. post the round summary (what changed per round, what was verified);
|
||||||
|
2. request the human's review;
|
||||||
|
3. set `state:needs-human` yourself.
|
||||||
|
|
||||||
|
The label write is optimistic — the reconciler validates it, and takes it
|
||||||
|
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
|
||||||
|
human's. Address what comes back (`state:addressing`) and re-hand-off the
|
||||||
|
same way.
|
||||||
98
.ceremony/LABELS.md
Normal file
98
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Labels
|
||||||
|
|
||||||
|
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||||
|
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||||
|
surfaces); everything else below is core and identical everywhere, created by
|
||||||
|
the labels workflow's bootstrap dispatch (issue #10).
|
||||||
|
|
||||||
|
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||||
|
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||||
|
triage → build queue, doctrine-enforced today, machinery to follow —
|
||||||
|
issue #18). One rule joins everything: **states are machine-owned, intent
|
||||||
|
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||||
|
and the reconciler recomputes it from GitHub's own facts.
|
||||||
|
|
||||||
|
## PR state — who is the ball with? (exactly one per open PR)
|
||||||
|
|
||||||
|
| Label | Color | Waiting on |
|
||||||
|
|---|---|---|
|
||||||
|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||||
|
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||||
|
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
|
||||||
|
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||||
|
|
||||||
|
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||||
|
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||||
|
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||||
|
so it requires zero blockers and head-current approvals; anything less and
|
||||||
|
the reconciler takes it back. The author sets it at handoff (the one
|
||||||
|
hand-set state); the `labeled` event fires the sweep that validates the
|
||||||
|
write within seconds.
|
||||||
|
|
||||||
|
## PR blockers — what is in the way? (facts, as many as apply)
|
||||||
|
|
||||||
|
| Label | Color | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||||
|
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||||
|
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||||
|
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||||
|
|
||||||
|
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||||
|
separate axes because the single-label version kept lying — independent facts
|
||||||
|
projected onto one totally-ordered label meant one always won and the losers
|
||||||
|
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||||
|
strips it on sight).
|
||||||
|
|
||||||
|
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||||
|
|
||||||
|
| Label | Color | Means | Set by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||||
|
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||||
|
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||||
|
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||||
|
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||||
|
|
||||||
|
The invariant a board scan relies on: every open issue is either
|
||||||
|
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||||
|
`blocked`. A `claimed` issue with no open PR and no activity is what the
|
||||||
|
staleness sweep will reclaim (issue #18); until that machinery exists,
|
||||||
|
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
|
||||||
|
|
||||||
|
## Cross-cutting (PRs and issues)
|
||||||
|
|
||||||
|
| Label | Color | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||||
|
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
|
||||||
|
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||||
|
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||||
|
|
||||||
|
## Scope — which surface? (PRs and issues, any number)
|
||||||
|
|
||||||
|
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||||
|
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
|
||||||
|
actions/labeler, issues get theirs from triage. This repo's set:
|
||||||
|
|
||||||
|
| Label | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
|
||||||
|
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
|
||||||
|
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
|
||||||
|
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
|
||||||
|
|
||||||
|
## Issue types
|
||||||
|
|
||||||
|
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||||
|
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||||
|
on a PR would say the same thing twice and drift.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
The labels workflow (issue #10) recomputes PR state statelessly on PR events
|
||||||
|
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
|
||||||
|
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
|
||||||
|
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
|
||||||
|
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
|
||||||
|
bootstrap — a `question` is a discussion, not an issue.
|
||||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# .ceremony/ — the vendored doctrine mirror
|
||||||
|
|
||||||
|
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
|
||||||
|
these files here: they are byte-identical copies of
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
|
||||||
|
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
|
||||||
|
goes red. They are changed in heavy-duty/ceremony, through its own flow,
|
||||||
|
and arrive here when the pin moves.
|
||||||
|
|
||||||
|
The pin lives in `.github/workflows/release.yml` — the single
|
||||||
|
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
|
||||||
|
pin governs machinery and doctrine alike: bump it and re-sync this mirror
|
||||||
|
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
|
||||||
|
what is stale).
|
||||||
70
.ceremony/REVIEWER.md
Normal file
70
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# REVIEWER.md — the reviewer role
|
||||||
|
|
||||||
|
You are one voice on a panel. The panel's job is to converge — on an
|
||||||
|
approval the human can trust, or on a precise statement of what is wrong.
|
||||||
|
The machine reads only your **verdict**; humans read your reasons.
|
||||||
|
|
||||||
|
## The verdict doctrine
|
||||||
|
|
||||||
|
- **Every review ends in a verdict**: approve, or request changes. A
|
||||||
|
comment-only review is a non-verdict — it does not say whether the round
|
||||||
|
passed, the state machine treats it as not-approved, and the PR simply
|
||||||
|
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||||
|
only wedges the flow.
|
||||||
|
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||||
|
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||||
|
their discretion. Anything blocking — including a question whose answer
|
||||||
|
gates your approval — is **request changes**, saying exactly what
|
||||||
|
unblocks it.
|
||||||
|
- An approval you would not defend to the human is a defect. You are not
|
||||||
|
being asked to be agreeable; you are being asked to be right.
|
||||||
|
|
||||||
|
## What you review against
|
||||||
|
|
||||||
|
In order of authority:
|
||||||
|
|
||||||
|
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
|
||||||
|
spec. Check every criterion; a PR that ships less than the issue says is
|
||||||
|
a request-changes even if the code is beautiful.
|
||||||
|
2. **The repo's load-bearing constraints** — the rules bought with
|
||||||
|
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||||
|
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||||
|
"simplifies away" a constraint gets request-changes with a link to the
|
||||||
|
incident that made the rule.
|
||||||
|
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||||
|
floor exist? do the failure cases actually fail?), then conventions.
|
||||||
|
Changelog line present for behavior changes; comments carry why, not
|
||||||
|
what.
|
||||||
|
|
||||||
|
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||||
|
test settles what a comment thread can't. A review that says "I ran X and
|
||||||
|
saw Y" outranks one that says "this looks like it might".
|
||||||
|
|
||||||
|
## What you do not do
|
||||||
|
|
||||||
|
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||||
|
above it, in a discussion where humans had their say. If you think the
|
||||||
|
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||||
|
discussion, while still reviewing the implementation against the spec as
|
||||||
|
written. Spec changes go through triage, not through a review round.
|
||||||
|
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||||
|
human; only humans merge.
|
||||||
|
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||||
|
builder pushes after your approval, GitHub stales it — that is correct,
|
||||||
|
and the builder owes a re-request, not an assumption.
|
||||||
|
|
||||||
|
## The round rhythm
|
||||||
|
|
||||||
|
- Review the **whole PR at the current head** each round, not just the diff
|
||||||
|
since your last comments — the fix for someone else's point can break
|
||||||
|
yours.
|
||||||
|
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||||
|
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||||
|
reviews mid-address just churn the target).
|
||||||
|
- Convergence = every panel verdict approves the current head, no
|
||||||
|
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||||
|
the panel's job is done.
|
||||||
|
- If a round exposes a disagreement **within the panel**, argue it in the PR
|
||||||
|
with evidence until one side concedes or the builder escalates to the
|
||||||
|
maintainer for a ruling. Two reviewers pulling a builder in opposite
|
||||||
|
directions without resolution is a panel failure, not a builder failure.
|
||||||
95
.ceremony/TRIAGE.md
Normal file
95
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# TRIAGE.md — the triage role
|
||||||
|
|
||||||
|
You are the only door issues come through. Humans and agents open
|
||||||
|
**discussions**; you decide what becomes work. The quality of every
|
||||||
|
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||||
|
spec to review against — is set here, by you, and nowhere else.
|
||||||
|
|
||||||
|
## Why this door exists
|
||||||
|
|
||||||
|
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||||
|
order a builder must be able to execute **without asking anyone anything**.
|
||||||
|
Keeping one accountable role between the two is what keeps the bar from
|
||||||
|
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||||
|
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||||
|
is the failure this whole flow exists to prevent.
|
||||||
|
|
||||||
|
## Your inputs
|
||||||
|
|
||||||
|
- **Every open discussion** in the repo you serve.
|
||||||
|
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||||
|
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||||
|
substance back into a discussion and close it, saying why. Do not shame the
|
||||||
|
filer; do route the work correctly.
|
||||||
|
|
||||||
|
## For each discussion, converge on exactly one outcome
|
||||||
|
|
||||||
|
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||||
|
already shipped or already tracked. Reply with the answer (link the code,
|
||||||
|
the doc, the existing issue), mark answered.
|
||||||
|
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||||
|
repo, its history, or its docs. Ask the 2–3 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
Normal file
22
.github/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 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
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
|
scope:cli|C5DEF5|bin/box — the command surface
|
||||||
|
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||||
|
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||||
|
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||||
|
scope:templates|C5DEF5|templates/ — the box seeds
|
||||||
|
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||||
177
.github/workflows/ci.yml
vendored
177
.github/workflows/ci.yml
vendored
|
|
@ -8,19 +8,105 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||||
|
# for it. That check is about a DIFF — which release headings the
|
||||||
|
# merge base had — so it needs the base branch's history present,
|
||||||
|
# and the default depth-1 checkout has none of it. An explicit
|
||||||
|
# `git fetch origin <base>` would be narrower, but it has to be
|
||||||
|
# right on both event types and on fork PRs, and getting it subtly
|
||||||
|
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||||
|
# the exact failure this repo keeps refusing). Full history on a
|
||||||
|
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||||
|
# remaining skip red rather than green.
|
||||||
|
fetch-depth: 0
|
||||||
- name: shellcheck
|
- name: shellcheck
|
||||||
# -x follows `source`/`.` directives; box has no lib split today, but the
|
# -x follows `source`/`.` directives; box has no lib split today, but the
|
||||||
# flag costs nothing and keeps the invocation identical to rig's.
|
# flag costs nothing and keeps the invocation identical to rig's.
|
||||||
# globstar so a script in a new subdirectory is linted without anyone
|
# globstar so a script in a new subdirectory is linted without anyone
|
||||||
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
||||||
# (bin/box). The file list is printed so under-coverage shows up in the log.
|
# (bin/box). The file list is printed so under-coverage shows up in the log.
|
||||||
|
#
|
||||||
|
# dotglob is not decoration (#116): globstar makes `**` descend, but a
|
||||||
|
# glob still does not MATCH a dot-prefixed name, so `**/` never entered
|
||||||
|
# `.github/` — and the whole release path (changelog-armed.sh, which
|
||||||
|
# gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted
|
||||||
|
# while the comment above told the next author it was covered.
|
||||||
|
# Measured when this invariant was introduced: dotglob added exactly
|
||||||
|
# those three and nothing else — a checkout's `.git` carries no `*.sh`
|
||||||
|
# (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it.
|
||||||
|
#
|
||||||
|
# The sweep below is the CLASS check, same shape as the eof_guard_sweep
|
||||||
|
# in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps
|
||||||
|
# the gap from reopening is asserting that every TRACKED script is in
|
||||||
|
# the set actually handed to shellcheck. `git ls-files` is the authority
|
||||||
|
# on what the repo contains; if the glob ever drifts from it again —
|
||||||
|
# another dot-directory, another shopt subtlety — CI says which files
|
||||||
|
# escaped instead of quietly linting a subset and passing.
|
||||||
run: |
|
run: |
|
||||||
shopt -s globstar
|
shopt -s globstar dotglob
|
||||||
files=(bin/* **/*.sh)
|
files=(bin/* **/*.sh)
|
||||||
printf 'shellcheck: %s\n' "${files[@]}"
|
printf 'shellcheck: %s\n' "${files[@]}"
|
||||||
|
missing="$(comm -13 \
|
||||||
|
<(printf '%s\n' "${files[@]}" | sort -u) \
|
||||||
|
<(git ls-files '*.sh' | sort -u))"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "tracked scripts the shellcheck sweep does not cover (#116):"
|
||||||
|
printf '%s\n' "$missing" | sed 's/^/ /'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
shellcheck -x "${files[@]}"
|
shellcheck -x "${files[@]}"
|
||||||
- name: cli tests
|
- name: cli tests
|
||||||
run: bash test/cli.sh
|
run: bash test/cli.sh
|
||||||
|
- name: 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
|
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||||
|
|
@ -33,27 +119,94 @@ jobs:
|
||||||
# release still proves the boundary.
|
# release still proves the boundary.
|
||||||
rehearsal:
|
rehearsal:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: install incus
|
- name: install incus
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
|
||||||
- name: global install, from this checkout (the #71 layout)
|
- name: global install, via install.sh itself (the #71 layout, versioned)
|
||||||
# Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove
|
# install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this
|
||||||
# the code under review. Same tree, same layout, same a+rX.
|
# 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.
|
||||||
run: |
|
run: |
|
||||||
sudo cp -r . /opt/box
|
sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
|
||||||
sudo rm -rf /opt/box/.git
|
# assert what landed: the layout, the chain, and that it answers
|
||||||
sudo chmod -R a+rX /opt/box
|
readlink -f /usr/local/bin/box | grep '^/opt/box/versions/'
|
||||||
sudo ln -sf /opt/box/bin/box /usr/local/bin/box
|
/usr/local/bin/box --version
|
||||||
|
/usr/local/bin/box versions
|
||||||
- name: setup-host
|
- name: setup-host
|
||||||
run: sudo bash /opt/box/host/setup-host.sh
|
run: sudo bash /opt/box/current/host/setup-host.sh
|
||||||
- name: doctor — the baseline is provable before anything is judged
|
- name: doctor — the baseline is provable before anything is judged
|
||||||
run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh
|
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
|
||||||
- name: multi-user rehearsal (criteria a-l, container mode)
|
- name: multi-user rehearsal (criteria a-l, container mode)
|
||||||
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container
|
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
|
||||||
|
|
||||||
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole
|
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole
|
||||||
# surface — cold template mints, expose, migration — and wants a real host
|
# surface — cold template mints, expose, migration — and wants a real host
|
||||||
|
|
|
||||||
13
.github/workflows/labels.yml
vendored
Normal file
13
.github/workflows/labels.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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
|
||||||
18
.github/workflows/release.yml
vendored
Normal file
18
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
||||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# AGENTS.md — start at .ceremony/
|
||||||
|
|
||||||
|
This repository is governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||||
|
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||||
|
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||||
|
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||||
323
CHANGELOG.md
323
CHANGELOG.md
|
|
@ -5,124 +5,161 @@ which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
## 0.6.0 — 2026-07-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
|
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
|
||||||
runs `box grant <user>` and that user gets their own boxes on the same
|
on the shared hardened `boxnet` (#74)
|
||||||
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
|
- CI runs the multi-user rehearsal on a real Incus
|
||||||
(`--purge` deletes their world, and asserts the absence). The tier rides
|
- Global / root install — one world-readable tree at `/opt/box` (#71)
|
||||||
incus-user, whose defaults miss box's contract three measured ways (Debian
|
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
|
||||||
13 / Incus 6.0.4): a private _unhardened_ NAT bridge per user, snapshots
|
|
||||||
blocked, the `box-net` profile invisible — so grant is an idempotent
|
|
||||||
convergence: project narrowed to `boxnet` **and only boxnet** (listing the
|
|
||||||
private bridge too, the obvious fix, would keep an unhardened network one
|
|
||||||
`--network` flag away), snapshots allowed, the shipped profile installed
|
|
||||||
into their project. `box_tier()` (live credentials, argless `id -nG`)
|
|
||||||
drives the tier-aware surface: `expose` refuses honestly before any daemon
|
|
||||||
call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed
|
|
||||||
end-to-end by `drill/multiuser.sh` (criteria a–n: confinement, lifecycle,
|
|
||||||
cross-user visibility, name collisions, the in-box isolation contract,
|
|
||||||
escape hatches, re-sync survival, revoke incl. the live-session case) —
|
|
||||||
54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review.
|
|
||||||
- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal`
|
|
||||||
job stands up the full stack on the runner (setup-host, doctor, then
|
|
||||||
`multiuser.sh --container`), so every PR proves the tier's semantics
|
|
||||||
against a live daemon, not a mock. The VM trust boundary itself remains a
|
|
||||||
real-hardware ritual, like the full drill.
|
|
||||||
- **Global / root install** (#71) — run as root, box installs _once_ to
|
|
||||||
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
|
|
||||||
every operator on a shared host runs the same tree. Per-user installs are
|
|
||||||
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
|
|
||||||
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
|
|
||||||
got `command not found` — so the root branch lands in a system location and
|
|
||||||
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
|
|
||||||
unblocks "rig installs box" (rig#24's `box` role).
|
|
||||||
- **CI + a test suite** — `.github/workflows/ci.yml` (a `check` job: globstar
|
|
||||||
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
|
|
||||||
dependency-free and runnable by a non-root user with no Incus. It exercises the
|
|
||||||
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
|
|
||||||
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
|
|
||||||
tmux in every template — the box was the repo with "no tests and no CI".
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **`box restore` never worked against Incus 6** — the command table
|
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
|
||||||
dispatched `incus restore`, a subcommand that does not exist (Incus 6
|
which does not exist
|
||||||
spells it `incus snapshot restore`), so every restore died on "unknown
|
- `box tmux` works on every template — tmux is in each template's package list
|
||||||
command". Found by #74's rehearsal exercising the full lifecycle as a
|
(#65)
|
||||||
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
|
- `box setup-host` finishes in one run, re-execing itself under
|
||||||
`test/cli.sh` now hold it.
|
`sg incus-admin` (#63)
|
||||||
- **`box tmux` works on every template** (#65) — `box tmux` runs
|
- `setup-host` works as root, with or without `sudo`
|
||||||
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
|
- `setup-host` grants `incus-admin` to the human, not to root
|
||||||
it failed with `tmux: command not found`. `tmux` is now in each template's
|
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
|
||||||
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
|
- `setup-host`'s apt calls can no longer hang on the dpkg lock
|
||||||
|
|
||||||
- **`box setup-host` finishes in one run** (#63). When it had to add you to
|
|
||||||
`incus-admin` it stopped there and told you to re-login and re-run — an
|
|
||||||
`exit 0` that reported success having built none of the stack: no `boxnet`,
|
|
||||||
no ACL, no `box-net` profile, no firewall. It now re-execs itself under
|
|
||||||
`sg incus-admin` and completes in that one invocation. The membership check
|
|
||||||
was also asking the wrong question: `id -nG "$USER"` reads the group
|
|
||||||
database, which lists the group the moment `usermod` returns, so a
|
|
||||||
same-session re-run passed the check with credentials that still lacked the
|
|
||||||
group and died further down on a bare permission error from `incus`. Argless
|
|
||||||
`id -nG` asks the process what it actually holds.
|
|
||||||
|
|
||||||
- **`setup-host` works as root, with or without `sudo`** — every privileged
|
|
||||||
call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package)
|
|
||||||
it died on `sudo: command not found` before doing anything. Privilege is now
|
|
||||||
resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if
|
|
||||||
neither is possible. This is what made `install.sh`'s root path real rather
|
|
||||||
than nominal.
|
|
||||||
- **`setup-host` grants `incus-admin` to the human, not to root** — under
|
|
||||||
`sudo install.sh` it would have added `root` to the group: a no-op (UID 0
|
|
||||||
opens the socket regardless) that also left the actual user locked out of
|
|
||||||
their own boxes. It now derives the login user from `SUDO_USER`.
|
|
||||||
- **`box-firewall.service` now reports its state honestly** — the unit is
|
|
||||||
`Type=oneshot` and was missing `RemainAfterExit=yes`, so it went
|
|
||||||
`inactive (dead)` the instant it succeeded: a host whose isolation was
|
|
||||||
perfectly live read as one whose firewall unit had died. drill.sh sends you
|
|
||||||
to `systemctl status box-firewall` to diagnose exactly that, and
|
|
||||||
setup-host.sh's own comment already asserted the unit "is RemainAfterExit" —
|
|
||||||
it was not. Found by running the drill on a real host and mistrusting the
|
|
||||||
green: `nft list table bridge box` showed the drop live while the unit read
|
|
||||||
dead. `restart` was and remains correct either way.
|
|
||||||
- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has
|
|
||||||
`apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain
|
|
||||||
`apt-get install` waits on it silently and indefinitely. Now bounded
|
|
||||||
(`DPkg::Lock::Timeout=300`) and non-interactive, which matters because
|
|
||||||
`install.sh` runs it with nobody watching.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
|
- `drill.sh` asserts the post-install stack instead of building it itself
|
||||||
`setup-host` itself right after installing, so the stack existed by its own
|
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
|
||||||
hand and a run passed identically whether or not `install.sh` had done a
|
- `install.sh` never overwrites an existing install
|
||||||
thing; a fresh run converged the stack three times, while the messages still
|
|
||||||
described the pre-#63 "first pass may only add you to the group" behaviour.
|
|
||||||
It now asserts the post-install stack in-group before touching the host, and
|
|
||||||
runs `setup-host` exactly once more — after the clean, which deliberately
|
|
||||||
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
|
|
||||||
hands sequencing back to the drill. Pre-setup tripwires now read _before_
|
|
||||||
`install.sh`, since that is what triggers setup now.
|
|
||||||
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
|
|
||||||
prompts _"Install box?"_, then on a fresh host installs the tree and asks a
|
|
||||||
second question, _"Set up this machine as a box host now?"_, running the whole
|
|
||||||
isolation stack if you say yes (previously it only printed a warning and left
|
|
||||||
you a command, so the install reported success and `box new` died on a host
|
|
||||||
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
|
|
||||||
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
|
|
||||||
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
|
|
||||||
- **`install.sh` never overwrites an existing install** — if box is already
|
|
||||||
installed it says so and changes nothing, so a stray re-run can no longer
|
|
||||||
clobber a working tree or rebuild the host stack under live boxes. Upgrading
|
|
||||||
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
|
|
||||||
preserved first) and install fresh. This replaces the earlier version-diff
|
|
||||||
refusal with a simpler rule that dissolves the same class of errors. The
|
|
||||||
version-aware upgrade that migrates boxes instead is #67; a portable
|
|
||||||
`box export` so a box survives its own deletion is #70.
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-15
|
## 0.5.0 — 2026-07-15
|
||||||
|
|
||||||
|
|
@ -133,61 +170,27 @@ installer retires — but nothing current carries the old name.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
|
- `codex` and `grok` templates
|
||||||
creds-free like every template. The template mechanic (image + user +
|
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
|
||||||
resources, never a network or a `security.*` key) now has three tenants
|
inside a box
|
||||||
beside `blank`, and the drill mints all of them cold.
|
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
|
||||||
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
|
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
|
||||||
door to a port inside a box, for seeing a dev server in your browser. The
|
`box migrate-host`
|
||||||
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
|
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
|
||||||
is per-port, `--list`/`--remove` manage it, and `box info` shows open
|
read)
|
||||||
exposures — a box with a hole says so.
|
|
||||||
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
|
|
||||||
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
|
|
||||||
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
|
|
||||||
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
|
|
||||||
flag can touch. `--from` refuses them — a clone carries its source's
|
|
||||||
resources.
|
|
||||||
- **Host lifecycle as verbs** — `box setup-host`, `box teardown-host`, and
|
|
||||||
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
|
|
||||||
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
|
|
||||||
bridge once empty (`--retire-legacy`).
|
|
||||||
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
|
|
||||||
can ship, renamed from `.claudebox/` (both spellings read).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
|
- VM mints no longer hang at GRUB — boxes launch with
|
||||||
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
|
`security.secureboot=false`
|
||||||
shim signature" forever. Boxes now launch with `security.secureboot=false`;
|
- `box expose` actually delivers packets
|
||||||
the VM boundary, not boot attestation, is the box threat model.
|
- Firewall rules converge on upgrade instead of pinning a host to the release
|
||||||
- **`box expose` actually delivers packets** — a trilogy of drill-found
|
that first ran there
|
||||||
absences: the NAT proxy needs the box's boxnet lease pinned as a static
|
- Failed mints tell you why
|
||||||
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
|
- `grok` installs the binary it actually ships
|
||||||
the lease); a loopback-sourced packet needs `route_localnet` plus a
|
|
||||||
masquerade on the bridge to leave the host and be answerable; and the box's
|
|
||||||
replies need a `ct state established,related` accept ahead of the host
|
|
||||||
firewall's input drop, which was eating them statelessly. Boxes still
|
|
||||||
cannot initiate toward the host — a box-originated SYN is a NEW flow.
|
|
||||||
- **Firewall rules now converge on upgrade** — `box-firewall.sh` rebuilds its
|
|
||||||
chains every run (add + flush + re-add) instead of skipping when they
|
|
||||||
exist, which had pinned every host to the rule set of the release that
|
|
||||||
first ran there.
|
|
||||||
- **Failed mints tell you why** — cloud-init failures print the box's own log
|
|
||||||
excerpts and leave the box up to inspect; a mint that never boots names the
|
|
||||||
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
|
|
||||||
console dump; the installer asserts it landed the ref it was asked for.
|
|
||||||
- **`grok` installs the binary it actually ships** — the installer was read,
|
|
||||||
not guessed at, and the CLI lands on the non-interactive PATH (same fix
|
|
||||||
class as codex).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Debrand complete** — env vars, install dir, docs, template descriptions
|
- Debrand complete — env vars, install dir, docs, template descriptions and
|
||||||
and the README all say `box`; the install URL is
|
the README all say `box`; the install URL is `heavy-duty/box`
|
||||||
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
|
- The drill grew from 47 to 84 checks
|
||||||
- **The drill grew from 47 to 84 checks** — the expose door opened, exercised
|
|
||||||
and shut (with the contract re-probed around it), every template minted
|
|
||||||
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
|
|
||||||
inline resource flags asserted (including their precedence over the
|
|
||||||
environment).
|
|
||||||
|
|
|
||||||
83
CONTRIBUTING.md
Normal file
83
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 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, GitHub’s 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 harness’s 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: rig’s drill uses the candidate box ref, while
|
||||||
|
box’s 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
|
||||||
406
README.md
406
README.md
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||||
network-isolated Incus box from a **template**; the coding-agent templates
|
network-isolated Incus box from a **template**; the coding-agent templates
|
||||||
ship a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
||||||
Codex), `grok` (xAI Grok). The box is the product — you log in and work;
|
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||||
destroying it loses nothing you didn't push.
|
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.
|
||||||
|
|
||||||
**Strictly creds-free.** A box ships with everything installed and **no**
|
**Strictly creds-free.** A box ships with everything installed and **no**
|
||||||
credentials — no agent token, no git PAT, nothing. You authenticate
|
credentials — no agent token, no git PAT, nothing. You authenticate
|
||||||
|
|
@ -42,29 +45,66 @@ design rationale.
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
It asks first — **"Install box?"** — then, if box is not already installed,
|
By default that installs the **latest release** — the installer resolves the
|
||||||
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
|
release tag off GitHub's `releases/latest` redirect (no API, no token) and
|
||||||
asks a second question: **"Set up this machine as a box host now?"** Say yes and
|
downloads exactly that tree, so two operators running it get the same box.
|
||||||
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
|
If the resolution fails it says so and stops — it never silently hands out
|
||||||
you can run `box setup-host` later. (No `git clone` needed.)
|
`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)):
|
||||||
|
|
||||||
**Re-running is a safe no-op.** If box is already installed, the installer tells
|
```sh
|
||||||
you so and changes nothing — a stray re-run can never clobber your install or
|
curl -fsSL .../install.sh | bash # the latest release (default)
|
||||||
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
|
curl -fsSL .../install.sh | BOX_REF=0.6.0 bash # pin a release
|
||||||
what you have and install fresh. Preserve any boxes first — `box down <box>`,
|
curl -fsSL .../install.sh | BOX_REF=main bash # the development tip
|
||||||
copy out anything you need (a portable `box export` is
|
```
|
||||||
|
|
||||||
|
(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 —
|
||||||
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
||||||
(which deletes the box _and_ its snapshots) — then:
|
(which deletes the box _and_ its snapshots) — then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
|
box versions # what is installed, which is current, which is running
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
box use <version> # flip the default (same refusal while boxes exist)
|
||||||
```
|
```
|
||||||
|
|
||||||
A version-aware upgrade that migrates boxes instead of asking you to is
|
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
|
||||||
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
|
next installer run — the tree is moved, not re-downloaded, and your boxes are
|
||||||
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
|
untouched. After switching versions (and `box setup-host`, if the stack was
|
||||||
declines the host-setup step.
|
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).
|
||||||
|
|
||||||
### Global vs per-user install
|
### Global vs per-user install
|
||||||
|
|
||||||
|
|
@ -83,7 +123,9 @@ 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
|
`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
|
install under `/root` would be `0700` and unreadable to everyone else — which
|
||||||
is exactly the bug the root branch fixes.
|
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.
|
||||||
|
|
||||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||||
|
|
||||||
|
|
@ -103,6 +145,24 @@ re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
|
||||||
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
||||||
`sudo apt-get install -y dnsmasq-base`.
|
`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`
|
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
|
re-homes each legacy box onto `boxnet` (authed state preserved), and
|
||||||
`box migrate-host --retire-legacy` removes the old bridge and profile once no
|
`box migrate-host --retire-legacy` removes the old bridge and profile once no
|
||||||
|
|
@ -138,7 +198,8 @@ 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
|
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
|
restricted to `boxnet` **and only boxnet** — the hardened network is not their
|
||||||
default placement but the only one their certificate can express — snapshots
|
default placement but the only one their certificate can express — snapshots
|
||||||
are allowed, and the shipped profile is installed into their project. Re-run
|
and backups are allowed (the clone and `box export` workflows), and the
|
||||||
|
shipped profile is installed into their project. Re-run
|
||||||
`box grant <user>` after upgrading box to refresh the profile, like
|
`box grant <user>` after upgrading box to refresh the profile, like
|
||||||
`setup-host` for the stack.
|
`setup-host` for the stack.
|
||||||
|
|
||||||
|
|
@ -156,13 +217,14 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box new --name work --template claude # a creds-free coding-agent box (~10 min cold)
|
box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
|
||||||
box shell work # enter as the template's user
|
box shell work # enter as the template's user
|
||||||
```
|
```
|
||||||
|
|
||||||
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
|
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
||||||
`blank` for none. Inside the box, authenticate as needed. The `claude` template
|
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||||
looks like this; the others follow the same shape with their own login step:
|
`claude-box` template looks like this; the others follow the same shape with
|
||||||
|
their own login step:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
claude # then run /login — copy the URL (press c), open it
|
claude # then run /login — copy the URL (press c), open it
|
||||||
|
|
@ -177,23 +239,68 @@ claude # if the repo has .box/, the agent reads it and
|
||||||
No coding agent is special — each is one template among several, and adding
|
No coding agent is special — each is one template among several, and adding
|
||||||
another is just another directory. What ships today:
|
another is just another directory. What ships today:
|
||||||
|
|
||||||
| Template | What's in it |
|
| Template | What it becomes |
|
||||||
| -------- | --------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------ |
|
||||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||||
| `claude` | Claude Code, creds-free — where this project started |
|
| `claude-box` | Claude Code, creds-free — where this project started |
|
||||||
| `codex` | OpenAI Codex CLI, creds-free |
|
| `codex-box` | OpenAI Codex CLI, creds-free |
|
||||||
| `grok` | xAI Grok CLI, creds-free |
|
| `grok-box` | xAI Grok CLI, creds-free |
|
||||||
|
| `kimi-box` | Moonshot Kimi CLI, creds-free |
|
||||||
|
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||||
|
|
||||||
A template is a directory under `templates/`: a `box.env` (image, user,
|
**Templates are thin seeds; rig does the becoming**
|
||||||
resources — parsed against a strict allowlist, never sourced) and a
|
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
||||||
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
|
directory under `templates/`: a `box.env` (image, user, resources, boot
|
||||||
templates are all the same shape — install the CLI, put it on PATH, drop an
|
demands, tenant role — parsed against a strict allowlist, never sourced) and
|
||||||
agent-context file; none of them carry credentials.
|
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.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box templates # list what this install can mint
|
box templates # list what this install can mint
|
||||||
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
||||||
# same isolation, nobody home
|
# same isolation, nobody home — no rig, no role
|
||||||
```
|
```
|
||||||
|
|
||||||
A template **cannot** name a network, a profile, or a `security.*` flag —
|
A template **cannot** name a network, a profile, or a `security.*` flag —
|
||||||
|
|
@ -218,11 +325,124 @@ box new --name feature --from work/authed # clone the authed state into a new
|
||||||
|
|
||||||
`--from` copies the whole box (agent login, git creds, clones and all) while
|
`--from` copies the whole box (agent login, git creds, clones and all) while
|
||||||
preserving isolation. You can also `box new --name x --from work` to clone
|
preserving isolation. You can also `box new --name x --from work` to clone
|
||||||
a box's live state, or roll a box back with `box restore work authed`.
|
a box's live state, or roll a box back with `box restore work authed` — which
|
||||||
|
asks first, since a rollback discards everything since the snapshot (`--force`
|
||||||
|
skips the prompt, and scripts must pass it: with no terminal to ask on, box
|
||||||
|
refuses rather than assuming yes).
|
||||||
|
|
||||||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||||
snapshot labels and the `--from` line to clone one.
|
snapshot labels and the `--from` line to clone one.
|
||||||
|
|
||||||
|
### `pristine` — the one checkpoint box takes for you
|
||||||
|
|
||||||
|
Every fresh mint marks a snapshot called `pristine`
|
||||||
|
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
|
||||||
|
is true: **after cloud-init, before `rig bootstrap` converges the tenant
|
||||||
|
role.** At that instant the guest is pristine Debian plus box's thin seed
|
||||||
|
(the user, tmux, rig) and nothing else — the state
|
||||||
|
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
|
||||||
|
to pristine Debian". It exists for a few seconds on every mint, so box
|
||||||
|
captures it rather than asking you to be quick.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box restore work pristine # undo the tenant role and everything since
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a complete undo for every tenant role: everything `rig bootstrap
|
||||||
|
claude|codex|grok|staging` does — docker, node, the agent CLI, the
|
||||||
|
agent-context file, the role marker — is box-local and file-shaped, so a
|
||||||
|
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
|
||||||
|
|
||||||
|
Three things it deliberately does not do:
|
||||||
|
|
||||||
|
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
|
||||||
|
deletes a box _and_ every snapshot it has. `box export` is the only state
|
||||||
|
that outlives the box — see below.
|
||||||
|
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
|
||||||
|
registration, a pushed commit: those are records held somewhere else, and
|
||||||
|
no filesystem rollback undoes them (rig#62 covers those separately).
|
||||||
|
- **A `--from` clone gets no `pristine` of its own.** A clone skips
|
||||||
|
cloud-init and rig entirely, so it has no pristine moment to capture, and
|
||||||
|
box will not label a source's worked-in state as one. Cloning a _box_
|
||||||
|
inherits the source's snapshots (a real `pristine` among them, if the
|
||||||
|
source had one); cloning a _snapshot_ starts with none. `box new` says
|
||||||
|
which of the two you got.
|
||||||
|
|
||||||
|
On a host whose storage pool uses the `dir` driver, a snapshot is a full
|
||||||
|
multi-GB copy rather than a near-free copy-on-write mark, so the mint
|
||||||
|
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
|
||||||
|
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
|
||||||
|
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
|
||||||
|
skips the mark on any host.
|
||||||
|
|
||||||
|
### `bootstrapped` — the same undo, one step later
|
||||||
|
|
||||||
|
A mint whose template names a bootstrap role marks a second snapshot,
|
||||||
|
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
|
||||||
|
`rig bootstrap` has run and box has **watched it succeed**. That is the box
|
||||||
|
converged and not yet touched — the state you actually wanted back most of
|
||||||
|
the time.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box restore work bootstrapped # keep the tenant role, undo what you did to it
|
||||||
|
box restore work pristine # throw the tenant role away too
|
||||||
|
```
|
||||||
|
|
||||||
|
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
|
||||||
|
pool (with two marks that disk cost is twice the size, so a CoW-less host is
|
||||||
|
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
|
||||||
|
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
|
||||||
|
(`box export` is the durable path), and it **cannot reach off-box state** —
|
||||||
|
a tailnet device record, a runner registration (rig#62).
|
||||||
|
|
||||||
|
**Read the label in one direction only.** Its _presence_ means the mint-time
|
||||||
|
hook converged and nothing has touched the box since. Its _absence_ means
|
||||||
|
nothing at all, because box only marks a hook it watched:
|
||||||
|
|
||||||
|
- A **blank** box runs no hook, so there is no convergence to mark. It gets
|
||||||
|
none — `pristine` and `bootstrapped` would be the same disk state at twice
|
||||||
|
the cost, and a label claiming a convergence that never happened is worse
|
||||||
|
than no label.
|
||||||
|
- A box whose hook **failed** gets none either. box tells you to re-run the
|
||||||
|
role by hand through `box shell`, and a by-hand run happens in a shell box
|
||||||
|
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
|
||||||
|
at the moment it is true, rather than inventing a fact.
|
||||||
|
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
|
||||||
|
|
||||||
|
`box info <box>` is what actually tells you which labels a box has.
|
||||||
|
|
||||||
|
## Survive the host: `box export` / `box import`
|
||||||
|
|
||||||
|
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`
|
## See a dev server: `box expose`
|
||||||
|
|
||||||
The isolation contract says no inbound path exists — which is one "no" too
|
The isolation contract says no inbound path exists — which is one "no" too
|
||||||
|
|
@ -249,12 +469,21 @@ the door is per-port, punched and removable at runtime.
|
||||||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
||||||
box templates # list the templates this install can mint
|
box templates # list the templates this install can mint
|
||||||
box list # list your boxes
|
box list # list your boxes
|
||||||
box info <box> # one box: state, IP, exposures, snapshot labels
|
box info <box> # one box: state, IP, exposures, provenance, snapshots
|
||||||
box shell <box> # enter as the template's user
|
box shell <box> # enter as the template's user
|
||||||
box exec <box> -- <cmd...> # run a command in the box
|
box exec <box> -- <cmd...> # run a command in the box
|
||||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||||
box restore <box> <snap> # roll back to a snapshot
|
box restore <box> <snap> [--force]
|
||||||
|
# roll back to a snapshot — destructive, asks first
|
||||||
|
# 'pristine' is auto-marked at mint: back to
|
||||||
|
# pristine Debian + box's seed, before rig ran
|
||||||
|
# 'bootstrapped' too, if a rig hook converged:
|
||||||
|
# the role kept, everything since undone
|
||||||
|
box export <box> [<file>] [--instance-only]
|
||||||
|
# one portable file (snapshots incl.) — survives rm & host
|
||||||
|
box import <file> [--name <box>]
|
||||||
|
# mint a box back from an exported file, re-stamped
|
||||||
box rename <box> <new> # rename a box (stop it first)
|
box rename <box> <new> # rename a box (stop it first)
|
||||||
box down <box> # stop (state kept; `start` resumes)
|
box down <box> # stop (state kept; `start` resumes)
|
||||||
box start <box> # start a stopped box
|
box start <box> # start a stopped box
|
||||||
|
|
@ -281,6 +510,82 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
|
||||||
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||||
dev/test only.
|
dev/test only.
|
||||||
|
|
||||||
|
## What minted this box: `box info`
|
||||||
|
|
||||||
|
A box outlives the release that minted it, the template that shaped it and the
|
||||||
|
image build it came from — and until
|
||||||
|
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
|
||||||
|
There is no host-side per-box store; the Incus instance config _is_ the
|
||||||
|
database, so a fact not written at mint time is simply gone. `box new` now
|
||||||
|
stamps what it knew, and `box info` reads it back:
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME work
|
||||||
|
STATE RUNNING
|
||||||
|
TYPE VM
|
||||||
|
IPV4 10.x.x.x
|
||||||
|
|
||||||
|
MINTED 2026-07-19T14:22:07Z by box 0.8.1
|
||||||
|
TEMPLATE claude (user claude, role claude)
|
||||||
|
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
|
||||||
|
MODE vm (asked: auto)
|
||||||
|
RIG heavy-duty/rig@main
|
||||||
|
ORIGIN mint
|
||||||
|
```
|
||||||
|
|
||||||
|
The image line carries both halves on purpose: the template names an
|
||||||
|
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
|
||||||
|
only reproducible fact. `box info --json` carries every key verbatim — they
|
||||||
|
ride `incus list --format json` in `config`.
|
||||||
|
|
||||||
|
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
|
||||||
|
its source's template and user for free — but inheriting the mint stamp would
|
||||||
|
not make it stale, it would make it **false**: the clone was not present at that
|
||||||
|
mint. `box new --from` therefore re-stamps the four keys that describe _this_
|
||||||
|
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
|
||||||
|
box version that cloned it) and leaves the lineage keys alone, because the
|
||||||
|
clone's disk genuinely did come from that image, template and role. `origin.from`
|
||||||
|
records one hop: a clone of a clone names its parent, not its grandparent.
|
||||||
|
|
||||||
|
**An import records the trip, and rewrites nothing**
|
||||||
|
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
|
||||||
|
import` restores is the _artifact's_ truth, so an imported box keeps its mint
|
||||||
|
stamp verbatim — the mint time, the box version, the image and the origin
|
||||||
|
belong to the originating host and survive the trip on purpose. What `box
|
||||||
|
import` adds is the one fact the artifact cannot carry: that the trip happened.
|
||||||
|
|
||||||
|
```
|
||||||
|
MINTED 2026-06-01T10:00:00Z by box 0.7.0
|
||||||
|
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
|
||||||
|
ORIGIN clone of work/authed
|
||||||
|
```
|
||||||
|
|
||||||
|
It is **not** `origin=import`, and the difference is the whole point. `origin`
|
||||||
|
answers how the instance came into _being_ — mint or clone — and overwriting it
|
||||||
|
would destroy that: the clone above would come back claiming to be an import,
|
||||||
|
with nothing left saying it was ever a clone and an `origin.from` naming a
|
||||||
|
lineage no key explains. The import is a _third_ fact, orthogonal to the first
|
||||||
|
two, so it takes its own keys and leaves every other one alone.
|
||||||
|
|
||||||
|
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
|
||||||
|
stops the mint time being misread as this host's. Note what it does not claim:
|
||||||
|
box has no record of _which_ host minted the box, and a box can be exported and
|
||||||
|
re-imported onto the same host (that is the upgrade flow above), so the line
|
||||||
|
states only the ordering — the one thing box actually knows.
|
||||||
|
|
||||||
|
**A box can make the trip more than once**, and both ends are kept: the first
|
||||||
|
import is pinned forever, the latest is refreshed on every arrival, and a count
|
||||||
|
says how many. Last-wins alone would erase the evidence of the earlier trips,
|
||||||
|
which is the same mistake `origin=import` makes one level up. (The shape
|
||||||
|
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
|
||||||
|
manifest: a birth pair plus a latest pair.)
|
||||||
|
|
||||||
|
**Boxes minted before this stamp existed keep working**, under this verb and
|
||||||
|
every other — they render as a box with blanks and say `MINTED (not recorded)`
|
||||||
|
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
|
||||||
|
not the box version) so a box minted by a later release reads back on an older
|
||||||
|
box as "here is what I understand, and there is more I don't".
|
||||||
|
|
||||||
## Boxes are just Incus instances
|
## Boxes are just Incus instances
|
||||||
|
|
||||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||||
|
|
@ -387,12 +692,29 @@ documentation, not a host-executed script. See
|
||||||
|
|
||||||
## Uninstall
|
## 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
|
```sh
|
||||||
box teardown-host # boxes, network, ACL, profile, firewall
|
box uninstall <version> # one non-current version (side-by-side cleanup)
|
||||||
box teardown-host --purge-incus # ...and Incus itself
|
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
|
||||||
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Non-goals
|
||||||
|
|
||||||
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.0
|
0.9.1-dev
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# box design
|
# box design
|
||||||
|
|
||||||
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
||||||
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for
|
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
||||||
none). It is infrastructure, not a project provisioner.
|
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
|
||||||
|
|
||||||
See issue #3 for the full reframe and rationale. This doc captures the durable
|
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||||
design decisions.
|
design decisions.
|
||||||
|
|
@ -43,13 +43,82 @@ snapshots, not a secrets store:
|
||||||
|
|
||||||
Log in once → snapshot → spin up authed boxes from it.
|
Log in once → snapshot → spin up authed boxes from it.
|
||||||
|
|
||||||
|
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
|
||||||
|
after cloud-init and before the `rig bootstrap` hook (#104, child of
|
||||||
|
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
|
||||||
|
installed but not yet run — is the state "back to pristine Debian" names, it
|
||||||
|
lasts a few seconds, and nobody is standing there to snapshot it. `box
|
||||||
|
restore <n> pristine` undoes a tenant role wholesale, because everything the
|
||||||
|
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
|
||||||
|
`pristine`: it never has the moment, so it inherits its source's snapshots or
|
||||||
|
has none, and box refuses to label a worked-in state as pristine. On a `dir`
|
||||||
|
pool the mark would be a full copy rather than a CoW one, so the mint skips
|
||||||
|
it loudly.
|
||||||
|
|
||||||
|
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
|
||||||
|
hook has run and box has *watched it succeed*. Same policy, literally the same
|
||||||
|
function: default on, never fatal, skipped on `dir`, opt-out via
|
||||||
|
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
|
||||||
|
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
|
||||||
|
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
|
||||||
|
therefore no event — marking it anyway would duplicate `pristine` byte for
|
||||||
|
byte at twice the disk cost, or assert a convergence that never happened. A
|
||||||
|
failed hook gets no mark either: box tells the operator to re-run the role
|
||||||
|
through `box shell`, which is a run box does not watch, so it hands over `box
|
||||||
|
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
|
||||||
|
that is only ever read one way — **presence** means "converged and untouched";
|
||||||
|
**absence** means nothing, and nothing in box may treat a missing
|
||||||
|
`bootstrapped` as evidence a box is unconverged.
|
||||||
|
|
||||||
|
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
||||||
|
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
|
## The box announces itself to the agent
|
||||||
|
|
||||||
cloud-init installs a global agent-context file in every coding-agent box
|
Every coding-agent box gets a global agent-context file
|
||||||
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
|
(`~/.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
|
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
|
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
|
||||||
execution.
|
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.
|
||||||
|
|
||||||
## `.box/` is optional, agent-facing documentation
|
## `.box/` is optional, agent-facing documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
||||||
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
||||||
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows
|
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
|
||||||
**nothing** about your project. There is no `install` step and no host-run
|
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
|
||||||
setup script.
|
host-run setup script.
|
||||||
|
|
||||||
A project makes itself easy to stand up inside a box by shipping an optional
|
A project makes itself easy to stand up inside a box by shipping an optional
|
||||||
`.box/` folder. This folder is **agent-facing documentation** — read and
|
`.box/` folder. This folder is **agent-facing documentation** — read and
|
||||||
|
|
|
||||||
134
docs/plans/2026-07-18-box-export.md
Normal file
134
docs/plans/2026-07-18-box-export.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -127,11 +127,13 @@ It had never worked.
|
||||||
## Rehearsal and CI
|
## Rehearsal and CI
|
||||||
|
|
||||||
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
||||||
criteria (a)–(f) from #74 plus the measured extensions (g)–(n): the in-box
|
criteria (a)–(f) from #74 plus the measured extensions (g)–(o): the in-box
|
||||||
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
||||||
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
||||||
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure
|
scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
|
||||||
injections (n). Real users, real grants, real mints, probes from inside;
|
injections (n) and the `incus-admin`-only grant (o) — the last of these
|
||||||
|
because the shim suite models neither `INCUS_SOCKET` nor socket permissions,
|
||||||
|
so the group gate on `unix.socket.user` can only be measured live (#99, #101). Real users, real grants, real mints, probes from inside;
|
||||||
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
||||||
made.
|
made.
|
||||||
|
|
||||||
|
|
|
||||||
154
docs/plans/2026-07-18-versioned-install.md
Normal file
154
docs/plans/2026-07-18-versioned-install.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# 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).
|
||||||
165
drill/doctor.sh
165
drill/doctor.sh
|
|
@ -35,7 +35,108 @@ no() { printf ' \033[31mDIRTY\033[0m %s\n' "$*"; bad=$((bad + 1)); }
|
||||||
inf() { printf ' %s\n' "$*"; }
|
inf() { printf ' %s\n' "$*"; }
|
||||||
head_() { printf '\n\033[1m%s\033[0m\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; }
|
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 || {
|
timeout 10 incus list >/dev/null 2>&1 || {
|
||||||
echo "doctor: the incus daemon is not answering (see issue #26 for recovery):"
|
echo "doctor: the incus daemon is not answering (see issue #26 for recovery):"
|
||||||
echo " sudo pkill -9 -f 'incusd shutdown'"
|
echo " sudo pkill -9 -f 'incusd shutdown'"
|
||||||
|
|
@ -85,12 +186,21 @@ if [ "$TIER" = restricted ]; then
|
||||||
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
|
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
|
||||||
if [ -n "$probe" ]; then
|
if [ -n "$probe" ]; then
|
||||||
inf "probing inside '$probe':"
|
inf "probing inside '$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 \
|
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
|
||||||
&& ok "reaches 1.1.1.1 by address — egress routing is fine" \
|
routing=1; 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)"
|
else
|
||||||
timeout -k 5 25 incus exec "$probe" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1 \
|
routing=0; no "cannot reach 1.1.1.1 by address — egress routing is broken (an admin problem: box doctor as admin)"
|
||||||
&& ok "resolves deb.debian.org — DNS works" \
|
fi
|
||||||
|| no "CANNOT resolve deb.debian.org — an admin problem (the resolver pin lives on the host): box doctor as admin"
|
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"
|
||||||
else
|
else
|
||||||
inf "no running box to probe with (mint one: box new --name work)"
|
inf "no running box to probe with (mint one: box new --name work)"
|
||||||
fi
|
fi
|
||||||
|
|
@ -166,6 +276,26 @@ else
|
||||||
inf " (or: sudo systemctl restart box-firewall.service)"
|
inf " (or: sudo systemctl restart box-firewall.service)"
|
||||||
fi
|
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
|
# 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.
|
# pre-rename ancestor and may linger while legacy boxes still reference it.
|
||||||
# Check whichever exist — an unisolated NIC is a fault on either.
|
# Check whichever exist — an unisolated NIC is a fault on either.
|
||||||
|
|
@ -209,6 +339,22 @@ if incus network acl show box-isolate >/dev/null 2>&1; then
|
||||||
no "an @internal rule survived phase D"
|
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"; }
|
[ "$FIX" = 1 ] && { incus network acl rule remove box-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
|
||||||
fi
|
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
|
else
|
||||||
inf "box-isolate does not exist (a fresh host)"
|
inf "box-isolate does not exist (a fresh host)"
|
||||||
fi
|
fi
|
||||||
|
|
@ -329,6 +475,11 @@ 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
|
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"
|
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
|
else
|
||||||
no "CANNOT resolve deb.debian.org — this is exactly what kills cloud-init on every cold mint"
|
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
|
# Egress by address was probed above. If it worked, the fault is purely
|
||||||
|
|
@ -341,6 +492,8 @@ 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."
|
inf "…and it cannot reach 1.1.1.1 by address either — so egress itself is broken, not just DNS."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
probe_sig "$probe"
|
||||||
else
|
else
|
||||||
inf "no box to probe with (mint one, or run without --fix after a run)"
|
inf "no box to probe with (mint one, or run without --fix after a run)"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
|
||||||
# Read from inside a box WITHOUT ever hanging the drill.
|
# Read from inside a box WITHOUT ever hanging the drill.
|
||||||
#
|
#
|
||||||
# Two traps, both hit for real:
|
# Two traps, both hit for real:
|
||||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
|
||||||
# all). Fine for a person, needless machinery for a probe.
|
# needless machinery for a probe.
|
||||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||||
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
||||||
|
|
@ -94,14 +94,17 @@ in_box() {
|
||||||
# · and the interface is NOT called eth0. The PROFILE names the device eth0,
|
# · 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
|
# but inside a VM guest predictable naming renames it enp5s0. Six runs of
|
||||||
# A3 "not probed" were this, not the network.
|
# A3 "not probed" were this, not the network.
|
||||||
# So: read it from inside the box, and select by SUBNET (10.88.x, what boxnet
|
# So: read it from inside the box, and select by SUBNET (what boxnet hands
|
||||||
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
|
# out — read off the network, never hardcoded: BOX_SUBNET moves it, #80)
|
||||||
# and the NIC's name is the guest's business, not ours.
|
# 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; }
|
||||||
boxnet_ip() {
|
boxnet_ip() {
|
||||||
local b="$1" ip _i
|
local b="$1" ip _i pfx
|
||||||
|
pfx="$(boxnet_gw)"; pfx="${pfx%.*}."
|
||||||
for _i in $(seq 1 15); do
|
for _i in $(seq 1 15); do
|
||||||
ip="$(in_box "$b" ip -4 -o addr show scope global \
|
ip="$(in_box "$b" ip -4 -o addr show scope global \
|
||||||
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.88\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
|
| 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 } }')"
|
||||||
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
|
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
@ -202,10 +205,11 @@ EOF
|
||||||
export BOX_SKIP_SETUP_HOST=1
|
export BOX_SKIP_SETUP_HOST=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The installer is a no-op when box is already installed — upgrading is
|
# The installer converges when a version is already installed (0.7.0's
|
||||||
# uninstall-then-install, by design. The drill re-proves a tree from scratch
|
# versioned layout: re-running the same version is a no-op, and an upgrade
|
||||||
# every run, so it does the uninstall itself: clear any prior tree and symlink
|
# lands side-by-side without flipping under boxes). The drill re-proves a
|
||||||
# before installing, or install.sh would correctly refuse to touch them.
|
# tree from SCRATCH every run — a fresh host, not a converged one — so it
|
||||||
|
# removes the whole install root and symlink first.
|
||||||
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
|
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
|
||||||
|
|
||||||
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
|
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
|
||||||
|
|
@ -224,7 +228,7 @@ EOF
|
||||||
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
|
# 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
|
# the wrong tree while reporting success. A drill that silently drills the
|
||||||
# wrong code is worse than one that fails.
|
# wrong code is worse than one that fails.
|
||||||
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
|
got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
|
||||||
if [ "$got" != "$REPO@$REF" ]; then
|
if [ "$got" != "$REPO@$REF" ]; then
|
||||||
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
|
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
|
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
|
||||||
|
|
@ -302,7 +306,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 " 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 " is supposed to converge in one run (#63). One of those did not happen." >&2
|
||||||
echo " reproduce with the output visible:" >&2
|
echo " reproduce with the output visible:" >&2
|
||||||
echo " ~/.local/share/box/host/setup-host.sh" >&2
|
echo " ~/.local/share/box/current/host/setup-host.sh" >&2
|
||||||
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
|
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -361,7 +365,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
|
# proves is idempotency: a second run over a cleaned host is a no-op that
|
||||||
# restores the stack rather than a fresh build.
|
# restores the stack rather than a fresh build.
|
||||||
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
|
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
|
||||||
if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then
|
if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
|
||||||
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
|
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 " 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
|
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
|
||||||
|
|
@ -374,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then
|
||||||
fi
|
fi
|
||||||
inf "host setup complete"
|
inf "host setup complete"
|
||||||
|
|
||||||
# A real server has room for the claude template's resources (8GiB/4cpu), and
|
# A real server has room for the claude-box template's resources (8GiB/4cpu), and
|
||||||
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
||||||
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
||||||
# template at mint — a profile edit no longer reaches them; the supported
|
# template at mint — a profile edit no longer reaches them; the supported
|
||||||
|
|
@ -382,9 +386,9 @@ inf "host setup complete"
|
||||||
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||||
if [ "$ram" -lt 20 ]; then
|
if [ "$ram" -lt 20 ]; then
|
||||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
export BOX_MEMORY=3GiB BOX_CPU=2
|
||||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
|
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude-box template's 8GiB/4cpu is what was NOT drilled)"
|
||||||
else
|
else
|
||||||
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
|
inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||||
|
|
@ -469,7 +473,7 @@ phase "B. The box surface"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
||||||
# a pinned literal here would fail the drill on every release.
|
# a pinned literal here would fail the drill on every release.
|
||||||
expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')"
|
expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')"
|
||||||
v="$(box --version 2>&1)"
|
v="$(box --version 2>&1)"
|
||||||
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||||
|
|
||||||
|
|
@ -488,17 +492,17 @@ fi
|
||||||
|
|
||||||
# --- templates: the mint surface is itself a surface to test ----------------
|
# --- templates: the mint surface is itself a surface to test ----------------
|
||||||
tpl_missing=""
|
tpl_missing=""
|
||||||
for t in blank claude codex grok; do
|
for t in blank claude-box codex-box grok-box kimi-box; do
|
||||||
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
||||||
done
|
done
|
||||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
|
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|
||||||
|| no "templates listing is missing:$tpl_missing"
|
|| no "templates listing is missing:$tpl_missing"
|
||||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||||
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||||
# The one rule that keeps templates honest: no key can name a network. Plant a
|
# 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
|
# bad template in the installed tree (the drill owns this host), expect the
|
||||||
# parser to reject it BY NAME, remove it.
|
# parser to reject it BY NAME, remove it.
|
||||||
badt="$HOME/.local/share/box/templates/cbdrill-bad"
|
badt="$HOME/.local/share/box/current/templates/cbdrill-bad"
|
||||||
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
|
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'" \
|
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" \
|
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|
||||||
|
|
@ -547,19 +551,22 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
||||||
# once by blank+claude and needs no per-template repeat. What a NEW template
|
# once by blank+claude-box and needs no per-template repeat. What a NEW template
|
||||||
# still has to prove is its own payload: the CLI installs, lands on the
|
# still has to prove is its own payload: the CLI installs, lands on the
|
||||||
# non-interactive exec PATH, and answers --version. One mint each.
|
# non-interactive exec PATH, and answers --version. One mint each.
|
||||||
|
# The box NAME stays the bare agent name — it is what the pre-flight banner
|
||||||
|
# announces and what teardown deletes — while the TEMPLATE carries rig#76's
|
||||||
|
# family suffix. They are two different namespaces and only one of them moved.
|
||||||
for t in codex grok; do
|
for t in codex grok; do
|
||||||
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
||||||
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
|
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
|
||||||
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
|
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
|
||||||
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
||||||
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
|
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
|
||||||
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
|
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
|
||||||
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
||||||
else
|
else
|
||||||
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
|
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
|
||||||
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
|
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
|
||||||
# Do not throw the evidence away — say WHAT the installer actually left.
|
# Do not throw the evidence away — say WHAT the installer actually left.
|
||||||
# Do NOT throw the evidence away — say what the installer actually left
|
# Do NOT throw the evidence away — say what the installer actually left
|
||||||
|
|
@ -579,10 +586,10 @@ for t in codex grok; do
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
printf '\n minting a claude-box box (cold, ~10 min)…\n'
|
||||||
t0=$SECONDS
|
t0=$SECONDS
|
||||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
|
||||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
|
||||||
else
|
else
|
||||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
||||||
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
|
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
|
||||||
|
|
@ -692,10 +699,11 @@ fi
|
||||||
# nothing serves and read refused-vs-dropped — refused would mean the box's
|
# 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.
|
# 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.)
|
# (No background listener: one less process to leak, one less way to wedge.)
|
||||||
hv="$(box_probe archive http://10.88.0.1:8099)"
|
gw="$(boxnet_gw)"
|
||||||
|
hv="$(box_probe archive "http://$gw:8099")"
|
||||||
case "$hv" in
|
case "$hv" in
|
||||||
reachable|refused)
|
reachable|refused)
|
||||||
no "THE BOX'S PACKETS REACH THE HOST on 10.88.0.1:8099 [$hv] — the firewall rules are not holding"
|
no "THE BOX'S PACKETS REACH THE HOST on $gw:8099 [$hv] — the firewall rules are not holding"
|
||||||
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
|
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
|
||||||
dropped)
|
dropped)
|
||||||
ok "box → host is blocked (no path to the machine's sockets)"
|
ok "box → host is blocked (no path to the machine's sockets)"
|
||||||
|
|
@ -746,7 +754,7 @@ elif [ -n "$PEER_IP" ]; then
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
no "could not read peer's boxnet address — the sibling probe never ran"
|
no "could not read peer's boxnet address — the sibling probe never ran"
|
||||||
aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
|
aud "A3 sibling: NOT PROBED (no boxnet address on peer)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
|
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
|
||||||
|
|
@ -794,7 +802,7 @@ fi
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
phase "E. box expose — a deliberate loopback door (#55)"
|
phase "E. box expose — a deliberate loopback door (#55)"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# archive is a running claude box (node is installed). Start a DETACHED
|
# archive is a running claude-box box (node is installed). Start a DETACHED
|
||||||
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
|
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
|
||||||
# from the HOST's loopback. Then prove removing it closes the door, and that a
|
# from the HOST's loopback. Then prove removing it closes the door, and that a
|
||||||
# NON-exposed port still obeys the ingress drop — the feature must not
|
# NON-exposed port still obeys the ingress drop — the feature must not
|
||||||
|
|
@ -889,7 +897,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
|
# 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
|
# migrate-host.sh moves it onto the new stack with its identity intact, and
|
||||||
# retires the legacy stack only once it is empty.
|
# retires the legacy stack only once it is empty.
|
||||||
MIG="$HOME/.local/share/box/host/migrate-host.sh"
|
MIG="$HOME/.local/share/box/current/host/migrate-host.sh"
|
||||||
if [ ! -f "$MIG" ]; then
|
if [ ! -f "$MIG" ]; then
|
||||||
no "migrate-host.sh not installed — cannot drill the transition"
|
no "migrate-host.sh not installed — cannot drill the transition"
|
||||||
else
|
else
|
||||||
|
|
@ -935,7 +943,7 @@ else
|
||||||
|| no "migrate: legacy box is NOT on box-net"
|
|| no "migrate: legacy box is NOT on box-net"
|
||||||
lip="$(boxnet_ip legacybox)"
|
lip="$(boxnet_ip legacybox)"
|
||||||
[ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \
|
[ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \
|
||||||
|| no "migrate: legacy box has no 10.88 address — the move did not take"
|
|| no "migrate: legacy box has no boxnet address — the move did not take"
|
||||||
in_box legacybox getent hosts deb.debian.org >/dev/null 2>&1 \
|
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" \
|
&& ok "migrate: re-homed box resolves + reaches the internet on its new leg" \
|
||||||
|| no "migrate: re-homed box cannot resolve on boxnet"
|
|| no "migrate: re-homed box cannot resolve on boxnet"
|
||||||
|
|
@ -985,5 +993,5 @@ fi
|
||||||
echo
|
echo
|
||||||
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
|
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 "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
|
||||||
inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]"
|
inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
# host-owned control — the scoped guarantee, measured (#75 review)
|
# host-owned control — the scoped guarantee, measured (#75 review)
|
||||||
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
||||||
# pre-existing member warned loudly, re-run converges (#75 review)
|
# pre-existing member warned loudly, re-run converges (#75 review)
|
||||||
|
# o. an incus-admin-ONLY member is provisioned for real: the group step
|
||||||
|
# opens incus-user's socket, the lazy project appears, and dropping
|
||||||
|
# incus-admin lands them in it with no re-grant (#99, #101 review)
|
||||||
#
|
#
|
||||||
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
||||||
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
||||||
|
|
@ -105,11 +108,16 @@ probe_up() { # probe_up <user> <box> <url>
|
||||||
echo "$r"
|
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() {
|
cleanup() {
|
||||||
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
||||||
echo
|
echo
|
||||||
echo "── cleanup"
|
echo "── cleanup"
|
||||||
for u in "$U1" "$U2" boxdrill3 boxdrill4; do
|
for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do
|
||||||
id "$u" >/dev/null 2>&1 || continue
|
id "$u" >/dev/null 2>&1 || continue
|
||||||
# A half-failed purge followed by userdel leaves a project owned by
|
# A half-failed purge followed by userdel leaves a project owned by
|
||||||
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
||||||
|
|
@ -151,6 +159,13 @@ snaps="$(incus project get "$p1" restricted.snapshots 2>/dev/null)"
|
||||||
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|
||||||
|| no "restricted.snapshots = '$snaps' — box snapshot will refuse"
|
|| 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 \
|
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" \
|
&& 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"
|
|| ok "(h) $p1's default profile places no network — box-net is the only door"
|
||||||
|
|
@ -187,11 +202,42 @@ fi
|
||||||
rm -f "$mintlog"
|
rm -f "$mintlog"
|
||||||
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
||||||
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
||||||
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" \
|
# #169: compare the new explicit login-user boundary with sudo -i while the
|
||||||
|
# latter is still safe (one fixed `env` argv), then exercise the exact
|
||||||
|
# silent-success multiline shape that sudo -i used to corrupt.
|
||||||
|
target_user="$(as_u "$U1" incus config get mine user.box.user)"
|
||||||
|
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
|
||||||
|
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||||
|
exec_env="$(as_u "$U1" box exec mine -- env \
|
||||||
|
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||||
|
[ "$exec_env" = "$legacy_env" ] \
|
||||||
|
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|
||||||
|
|| {
|
||||||
|
no "(b) box exec changed the cross-user login environment (#169)"
|
||||||
|
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
|
||||||
|
}
|
||||||
|
as_u "$U1" box exec mine -- bash -lc '
|
||||||
|
set -o errexit -o nounset -o pipefail
|
||||||
|
touch /tmp/box-169-step-one
|
||||||
|
touch /tmp/box-169-step-two
|
||||||
|
' >/dev/null 2>&1 \
|
||||||
|
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
|
||||||
|
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
|
||||||
|
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|
||||||
|
|| no "(b) box exec corrupted a multiline command (#169)"
|
||||||
|
|
||||||
|
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
|
||||||
|
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|
||||||
|| no "(g) mine has no boxnet address in box info"
|
|| no "(g) mine has no boxnet address in box info"
|
||||||
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
|
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
|
||||||
as_u "$U1" box restore mine s1 >/dev/null 2>&1 && ok "(b) box restore mine s1 (the incus 6 'snapshot restore' spelling)" || no "(b) restore failed"
|
# --force, and it is the assertion as much as the fix: restore is destructive
|
||||||
|
# and now asks (#105), so an unattended drill MUST consent explicitly. Without
|
||||||
|
# the flag this line fails — correctly — because confirm() refuses rather than
|
||||||
|
# assuming yes when there is no terminal to ask on. Adding --force here is not
|
||||||
|
# working around the gate; it is the drill proving the gate is real, and that
|
||||||
|
# the documented non-interactive path through it works on real Incus.
|
||||||
|
as_u "$U1" box restore mine s1 --force >/dev/null 2>&1 && ok "(b) box restore mine s1 --force (the incus 6 'snapshot restore' spelling, through the #105 gate)" || no "(b) restore failed"
|
||||||
as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed"
|
as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed"
|
||||||
# c1 stays alive through phase g: it is the distinctly-NAMED sibling the
|
# c1 stays alive through phase g: it is the distinctly-NAMED sibling the
|
||||||
# enumeration probe needs (both users' primaries are 'mine' by design of d).
|
# enumeration probe needs (both users' primaries are 'mine' by design of d).
|
||||||
|
|
@ -217,14 +263,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)"
|
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)"
|
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>}"
|
inf "$U1's mine: ${ip1:-<no ip>} $U2's mine: ${ip2:-<no ip>}"
|
||||||
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
|
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
|
||||||
|
|
||||||
r="$(probe_up "$U1" mine https://1.1.1.1)"
|
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"
|
[ "$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 \
|
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"
|
&& ok "(g) public DNS resolves (via the pinned resolver)" || no "(g) DNS broken inside the box"
|
||||||
|
|
||||||
r="$(probe_from "$U1" mine "http://10.88.0.1:22")"
|
r="$(probe_from "$U1" mine "http://$(boxnet_gw):22")"
|
||||||
[ "$r" = dropped ] && ok "(g) box → host is dropped (gateway :22: $r)" || no "(g) box can reach the HOST: $r"
|
[ "$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="$(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"
|
[ "$r" = dropped ] && ok "(g) box → RFC1918 is dropped ($r)" || no "(g) box reaches private space: $r"
|
||||||
|
|
@ -457,6 +503,65 @@ else
|
||||||
fi
|
fi
|
||||||
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
||||||
|
|
||||||
|
phase "o. an incus-admin-ONLY member — #99's canonical user, on real Incus"
|
||||||
|
# The case the shim suite structurally cannot reach: the fake 'incus' in
|
||||||
|
# test/cli.sh ignores INCUS_SOCKET and file permissions, so a grant that could
|
||||||
|
# never connect() still logged a clean run there. This is the same path over
|
||||||
|
# the real daemon, where the socket is a real file with a real owning group.
|
||||||
|
#
|
||||||
|
# The blocker it exists to catch (#101 review): incus-user's socket is
|
||||||
|
# /var/lib/incus/unix.socket.user, group 'incus', mode 0660. incus-admin opens
|
||||||
|
# the ADMIN socket and not that one, so an incus-admin-only member without an
|
||||||
|
# 'incus' membership takes EACCES on grant's pinned touch — swallowed by its
|
||||||
|
# '|| true' — no project is created, and the grant dies blaming a perfectly
|
||||||
|
# healthy incus-user. Every assertion below is dead under that implementation.
|
||||||
|
U5=boxdrill5
|
||||||
|
useradd -m -s /bin/bash "$U5" 2>/dev/null
|
||||||
|
usermod -aG incus-admin "$U5"
|
||||||
|
gpasswd -d "$U5" incus >/dev/null 2>&1 || true # stage the ONLY, exactly
|
||||||
|
uid5="$(id -u "$U5")"; p5="user-$uid5"
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& no "(o) $U5 is already in 'incus' — the admin-ONLY precondition is not staged, so this phase proves nothing" \
|
||||||
|
|| ok "(o) $U5 staged in 'incus-admin' only (the precondition the blocker needed)"
|
||||||
|
|
||||||
|
out="$(box grant "$U5" 2>&1)"; rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok "(o) box grant converges an incus-admin-only member (rc=0)"
|
||||||
|
else
|
||||||
|
no "(o) box grant FAILED for the admin-only member (rc=$rc) — #99 is still closed:"
|
||||||
|
printf '%s\n' "$out" | tail -4 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& ok "(o) the grant put them in 'incus' — the group that owns unix.socket.user" \
|
||||||
|
|| no "(o) still not in 'incus': the pinned touch cannot connect() to incus-user's socket"
|
||||||
|
incus project show "$p5" >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) $p5 exists — the lazy touch really reached incus-user AS them" \
|
||||||
|
|| no "(o) $p5 was never created — the touch never reached incus-user (the EACCES this phase is for)"
|
||||||
|
|
||||||
|
# The socket, directly: the connect() that used to fail, measured as them.
|
||||||
|
# Resolved by incus's own directory rule, not hardcoded.
|
||||||
|
sockdir=/var/lib/incus; [ -e /run/incus/unix.socket ] && sockdir=/run/incus
|
||||||
|
as_u "$U5" env INCUS_SOCKET="$sockdir/unix.socket.user" incus --project "$p5" profile show box-net >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) they can open unix.socket.user and read $p5's box-net profile" \
|
||||||
|
|| no "(o) EACCES/unreachable on $sockdir/unix.socket.user — the #101 blocker is back"
|
||||||
|
acc5="$(incus project get "$p5" restricted.networks.access 2>/dev/null)"
|
||||||
|
[ "$acc5" = boxnet ] \
|
||||||
|
&& ok "(o) $p5 is narrowed to boxnet like any other granted project" \
|
||||||
|
|| no "(o) $p5 restricted.networks.access = '$acc5' — the admin-only grant converged half a project"
|
||||||
|
|
||||||
|
# Grant's own closing promise, measured: "gpasswd -d <user> incus-admin (no
|
||||||
|
# re-grant needed; the project is ready)". True only because they were left in
|
||||||
|
# 'incus' — under the old no-op this drop left them in NEITHER group, box_tier
|
||||||
|
# 'none', and a ready project they could not open. So drop it and look.
|
||||||
|
gpasswd -d "$U5" incus-admin >/dev/null 2>&1
|
||||||
|
projects5="$(as_u "$U5" incus project list --format csv 2>/dev/null | cut -d, -f1)"
|
||||||
|
if [ "$(printf '%s\n' "$projects5" | grep -c .)" = 1 ] && printf '%s' "$projects5" | grep -q "$p5"; then
|
||||||
|
ok "(o) dropping incus-admin lands them in $p5 with NO re-grant — the promise holds"
|
||||||
|
else
|
||||||
|
no "(o) after dropping incus-admin they see: '$(printf '%s' "$projects5" | tr '\n' ' ')' — grant's no-re-grant promise is false"
|
||||||
|
fi
|
||||||
|
aud "o. incus-admin-only grant: in-'incus'=$(id -nG "$U5" 2>/dev/null | tr ' ' '\n' | grep -cx incus), project '$p5' access='$acc5', post-drop projects='$(printf '%s' "$projects5" | tr '\n' ' ')'"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════════════════════════════════════════════"
|
echo "════════════════════════════════════════════"
|
||||||
echo " $pass passed, $fail failed"
|
echo " $pass passed, $fail failed"
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,39 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
|
||||||
# shellcheck disable=SC2086 # the table spec is two words by design
|
# shellcheck disable=SC2086 # the table spec is two words by design
|
||||||
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
||||||
done
|
done
|
||||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Every ufw read is CAPTURED before it is matched, never piped into a reader
|
||||||
|
# that exits on its first match (#102, #107).
|
||||||
|
#
|
||||||
|
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
|
||||||
|
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
|
||||||
|
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
|
||||||
|
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
|
||||||
|
# held — the defect was latent here, never live. It was also one line from
|
||||||
|
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
|
||||||
|
# this into #102, skipping every UFW removal on a host the operator was told
|
||||||
|
# is wiped, with no error and no red X to see. Captured and matched with
|
||||||
|
# [[ ]], it is correct under any future 'set' line.
|
||||||
|
#
|
||||||
|
# The numbered loop had the same defect for a different reason: its condition
|
||||||
|
# was also an early-exit reader, so it could end while rules remained, and it
|
||||||
|
# re-read un-captured to get the number. It now reads one capture per
|
||||||
|
# iteration and breaks on absence — the re-scan is still per-delete (numbers
|
||||||
|
# shift after each removal), just no longer racing.
|
||||||
|
ufw_status=""
|
||||||
|
if command -v ufw >/dev/null; then
|
||||||
|
# '|| true': ufw exits non-zero when it cannot read its config, and "no
|
||||||
|
# usable ufw here" is nothing-to-clean, not a failure to report.
|
||||||
|
ufw_status="$(sudo ufw status 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ufw_status" == *"Status: active"* ]]; then
|
||||||
for net in boxnet claudenet; do
|
for net in boxnet claudenet; do
|
||||||
while sudo ufw status numbered | grep -q "on $net"; do
|
while :; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
|
||||||
|
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
|
||||||
|
[ -n "$line" ] || break
|
||||||
|
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||||
|
[ -n "$n" ] || break
|
||||||
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
|
||||||
57
drills/0.9.0.md
Normal file
57
drills/0.9.0.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# box 0.9.0 — release drill
|
||||||
|
|
||||||
|
**WAIVED. No drill was run for this release.**
|
||||||
|
|
||||||
|
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
|
||||||
|
gate requires a *record*, not a passing result — so a skip is a line in a diff
|
||||||
|
somebody reviewed, rather than the silence that let 0.7.0, 0.8.0 and every
|
||||||
|
release before them ship unproven.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The drill harness is not in a state to produce a trustworthy run. box has the
|
||||||
|
only real harness in the family (`drill/drill.sh`, ~85 probes), but the
|
||||||
|
procedure around it — standing up the substrate, pinning the release
|
||||||
|
candidates, capturing the result — was undocumented and assembled by hand.
|
||||||
|
rig and cast have no harness at all. A run improvised under those conditions
|
||||||
|
produces numbers nobody should rely on, and `drills/` is worth nothing if it
|
||||||
|
fills with records like that.
|
||||||
|
|
||||||
|
Deferred deliberately, and tracked: #155.
|
||||||
|
|
||||||
|
## What this release therefore does NOT prove
|
||||||
|
|
||||||
|
Stated plainly so it is not rediscovered as a surprise:
|
||||||
|
|
||||||
|
- **The VM trust boundary.** `ci.yml` says it in its own words: *"What
|
||||||
|
container mode canNOT validate is the VM trust boundary itself; that stays a
|
||||||
|
real-hardware ritual."* That is exactly the delta a release drill adds, and
|
||||||
|
it is exactly what is missing here.
|
||||||
|
- **The isolation contract on real hardware** — egress, the sibling drop, DNS
|
||||||
|
enumeration, IPv6 off, inbound drop — as measured by `drill/drill.sh` against
|
||||||
|
real VMs rather than containers.
|
||||||
|
- **The combination users receive.** A released box still resolves `RIG_REF` to
|
||||||
|
`main` (#150), so even a passing drill would have proven a pair that drifts
|
||||||
|
the moment rig's main moves.
|
||||||
|
|
||||||
|
## What CI did prove on this tree
|
||||||
|
|
||||||
|
Not nothing, and worth recording so the gap is precise rather than total:
|
||||||
|
|
||||||
|
| Job | Result |
|
||||||
|
|---|---|
|
||||||
|
| `rehearsal` — multi-user tier on a real Incus | pass |
|
||||||
|
| `scope` | pass |
|
||||||
|
| `reconcile` | pass |
|
||||||
|
| `test/cli.sh` | 653 passed, 0 failed |
|
||||||
|
| `test/release.sh` | 173 passed, 0 failed |
|
||||||
|
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
|
||||||
|
| `shellcheck -x bin/* **/*.sh` | clean |
|
||||||
|
|
||||||
|
The tier's semantics are covered. The boundary is not.
|
||||||
|
|
||||||
|
## The commitment
|
||||||
|
|
||||||
|
The next release carries a real drill record at `drills/<version>.md` — what
|
||||||
|
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
|
||||||
|
failed drill is a valid record; another waiver is not. Tracked in #155.
|
||||||
82
drills/README.md
Normal file
82
drills/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# drills/ — release evidence, one file per version
|
||||||
|
|
||||||
|
This directory holds the **evidence that a release was proven on real
|
||||||
|
hardware**. One file per shipped version, named exactly for the version:
|
||||||
|
|
||||||
|
```
|
||||||
|
drills/0.9.0.md
|
||||||
|
drills/0.9.0-rc1.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The name must match the contents of `VERSION` exactly.
|
||||||
|
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
|
||||||
|
refuses any tree with a bare `VERSION` that has no such file, or whose file is
|
||||||
|
blank. A `-dev` tree passes with nothing to assert.
|
||||||
|
|
||||||
|
Because each version owns a file, `0.9.0` and `0.9.0-rc1` can never be
|
||||||
|
confused for one another — they are simply different paths. That used to take
|
||||||
|
careful whole-version field matching inside one shared file; now it is free.
|
||||||
|
|
||||||
|
## This is not `drill/RUNS.md`
|
||||||
|
|
||||||
|
Two different artifacts, and the distinction is load-bearing:
|
||||||
|
|
||||||
|
| | what it is |
|
||||||
|
|---|---|
|
||||||
|
| [`drill/RUNS.md`](../drill/RUNS.md) | the **harness's own history** — every run of `drill/drill.sh`, the traps table, the lore about what broke and why. It is not release-scoped and it is not going anywhere. |
|
||||||
|
| `drills/<version>.md` | **release evidence** — the record that *this version* was drilled before it shipped. Release-scoped, one file, gated by CI. |
|
||||||
|
|
||||||
|
Appending to `drill/RUNS.md` does not satisfy the release gate, and is not
|
||||||
|
meant to. Keep using it for what it has always been for.
|
||||||
|
|
||||||
|
## What a record should contain
|
||||||
|
|
||||||
|
- **What ran** — which drill, how many probes, `drill/drill.sh` invocation.
|
||||||
|
- **On what host** — the machine, the OS, the Incus version. "Real hardware"
|
||||||
|
is the claim; name the hardware.
|
||||||
|
- **The pinned candidate refs** — the exact `BOX_REF` / `RIG_REF` /
|
||||||
|
`CAST_REF` under test, and the other repos' commit SHAs. A drill that does
|
||||||
|
not say what it drilled proves nothing later.
|
||||||
|
- **The shared run ID**, so this record reconciles with the sibling repos'.
|
||||||
|
- **The numbers** — passed, failed, how long it took.
|
||||||
|
- **What failed**, plainly.
|
||||||
|
|
||||||
|
**A failed drill is still a valid record.** The gate wants *evidence*, not
|
||||||
|
success. A record saying "83/85, criterion (m) regressed, here is the issue"
|
||||||
|
is a good record. So is a maintainer's written waiver explaining why this
|
||||||
|
release shipped without a full drill. What the gate refuses is silence — #95,
|
||||||
|
#114 and #148 all shipped unproven because a skip left no trace.
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
The version below is a **placeholder that can never be a real release**.
|
||||||
|
Copy the shape, not the number.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Release drill — 9.9.9
|
||||||
|
|
||||||
|
- **Run ID:** `drill-9.9.9-20260721-01` (shared with rig, cast)
|
||||||
|
- **Host:** bare Debian 13, Ryzen 7 5800X / 64 GB, Incus 6.0.2
|
||||||
|
- **Date:** 2026-07-21
|
||||||
|
- **Candidate refs:**
|
||||||
|
- box `release/9.9.9` @ `abc1234`
|
||||||
|
- rig `release/4.4.4` @ `def5678` (minted with `RIG_REF=release/4.4.4`)
|
||||||
|
- cast `release/2.2.2` @ `9abcdef`
|
||||||
|
|
||||||
|
## What ran
|
||||||
|
|
||||||
|
`bash drill/drill.sh --ref release/9.9.9` — the full end-to-end: install the
|
||||||
|
stack, mint every template cold, snapshot and restore, uninstall to zero
|
||||||
|
residue. Then `drill/multiuser.sh` for the two-user grant matrix.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**84/85 passed, 1 failed.** 41 minutes wall clock.
|
||||||
|
|
||||||
|
- Failed: `multiuser.sh` criterion (m) — the raw instance kept a stale route
|
||||||
|
after teardown. Filed as #999. Judged not release-blocking: it affects
|
||||||
|
teardown residue on a host that is about to be wiped, not the trust
|
||||||
|
boundary itself.
|
||||||
|
- The VM boundary probes (the 85-probe isolation contract) passed clean,
|
||||||
|
which is the assertion this repo's drill exists to make.
|
||||||
|
```
|
||||||
|
|
@ -5,11 +5,68 @@
|
||||||
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
|
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GW=10.88.0.1
|
|
||||||
NET=boxnet
|
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)"
|
||||||
|
|
||||||
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Read `ufw status` ONCE, into a variable, instead of piping it at a matcher.
|
||||||
if ! ufw status | grep "on $NET" | grep -q "DENY"; then
|
# 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
|
||||||
ufw insert 1 deny in on "$NET"
|
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 tcp
|
||||||
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto udp
|
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto udp
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@
|
||||||
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
||||||
#
|
#
|
||||||
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
||||||
# 1. put the user in the 'incus' group (not incus-admin — that is the tier)
|
# 1. put the user in the 'incus' group (not incus-admin — that is the tier).
|
||||||
|
# An incus-admin member goes in too, and NOT for privilege: incus-user's
|
||||||
|
# socket is a FILE, group 'incus', mode 0660, so the membership is the
|
||||||
|
# only thing that lets step 2 connect() at all (#99, #101 review)
|
||||||
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
||||||
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
||||||
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
||||||
|
|
@ -17,7 +20,8 @@
|
||||||
# '--network' flag away from any box they mint
|
# '--network' flag away from any box they mint
|
||||||
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
|
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
|
||||||
# on them)
|
# on them)
|
||||||
# 6. install the shipped box-net profile into their project
|
# 6. allow backups (blocked too; 'box export' rides the backup API — #70)
|
||||||
|
# 7. install the shipped box-net profile into their project
|
||||||
#
|
#
|
||||||
# Idempotent: every step converges, so re-running (including after a box
|
# Idempotent: every step converges, so re-running (including after a box
|
||||||
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
|
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
|
||||||
|
|
@ -62,12 +66,34 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2;
|
||||||
uid="$(id -u "$user")"
|
uid="$(id -u "$user")"
|
||||||
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
||||||
|
|
||||||
# An incus-admin member already holds the full socket; "granting" them the
|
# An incus-admin member gets the full convergence anyway (#99). This used to
|
||||||
# restricted tier would not restrict anything (admin membership wins at the
|
# be a hard refusal, on the reasoning that admin membership wins at the socket
|
||||||
# socket), it would only mislead whoever reads the group list later.
|
# so nothing here could restrict them. True — and beside the point, because it
|
||||||
|
# conflates the two separate things a grant hands over:
|
||||||
|
# · PERMISSION — the 'incus' group. At the DAEMON API they already hold
|
||||||
|
# strictly more through incus-admin, so this group adds no privilege. It
|
||||||
|
# is still required, because the two sockets are two FILES with two
|
||||||
|
# different owning groups (Debian 13 / Incus 6.0.4, measured):
|
||||||
|
# /var/lib/incus/unix.socket group incus-admin 0660
|
||||||
|
# /var/lib/incus/unix.socket.user group incus 0660
|
||||||
|
# incus-admin opens the first and not the second, and the second is the
|
||||||
|
# only one that provisions a user-<uid> project. An earlier revision of
|
||||||
|
# this script skipped the usermod for an admin member, reasoning that
|
||||||
|
# 'incus' is a subset of incus-admin — true of the API, false of the
|
||||||
|
# filesystem: the pinned touch below took EACCES, the '|| true' swallowed
|
||||||
|
# it, no project appeared, and the grant died blaming a healthy
|
||||||
|
# incus-user. So the group step is a real convergence for everyone.
|
||||||
|
# · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
|
||||||
|
# snapshot and backup allowances, the box-net profile installed INTO that
|
||||||
|
# project. An incus-admin member has none of it: box_tier() resolves them
|
||||||
|
# to 'admin' (bin/box), so they work in the SHARED default project next to
|
||||||
|
# root and every other admin, with no world of their own. This script is
|
||||||
|
# the only thing that provisions one, and refusing left them unable to get
|
||||||
|
# it without first being taken out of incus-admin.
|
||||||
|
# So provision, and say plainly at the end what the provisioning does not do.
|
||||||
|
admin_member=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2
|
admin_member=1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
||||||
|
|
@ -92,7 +118,11 @@ fi
|
||||||
# admin re-runs. Backing out the group closes that window completely for a
|
# admin re-runs. Backing out the group closes that window completely for a
|
||||||
# fresh grant (their existing sessions predate the membership, so no process
|
# fresh grant (their existing sessions predate the membership, so no process
|
||||||
# holds it yet). A user who was already in the group keeps it: not ours to
|
# holds it yet). A user who was already in the group keeps it: not ours to
|
||||||
# take on a re-run's failure.
|
# take on a re-run's failure. An incus-admin member now takes those same two
|
||||||
|
# paths (#101): the membership IS added for them and so IS backed out, with
|
||||||
|
# one thing extra to say either way — the rollback closes incus-user's socket
|
||||||
|
# and never their daemon access, which outlives this failure by a route the
|
||||||
|
# script never granted and must not pretend to control.
|
||||||
added_group=0; was_member=0
|
added_group=0; was_member=0
|
||||||
backout() {
|
backout() {
|
||||||
if [ "$added_group" -eq 1 ]; then
|
if [ "$added_group" -eq 1 ]; then
|
||||||
|
|
@ -105,6 +135,16 @@ backout() {
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# The rollback is real and verified, but for an admin member it is not a
|
||||||
|
# lockout and must not read as one: what came back was incus-user's
|
||||||
|
# socket key, not the daemon. Say what survives, and what would end it.
|
||||||
|
echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2
|
||||||
|
echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2
|
||||||
|
echo " neither granted nor removed, so every project on this host stays open to them." >&2
|
||||||
|
echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2
|
||||||
|
echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
# The one window the database cannot close: a login STARTED between our
|
# The one window the database cannot close: a login STARTED between our
|
||||||
# usermod and this backout keeps the group in its session credentials.
|
# usermod and this backout keeps the group in its session credentials.
|
||||||
# For a fresh grant that is a rare race, but rare is not never — name it
|
# For a fresh grant that is a rare race, but rare is not never — name it
|
||||||
|
|
@ -121,6 +161,14 @@ backout() {
|
||||||
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
||||||
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
||||||
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Same correction as above, for the member who was in BOTH groups before
|
||||||
|
# this run: 'box revoke' takes the 'incus' key back, and still leaves
|
||||||
|
# them the whole daemon.
|
||||||
|
echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2
|
||||||
|
echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2
|
||||||
|
echo " access: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap backout EXIT
|
trap backout EXIT
|
||||||
|
|
@ -131,17 +179,63 @@ if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
else
|
else
|
||||||
$SUDO usermod -aG incus "$user"
|
$SUDO usermod -aG incus "$user"
|
||||||
added_group=1
|
added_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Say why, because the group list alone would imply a restriction that is
|
||||||
|
# not in force — the concern the old no-op was built around. It was a
|
||||||
|
# cosmetic concern and this is where it gets carried: in output, not in a
|
||||||
|
# skipped mutation that broke the mechanism.
|
||||||
|
echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon,"
|
||||||
|
echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is"
|
||||||
|
echo " group 'incus' mode 0660, and nothing below can provision $user without it"
|
||||||
|
else
|
||||||
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
project="user-$uid"
|
project="user-$uid"
|
||||||
|
|
||||||
|
# The incus CLI picks its socket by WRITABILITY, not by intent: with no
|
||||||
|
# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and
|
||||||
|
# only falls back to unix.socket.user when it is not (client/connection.go,
|
||||||
|
# stable-6.0 — the same branch that then defaults the project to user-<uid>).
|
||||||
|
# For a plain 'incus' member the fallback fires and every command below lands
|
||||||
|
# where we want it. For an incus-admin member the daemon socket IS writable,
|
||||||
|
# so an unpinned client sails straight past incus-user — the touch would not
|
||||||
|
# provision anything and the grant would die claiming incus-user was
|
||||||
|
# unhealthy. Pin the socket for them, by incus's own directory rule.
|
||||||
|
# INCUS_DIR first, then /run/incus if the daemon socket lives there, else
|
||||||
|
# /var/lib/incus: incus's resolution order, not an approximation of it — the
|
||||||
|
# pinned path has to name the same directory the client would have chosen.
|
||||||
|
user_socket=""
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
incus_dir="${INCUS_DIR:-}"
|
||||||
|
if [ -z "$incus_dir" ]; then
|
||||||
|
incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
|
||||||
|
fi
|
||||||
|
user_socket="$incus_dir/unix.socket.user"
|
||||||
|
# $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement
|
||||||
|
# that /var/lib/incus is not traversable by a non-root admin, so an
|
||||||
|
# unprivileged stat answers "absent" for a socket that is very much there —
|
||||||
|
# and this check EXITS on absent. Same discipline, same reason (#101 review).
|
||||||
|
$SUDO test -e "$user_socket" \
|
||||||
|
|| { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the incus CLI as the granted user, on the socket that will actually
|
||||||
|
# serve them: pinned to incus-user for an admin member, left to the CLI's own
|
||||||
|
# resolution for everyone else (whose fallback already gets it right).
|
||||||
|
run_as_incus() {
|
||||||
|
if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@"
|
||||||
|
else run_as "$user" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 2. The project is created LAZILY, on the user's first contact with
|
# 2. The project is created LAZILY, on the user's first contact with
|
||||||
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
||||||
# it), so make that first contact happen now, as the user.
|
# it), so make that first contact happen now, as the user.
|
||||||
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "project: touching incus-user as $user to create $project..."
|
echo "project: touching incus-user as $user to create $project..."
|
||||||
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true
|
run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
||||||
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
echo "project: $project created"
|
echo "project: $project created"
|
||||||
|
|
@ -183,7 +277,18 @@ echo "network: $project restricted to boxnet (the private $bridge is unreference
|
||||||
incus project set "$project" restricted.snapshots allow </dev/null
|
incus project set "$project" restricted.snapshots allow </dev/null
|
||||||
echo "snapshots: allowed"
|
echo "snapshots: allowed"
|
||||||
|
|
||||||
# 6. The placement contract itself, installed into their project. Created if
|
# 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
|
||||||
# missing, refreshed unconditionally — same convergence discipline as
|
# missing, refreshed unconditionally — same convergence discipline as
|
||||||
# setup-host's own profile handling, so a box upgrade propagates by re-run.
|
# 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 \
|
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
|
||||||
|
|
@ -193,10 +298,53 @@ echo "profile: box-net installed in $project"
|
||||||
|
|
||||||
# Prove the grant from the USER's side of the socket — the only side that
|
# Prove the grant from the USER's side of the socket — the only side that
|
||||||
# matters. This catches the failure the steps above cannot see one at a time:
|
# matters. This catches the failure the steps above cannot see one at a time:
|
||||||
# a converged project the user still cannot reach.
|
# a converged project the user still cannot reach. For an admin member the
|
||||||
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|
# 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; }
|
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
trap - EXIT # converged and verified: the grant stands
|
trap - EXIT # converged and verified: the grant stands
|
||||||
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
#
|
#
|
||||||
# The zero-ceremony transition is just install.sh + setup-host.sh: that leaves
|
# 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,
|
# a DUAL-STACK host where legacy boxes (tag user.claudebox=1, claudenet/10.87,
|
||||||
# claude-dev) keep working while new mints land on boxnet/10.88. This script is
|
# claude-dev) keep working while new mints land on boxnet. This script is
|
||||||
# the two things that path does not do:
|
# the two things that path does not do:
|
||||||
#
|
#
|
||||||
# migrate-host.sh --box <name> re-home ONE legacy box onto the new stack
|
# migrate-host.sh --box <name> re-home ONE legacy box onto the new stack
|
||||||
|
|
@ -64,7 +64,11 @@ legacy_boxes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
|
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
|
||||||
# tool minted), so the new metadata is the claude template's.
|
# tool minted), so the new metadata is the claude-box template's. The stamp
|
||||||
|
# names the template as it is called TODAY, not as it was called when the box
|
||||||
|
# was minted: it is what 'box shell' and the mint hints read, so a re-homed
|
||||||
|
# box should look like a fresh claude-box mint, not like a fossil (rig#76's
|
||||||
|
# family suffix — the template is named for the role it converges).
|
||||||
rehome_one() {
|
rehome_one() {
|
||||||
local b="$1" st
|
local b="$1" st
|
||||||
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
|
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
|
||||||
|
|
@ -75,7 +79,7 @@ rehome_one() {
|
||||||
|
|
||||||
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
|
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
|
||||||
# valid legacy box (the old tag is untouched) AND now a new one.
|
# valid legacy box (the old tag is untouched) AND now a new one.
|
||||||
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|
incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
|
||||||
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
||||||
|
|
||||||
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
||||||
|
|
@ -88,17 +92,19 @@ rehome_one() {
|
||||||
incus start "$b" >/dev/null 2>&1 || { warn "$b: did not restart — start it by hand"; return 1; }
|
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
|
# 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box
|
||||||
# must be on 10.88 and actually resolve+reach the internet on its new leg
|
# must be on boxnet's subnet (read off the network — BOX_SUBNET moves it,
|
||||||
# before we call it migrated.
|
# #80) and actually resolve+reach the internet on its new leg before we
|
||||||
local _i ip
|
# call it migrated.
|
||||||
|
local _i ip pfx
|
||||||
|
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1)"; pfx="${pfx%.*}."
|
||||||
ip=""
|
ip=""
|
||||||
for _i in $(seq 1 30); do
|
for _i in $(seq 1 30); do
|
||||||
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
|
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
|
||||||
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
|
| 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}}')"
|
||||||
[ -n "$ip" ] && break
|
[ -n "$ip" ] && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
|
[ -n "$ip" ] || { warn "$b: never got a boxnet 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
|
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
|
# 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
|
# point the box wears both tags, so a failure anywhere above leaves it a
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ if [ "$purge" -eq 1 ]; then
|
||||||
if [ -z "${BOX_YES:-}" ]; then
|
if [ -z "${BOX_YES:-}" ]; then
|
||||||
if [ -t 0 ]; then
|
if [ -t 0 ]; then
|
||||||
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
|
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
|
||||||
read -r reply
|
# EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read'
|
||||||
|
# returns non-zero and errexit ends the run right here — before the
|
||||||
|
# 'case' below can name the abort, so the most destructive prompt box
|
||||||
|
# has would go silent at the moment it asked (#111).
|
||||||
|
read -r reply || { echo "box revoke: aborted." >&2; exit 1; }
|
||||||
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
|
||||||
else
|
else
|
||||||
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
|
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
|
||||||
|
|
@ -56,14 +60,42 @@ if [ "$purge" -eq 1 ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
|
||||||
|
# put them in 'incus' — not for privilege, but because incus-user's socket is
|
||||||
|
# a file owned by that group (#101) — so revoke has a real membership to take
|
||||||
|
# back here. What it does not have is a lockout: 'incus-admin' opens the
|
||||||
|
# daemon and is not this script's to remove, so "revoked" would still be the
|
||||||
|
# script claiming something it did not perform. Recorded here, spoken below.
|
||||||
|
admin_member=0
|
||||||
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
|
admin_member=1
|
||||||
|
fi
|
||||||
|
|
||||||
# The group, first — access ends even if a purge step below trips.
|
# The group, first — access ends even if a purge step below trips.
|
||||||
|
dropped_group=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
$SUDO gpasswd -d "$user" incus >/dev/null
|
$SUDO gpasswd -d "$user" incus >/dev/null
|
||||||
|
dropped_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which"
|
||||||
|
echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here"
|
||||||
|
else
|
||||||
echo "group: removed $user from 'incus'"
|
echo "group: removed $user from 'incus'"
|
||||||
|
fi
|
||||||
|
elif [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch"
|
||||||
else
|
else
|
||||||
echo "group: $user was not in 'incus'"
|
echo "group: $user was not in 'incus'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Say it once, plainly, before either path's summary: for an admin member the
|
||||||
|
# group step above closed nothing, so nothing this script does — including
|
||||||
|
# --purge — ends their access to the daemon. Only the admin group itself does.
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host"
|
||||||
|
echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin"
|
||||||
|
fi
|
||||||
|
|
||||||
# Supplementary groups are fixed AT LOGIN: the database change above does
|
# Supplementary groups are fixed AT LOGIN: the database change above does
|
||||||
# nothing to a session the user already holds — a leftover tmux keeps the
|
# nothing to a session the user already holds — a leftover tmux keeps the
|
||||||
# socket until it dies. For a bare revoke that is an honest warning. For
|
# socket until it dies. For a bare revoke that is an honest warning. For
|
||||||
|
|
@ -95,8 +127,28 @@ fi
|
||||||
if [ "$purge" -eq 0 ]; then
|
if [ "$purge" -eq 0 ]; then
|
||||||
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# "restores access" would be the wrong promise here: no access was lost.
|
||||||
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership"
|
||||||
|
else
|
||||||
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Not "revoked", still: what came back is the 'incus' membership grant
|
||||||
|
# added for incus-user's socket, and their access to this host was never
|
||||||
|
# riding on it. The project is kept. So say exactly what was taken and
|
||||||
|
# name the two real options, instead of a summary that reads as a lockout.
|
||||||
|
if [ "$dropped_group" -eq 1 ]; then
|
||||||
|
echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added."
|
||||||
|
echo " $user is NOT locked out: 'incus-admin' still opens every project on this host."
|
||||||
|
else
|
||||||
|
echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken."
|
||||||
|
fi
|
||||||
|
echo " to remove their access: gpasswd -d $user incus-admin"
|
||||||
|
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
echo "revoked: $user no longer has the restricted tier."
|
echo "revoked: $user no longer has the restricted tier."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -135,22 +187,51 @@ 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
|
# 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.
|
# re-grant starts clean instead of trusting a key the purge revoked.
|
||||||
if [ -d "/var/lib/incus/users/$uid" ]; then
|
# $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
|
||||||
$SUDO rm -rf "/var/lib/incus/users/$uid"
|
$SUDO rm -rf "/var/lib/incus/users/$uid"
|
||||||
echo "purge: incus-user state for uid $uid removed"
|
echo "purge: incus-user state for uid $uid removed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
|
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
|
||||||
# The certificate included: its removal above is set -e-exempt (left of &&),
|
# The certificate included: its removal above is set -e-exempt (left of &&),
|
||||||
# and a promise the header makes is a promise this block checks.
|
# and a promise the header makes is a promise this block checks. The
|
||||||
|
# incus-user state directory too — it was purged for releases without being
|
||||||
|
# re-checked, which is exactly the gap this block exists to close.
|
||||||
|
# The trust store is read into a capture rather than piped into a reader that
|
||||||
|
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
|
||||||
|
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
|
||||||
|
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
|
||||||
|
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
|
||||||
|
# would read as "no leftover cert" on a host that still trusts the revoked
|
||||||
|
# user's certificate and the purge would report success. Fail-open, on the
|
||||||
|
# cleanup path whose entire job is to prove access is gone.
|
||||||
|
# Un-racy in practice today — the trust store is small and likely one write —
|
||||||
|
# so this is defensive, not a live defect. Captured so it cannot become one.
|
||||||
|
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
|
||||||
|
|
||||||
leftover=""
|
leftover=""
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
||||||
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
||||||
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
# Leading newline so the first CSV row anchors like the `^` this replaces.
|
||||||
|
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
|
||||||
&& leftover="$leftover cert:incus-user-$uid"
|
&& leftover="$leftover cert:incus-user-$uid"
|
||||||
|
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
||||||
|
&& leftover="$leftover /var/lib/incus/users/$uid"
|
||||||
if [ -n "$leftover" ]; then
|
if [ -n "$leftover" ]; then
|
||||||
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "revoked: $user is out, and everything the tier created is gone."
|
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
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,159 @@ else
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# 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
|
# 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
|
# for the first minutes of its life — plain 'apt-get install' then waits on it
|
||||||
|
|
@ -145,22 +298,46 @@ PRESEED
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Isolated NAT network. IPv6 off: one less egress path to reason about.
|
# Isolated NAT network. IPv6 off: one less egress path to reason about.
|
||||||
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
|
# The default is 10.88 — not 10.87: a pre-rename host may still carry
|
||||||
# legacy boxes attached — two bridges must not claim one subnet.
|
# 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.
|
||||||
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
|
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
|
||||||
ipv4.address=10.88.0.1/24 ipv4.nat=true ipv6.address=none
|
ipv4.address="$BOX_GW/24" ipv4.nat=true ipv6.address=none
|
||||||
|
|
||||||
# ACL: default egress allow (internet), explicit drops for private space.
|
# ACL: default egress allow (internet), explicit drops for private space.
|
||||||
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
|
# Gateway carve-out first so instance DNS (dnsmasq on the gateway) survives.
|
||||||
if ! incus network acl show box-isolate >/dev/null 2>&1; then
|
# 'edit' the full shipped ruleset, not create-once: the carve-out derives
|
||||||
incus network acl create box-isolate
|
# from BOX_SUBNET now, and a bridge moved off a colliding subnet (#80's
|
||||||
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
|
# escape hatch) left the OLD /32 behind — box DNS to the new gateway then
|
||||||
incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
|
# died inside the 10.0.0.0/8 drop, looking like a dead resolver, not a stale
|
||||||
incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
|
# ACL. A conditional 'rule add' cannot converge that (the stale carve-out
|
||||||
incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
|
# would survive beside the new one); replacing the ruleset does, idempotently.
|
||||||
incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
|
incus network acl show box-isolate >/dev/null 2>&1 || incus network acl create box-isolate
|
||||||
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
|
incus network acl edit box-isolate <<ACL
|
||||||
fi
|
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
|
||||||
incus network set boxnet security.acls=box-isolate \
|
incus network set boxnet security.acls=box-isolate \
|
||||||
security.acls.default.egress.action=allow \
|
security.acls.default.egress.action=allow \
|
||||||
security.acls.default.ingress.action=drop
|
security.acls.default.ingress.action=drop
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,48 @@
|
||||||
# ancestor created, so one teardown cleans a host of any generation: all boxes
|
# 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
|
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
|
||||||
# profiles, and both generations of firewall units and nft tables.
|
# profiles, and both generations of firewall units and nft tables.
|
||||||
# Usage: ./host/teardown-host.sh [--purge-incus]
|
# Usage: ./host/teardown-host.sh [--purge-incus] [--yes]
|
||||||
# --purge-incus also apt-purge Incus itself (skipped if non-box
|
# --purge-incus also apt-purge Incus itself (skipped if non-box
|
||||||
# instances still exist on this host)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
purge=false
|
purge=false; yes=0
|
||||||
[ "${1:-}" = "--purge-incus" ] && purge=true
|
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
|
||||||
|
|
||||||
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
||||||
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
|
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
|
||||||
echo "(both current and pre-0.4.0 names)."
|
echo "(both current and pre-0.4.0 names)."
|
||||||
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
||||||
read -rp "Continue? [y/N] " a
|
if [ "$yes" -eq 1 ]; then
|
||||||
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
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
|
||||||
|
|
||||||
# Instances — both tag generations, one delete at a time (a multi-name
|
# Instances — both tag generations, one delete at a time (a multi-name
|
||||||
# 'incus delete' aborts at the first missing name).
|
# 'incus delete' aborts at the first missing name).
|
||||||
|
|
@ -43,10 +71,37 @@ sudo systemctl daemon-reload
|
||||||
|
|
||||||
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
||||||
# each delete, so re-scan and remove the first match until none remain)
|
# each delete, so re-scan and remove the first match until none remain)
|
||||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Every ufw read is CAPTURED before it is matched, never piped into a reader
|
||||||
|
# that can exit early — the same discipline box-firewall.sh now uses, and for
|
||||||
|
# the same measured reason (#102). This file sets `pipefail` (line 12), so
|
||||||
|
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
|
||||||
|
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
|
||||||
|
# and the pipeline yields 141. A plainly-active UFW then reads as inactive
|
||||||
|
# and this entire block silently skips, leaving stale boxnet/claudenet rules
|
||||||
|
# on a host the operator was told is clean. It is a branch condition, so
|
||||||
|
# errexit never fires — there is no error to see, which is exactly why it
|
||||||
|
# went unnoticed here while the same shape was being measured next door.
|
||||||
|
#
|
||||||
|
# The numbered loop had the same defect for a different reason: its condition
|
||||||
|
# was also an early-exit reader, so it could end while rules remained. It now
|
||||||
|
# reads one capture per iteration and breaks on absence — the re-scan is still
|
||||||
|
# per-delete (numbers shift after each removal), just no longer racing.
|
||||||
|
ufw_status=""
|
||||||
|
if command -v ufw >/dev/null; then
|
||||||
|
# '|| true': ufw exits non-zero when it cannot read its config, and under
|
||||||
|
# pipefail+errexit that would kill a teardown instead of correctly deciding
|
||||||
|
# "no usable ufw here, nothing to clean".
|
||||||
|
ufw_status="$(sudo ufw status 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ufw_status" == *"Status: active"* ]]; then
|
||||||
for net in boxnet claudenet; do
|
for net in boxnet claudenet; do
|
||||||
while sudo ufw status numbered | grep -q "on $net"; do
|
while :; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
|
||||||
|
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
|
||||||
|
[ -n "$line" ] || break
|
||||||
|
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||||
|
[ -n "$n" ] || break
|
||||||
sudo ufw --force delete "$n"
|
sudo ufw --force delete "$n"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
@ -72,4 +127,4 @@ if $purge; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)"
|
echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)"
|
||||||
|
|
|
||||||
412
install.sh
412
install.sh
|
|
@ -3,13 +3,32 @@ set -euo pipefail
|
||||||
|
|
||||||
# box installer — intended for: curl -fsSL .../install.sh | bash
|
# box installer — intended for: curl -fsSL .../install.sh | bash
|
||||||
#
|
#
|
||||||
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
|
# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
|
||||||
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
|
# installs it into the VERSIONED layout under $DEST:
|
||||||
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
|
#
|
||||||
# script keeps working; BOX_REPO overrides.)
|
# $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.
|
||||||
|
|
||||||
REPO="${BOX_REPO:-heavy-duty/box}"
|
REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
REF="${BOX_REF:-main}"
|
# 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:-}"
|
||||||
# Root installs GLOBALLY, non-root installs per-user. box's install tree is
|
# 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
|
# 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
|
# incus-group operator runs it) — unlike rig, which is root-only and can hide in
|
||||||
|
|
@ -47,30 +66,134 @@ confirm() { # $1 = question
|
||||||
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
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 ---------------------------------------------------------
|
# --- prerequisites ---------------------------------------------------------
|
||||||
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
# 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 tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
||||||
|
|
||||||
# --- confirm, then no-op if already installed ------------------------------
|
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
|
||||||
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
|
SRCDESC="local source $BOX_INSTALL_SOURCE"
|
||||||
# ask whether you meant to. Then, if box is already installed, this run changes
|
else
|
||||||
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
|
SRCDESC="$REPO@${REF:-latest release}"
|
||||||
# "curl clobbered my working install / rebuilt the stack under my boxes" class
|
fi
|
||||||
# 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."
|
|
||||||
|
|
||||||
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
|
# --- confirm first ---------------------------------------------------------
|
||||||
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
|
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
|
||||||
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
|
# ask whether you meant to. Everything after this converges: re-running with a
|
||||||
log "box is already installed ($cur, version $cur_ver) — nothing to do."
|
# version that is already installed changes nothing and says so, which
|
||||||
log "To install a different version, remove the current one first:"
|
# dissolves the whole "curl clobbered my working install / rebuilt the stack
|
||||||
log " · preserve any boxes you care about — 'box down <box>', then keep them"
|
# under my boxes" class of failures (#66).
|
||||||
log " (a portable 'box export' is #70; for now copy what you need OUT via"
|
confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed."
|
||||||
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
|
|
||||||
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
|
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
|
||||||
log " · then re-run this installer"
|
# rename over. Plain ln -sfn is unlink+create — a window where current names
|
||||||
exit 0
|
# 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"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- temp workspace --------------------------------------------------------
|
# --- temp workspace --------------------------------------------------------
|
||||||
|
|
@ -78,50 +201,149 @@ TMPDIR="$(mktemp -d)"
|
||||||
cleanup() { rm -rf "$TMPDIR"; }
|
cleanup() { rm -rf "$TMPDIR"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
# --- 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
|
||||||
|
|
||||||
log "installing box from $REPO@$REF"
|
log "extracting archive"
|
||||||
log "downloading $URL"
|
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|
|
||||||
|| die "failed to download $URL"
|
|
||||||
|
|
||||||
log "extracting archive"
|
|
||||||
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|
|
||||||
|| die "failed to extract archive"
|
|| die "failed to extract archive"
|
||||||
|
|
||||||
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
|
# 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
|
# 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
|
# claudebox → box rename, when this glob kept looking for claudebox-* and the
|
||||||
# installer died on every host. The tarball has exactly ONE top-level
|
# 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
|
# directory: take the directory, whatever it is called, and let the bin/box
|
||||||
# check below judge whether it is the right tree.
|
# check below judge whether it is the right tree.
|
||||||
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
|
fi
|
||||||
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
[ -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?"
|
||||||
|
|
||||||
# --- install into $DEST ----------------------------------------------------
|
# The tree's own VERSION file names the directory it lands in — the version IS
|
||||||
# Reached only on a host with no existing install (the no-op check above
|
# the identity of what is being installed, and 'box versions' lists these names.
|
||||||
# exits otherwise), so this is always a fresh tree, never an overwrite.
|
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)"
|
||||||
log "installing into $DEST"
|
[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version"
|
||||||
mkdir -p "$(dirname "$DEST")"
|
valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'"
|
||||||
mv "$EXTRACTED" "$DEST"
|
|
||||||
|
|
||||||
chmod +x "$DEST/bin/box"
|
# --- 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
|
||||||
|
|
||||||
# A global (root) install is run by OTHER users, but mv preserves the tarball's
|
# --- which version is the default? -----------------------------------------
|
||||||
# root:root ownership and GitHub's archives carry no world bits on some paths — so
|
# 'current' is the tracked default; flipping it is the ONLY step that changes
|
||||||
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
|
# what an operator's `box` runs. #66's stance, kept exactly here: never change
|
||||||
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
|
# versions under existing boxes. A fresh host (or a dangling current) is
|
||||||
# files, +search (x) on directories only. Guarded on root so the per-user install
|
# claimed outright; an upgrade flips only when no box exists — otherwise the
|
||||||
# stays byte-identical to before.
|
# new version sits installed side-by-side and 'box use' is the deliberate act.
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
|
||||||
chmod -R a+rX "$DEST"
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- put box on PATH -------------------------------------------------------
|
# --- 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"
|
mkdir -p "$BINDIR"
|
||||||
ln -sf "$DEST/bin/box" "$BINDIR/box"
|
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||||
log "linked $BINDIR/box -> $DEST/bin/box"
|
log "linked $BINDIR/box -> $DEST/current/bin/box"
|
||||||
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
|
# 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
|
# 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.
|
# honors their legacy tag — it is only the old command name that retires.
|
||||||
|
|
@ -137,6 +359,34 @@ if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then
|
||||||
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
|
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
|
||||||
fi
|
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 ------------------------------------------------------------
|
# --- PATH check ------------------------------------------------------------
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
*":$BINDIR:"*) : ;;
|
*":$BINDIR:"*) : ;;
|
||||||
|
|
@ -147,29 +397,36 @@ case ":$PATH:" in
|
||||||
;;
|
;;
|
||||||
esac
|
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) --------------------------------------------
|
# --- host setup (second prompt) --------------------------------------------
|
||||||
# The tool is installed; the machine is not yet a box host. Offer to finish the
|
# 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
|
# 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:
|
# 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.
|
# 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
|
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||||
# up by hand); BOX_YES answers "yes".
|
# up by hand); BOX_YES answers "yes".
|
||||||
|
#
|
||||||
|
# It runs $VDIR's script, NOT $DEST/current's. They are usually the same tree,
|
||||||
|
# but 'current' does not always flip: the #66 guard above keeps the default
|
||||||
|
# where it is when the host has existing boxes, so on such a host 'current'
|
||||||
|
# still names the OLD version. Going through it would converge the host with
|
||||||
|
# the old release's host-side scripts — reinstating exactly the stale artifacts
|
||||||
|
# #115 is about, in the one case where the operator's boxes make it costly.
|
||||||
|
# $VDIR is unambiguously the version this run installed, which is the version
|
||||||
|
# whose host contract we are being asked to satisfy.
|
||||||
setup_ok=""
|
setup_ok=""
|
||||||
setup_declined=""
|
setup_declined=""
|
||||||
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
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
|
||||||
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
|
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
warn "cannot set up the host: it needs root and sudo was not found."
|
warn "cannot set up the host: it needs root and sudo was not found."
|
||||||
warn " run this as root to finish: $DEST/host/setup-host.sh"
|
warn " run this as root to finish: $VDIR/host/setup-host.sh"
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
||||||
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
||||||
|
|
@ -177,7 +434,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
|
||||||
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
||||||
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
||||||
# group; that re-exec is a child here and completes the whole setup in one go.
|
# group; that re-exec is a child here and completes the whole setup in one go.
|
||||||
if bash "$DEST/host/setup-host.sh" </dev/null; then
|
if bash "$VDIR/host/setup-host.sh" </dev/null; then
|
||||||
setup_ok=1
|
setup_ok=1
|
||||||
else
|
else
|
||||||
warn "host setup did not complete — box is installed, the host is not ready."
|
warn "host setup did not complete — box is installed, the host is not ready."
|
||||||
|
|
@ -189,9 +446,18 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$setup_ok" ]; then
|
if [ -n "$setup_ok" ]; then
|
||||||
log "done ($REPO@$REF) — try: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — try: box new --name test"
|
||||||
elif [ -n "$setup_declined" ]; then
|
elif [ -n "$setup_declined" ]; then
|
||||||
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host"
|
log "done ($SRCDESC, version $new_ver) — when you want this machine to host boxes: box setup-host"
|
||||||
else
|
else
|
||||||
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-state the migration where the operator is actually looking (#117). The
|
||||||
|
# line at the migration itself is true but ~250 lines of output ago; this is
|
||||||
|
# the last thing printed, and it is where an unexplained 'box versions' entry
|
||||||
|
# stops being a surprise.
|
||||||
|
if [ -n "$migrated_from" ]; then
|
||||||
|
log "note: your pre-0.7.0 install was migrated to versions/$migrated_from and 'box versions' now lists it."
|
||||||
|
log " keep it to roll back ('box use $migrated_from'), or remove it: box uninstall $migrated_from"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
21
templates/claude-box/box.env
Normal file
21
templates/claude-box/box.env
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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"
|
||||||
33
templates/claude-box/user-data.yaml
Normal file
33
templates/claude-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap 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
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# 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"
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
#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
|
|
||||||
21
templates/codex-box/box.env
Normal file
21
templates/codex-box/box.env
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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"
|
||||||
33
templates/codex-box/user-data.yaml
Normal file
33
templates/codex-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap 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
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# 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"
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
#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
|
|
||||||
21
templates/grok-box/box.env
Normal file
21
templates/grok-box/box.env
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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"
|
||||||
33
templates/grok-box/user-data.yaml
Normal file
33
templates/grok-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap 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
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# 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"
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#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
|
|
||||||
22
templates/kimi-box/box.env
Normal file
22
templates/kimi-box/box.env
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||||
|
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
|
||||||
|
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||||
|
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
|
||||||
|
# the role itself: heavy-duty/rig#109).
|
||||||
|
# The template is named for the role it converges, suffix and all
|
||||||
|
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||||
|
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||||
|
# 'kimi' would ask a post-rename rig for a role that no longer exists.
|
||||||
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
# BOX_USER must match the user user-data.yaml creates (the duplication is
|
||||||
|
# deliberate and by hand) — and it is the tenant user the rig role converges
|
||||||
|
# (rig dies loudly if the seed did not create it).
|
||||||
|
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
|
||||||
|
BOX_IMAGE="images:debian/13/cloud"
|
||||||
|
BOX_USER="kimi"
|
||||||
|
BOX_CPU="4"
|
||||||
|
BOX_MEMORY="8GiB"
|
||||||
|
BOX_DISK="60GiB"
|
||||||
|
BOX_BOOTSTRAP_ROLE="kimi-box"
|
||||||
33
templates/kimi-box/user-data.yaml
Normal file
33
templates/kimi-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
|
# once, instead of being copy-pasted per template.
|
||||||
|
users:
|
||||||
|
- name: kimi
|
||||||
|
shell: /bin/bash
|
||||||
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
|
lock_passwd: true
|
||||||
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
|
packages:
|
||||||
|
- tmux
|
||||||
|
- curl
|
||||||
|
- ca-certificates
|
||||||
|
runcmd:
|
||||||
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
|
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
|
# it installs, so a branch under review is testable end to end.
|
||||||
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
29
templates/staging-box/box.env
Normal file
29
templates/staging-box/box.env
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 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"
|
||||||
34
templates/staging-box/user-data.yaml
Normal file
34
templates/staging-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#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
|
||||||
2844
test/cli.sh
2844
test/cli.sh
File diff suppressed because it is too large
Load diff
129
test/release.sh
Normal file
129
test/release.sh
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
#!/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 ]
|
||||||
Loading…
Reference in a new issue