Commit graph

236 commits

Author SHA1 Message Date
Daniel Marin
2c4f83814f
Merge pull request #112 from dan-claude-bot/fix/confirm-eof-abort
fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
2026-07-19 22:27:57 +01:00
dan-claude-bot
bb51aeeca9 fix: close the silent-EOF class at the two host/ gates it also reached (#111)
The first pass fixed #111 where it was reported — confirm() and
uninstall_confirm() in bin/box — and stopped there, while the same defect
sat at two more destructive gates in this repo:

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

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

The three drill/ prompts are deliberately untouched: they run under
'set -u' only, so EOF falls through to the '*)' arm and already aborts
out loud. install.sh:65 was already guarded.

What keeps the class closed is a repo-wide sweep in test/cli.sh: every
statement-initial 'read' fed from stdin, in any file that turns on
errexit, must carry a '||' guard — 'while read' loops and '<<<'
herestrings excluded, since neither is a prompt. Removing any of the four
guards makes it fail and name the file:line; it flags nothing else across
the tree's fifteen shell files. The absence of exactly this check is why
the host/ pair was missed the first time.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:18:36 +00:00
dan-claude-bot
dc09d54cbe fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.

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

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

Closes #102

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

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

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

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

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

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

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

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

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

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

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

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

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

Unblocks heavy-duty/rig#49.

Closes #99

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

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

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

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

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

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

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

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

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

Fixes #96

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:31 +00:00
Daniel Marin
fb7fc845bf
Merge pull request #95 from dan-claude-bot/release/0.7.0
release: 0.7.0
2026-07-19 15:24:18 +01:00
dan-claude-bot
07c0edd60a release: 0.7.0
The first release cut by the flow itself (#83): VERSION leaves 0.6.1-dev
for 0.7.0 and the Unreleased section is stamped with today's date. The
whole surface was drilled twice on 2026-07-19 — before and after the #92/
#93/#94 round — 85/0 on real incus both times, all three install channels
live, and the ceremony itself rehearsed end to end on a scratch fork
(mismatch and missing-section guards firing, the real path publishing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:44:32 +00:00
Daniel Marin
24797c9052
Merge pull request #94 from dan-claude-bot/fix/mint-launch-timeout
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93)
2026-07-19 14:13:02 +01:00
dan-claude-bot
6fc5ba4d0a docs: the changelog entry tells the round-2 story — probe, branch, cleanup
The entry ended at round 1's unconditional wedge message; the shipped
behavior probes the instance, narrates wedge vs overrun, and best-effort
deletes so the retry is clean either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:01:49 +00:00
dan-claude-bot
12edb1966f chore: the four new grep-proof checks carry the SC2016 directive their siblings had
CI shellchecks every shell file; the round-2 checks embed $ROOT in
single-quoted bash -c strings exactly like the check above them, but the
disable directive only covers the next command — each new check gets its
own, same as the repo pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:59:58 +00:00
dan-claude-bot
4da487e388 fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
dan-claude-bot
8ab9b38ba2 fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93)
Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm
present, images cached), the child 'incus launch' under 'box new' wedged
with no server-side operation: 'incus operation list' empty, the instance
never created, the daemon journal quiet — one wedge ran 56 minutes before
being killed by hand, the other was killed by a 540s wrapper. An immediate
retry of the identical command succeeded in ~2-3 minutes, both times. box
inherited that as an indefinite silent hang, indistinguishable from a cold
mint working.

The mint now prints "launching instance ..." before the call, and the call
rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous:
the coldest measured mint is minutes, never an hour; overridable the same
way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13.
When the budget fires (124, or 137 when the KILL was needed) the failure
says exactly what was measured — the client wedged with no server-side
operation, an immediate retry has been observed to succeed — and points at
'box doctor' for host state. A non-timeout launch failure still surfaces
incus's own stderr. The --from clone path is untouched: 'incus copy' of a
local instance is a different operation and has never been observed to
wedge this way.

Proven the way the other mint-path guards are (a daemon-free run cannot
mint): test/cli.sh greps that the narration orders before the launch, that
the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and
pinned stdin, and that the wedge message carries the retry hint, the
doctor, and #93 — plus a live shim-incus drive of all three exits (wedge,
plain refusal, success) during development.

Fixes #93

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
Daniel Marin
e40d82afa6
Merge pull request #90 from dan-claude-bot/feat/release-flow
feat: release flow — install-from-tag, release.yml, and -dev versions (#83)
2026-07-18 23:24:34 +01:00
dan-claude-bot
3122729877 fix: release.yml triggers on every tag, so a mismatch fails loudly
A shape-filtered trigger silently skips the tags it doesn't match — a
habitual v0.7.0 would mint nothing and say nothing. rig's release.yml
(heavy-duty/rig#40) already triggers on '**' and lets the tag==VERSION
assert be the loud gate; align box to that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:18:23 +00:00
dan-claude-bot
3cac269b5b docs: the three install channels and the release ritual (#83)
README: the installer's default is the latest release — reproducible,
resolved off the releases/latest redirect, failing loudly rather than
silently handing out main — with the pinned (BOX_REF=<tag>) and dev
(BOX_REF=main) channels beside it. CONTRIBUTING: a release is a PR,
then a tag — the release PR bumps VERSION and stamps the changelog,
the bare X.Y.Z tag on the merge commit triggers release.yml, and
main's VERSION bumps to X.Y.(Z+1)-dev immediately after, because the
versioned layout names install trees after VERSION. Plus the
CHANGELOG entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:18:23 +00:00
dan-claude-bot
646c458e25 test(release): the extraction, the resolution, and every channel — offline (#83)
test/release.sh, wired into CI beside the other suites and never
touching the network: release-notes.sh is driven against a fixture
changelog carrying every boundary (Unreleased must not leak, the
version is matched whole so 0.7.0 never grabs 0.7.0-rc1, a missing
or empty section refuses) AND against the real CHANGELOG.md, so the
header format cannot drift under release.yml. latest_release_tag is
extracted from install.sh and driven against a shim curl serving
canned redirects — including the no-releases redirect and a network
failure. And the three channels are REAL install.sh runs against
throwaway roots with the shim standing in for GitHub: the default
resolves and downloads the tag tarball (never a branch), a pin skips
the probe, BOX_REF=main falls back tag → branch in that order, and a
failed resolution refuses loudly having downloaded nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:17:51 +00:00
dan-claude-bot
7044f8e0c1 chore: bump main to 0.6.1-dev — a dev install must not impersonate 0.6.0 (#83)
0.6.0 is released; the versioned layout names install trees after
the tree's own VERSION file — so a main install at VERSION 0.6.0
would land in versions/0.6.0, impersonating the released tree: the
converging no-op then refuses to update it, and BOX_REINSTALL=1
silently swaps a release tree for a dev one under the same name. A
-dev version keeps dev installs side by side with releases. The
post-release bump is now part of the release ritual (CONTRIBUTING).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:17:51 +00:00
dan-claude-bot
6d4996e532 feat(install): three channels — the latest release by default, tags before branches (#83)
curl | bash used to hand out whatever main was at that second: the
release was a bookmark, not a package. install.sh now resolves the
latest release tag by following GitHub's releases/latest redirect
(one HEAD request via -w '%{redirect_url}' — no API, no token, no
rate-limit pain) and downloads that tag's tarball. A failed
resolution REFUSES with the way out (BOX_REF) — it never hangs and
never silently falls back to main. A set BOX_REF is tried as a tag
first (the pinned channel), then as a branch (the dev channel), so
one knob yields three channels: default = latest release,
BOX_REF=0.6.0 = pinned, BOX_REF=main = dev. The resolution happens
AFTER the confirm — even a redirect probe is network the operator
has not yet said yes to — and INSTALLED_FROM records the resolved
tag, so a caller can assert it got what it asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:17:51 +00:00
dan-claude-bot
dd51b06f3a feat(release): publish the tagged version's changelog section (#83)
On a bare X.Y.Z tag push (the 0.6.0 tag set the no-'v' precedent),
release.yml asserts the tag names the tree's own VERSION — a mismatch
fails loudly and creates NOTHING — then publishes the GitHub release
with that version's CHANGELOG.md section as the body: the curated
prose, not the generated PR list. No assets, because for a pure-bash
tree GitHub's source tarball for the tag IS the package.

The extraction lives in .github/scripts/release-notes.sh, a file of
its own so test/release.sh drives the same code against fixtures and
the real changelog — it refuses a missing or empty section, so a tag
whose release ritual was skipped fails before anything is created.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:17:51 +00:00
Daniel Marin
88db8cab04
Merge pull request #91 from dan-claude-bot/feat/subnet-autopick
feat(setup-host): auto-pick a free subnet — nested box-in-box with zero flags (#80)
2026-07-18 23:15:22 +01:00
dan-claude-bot
ff94af93c3 docs: the subnet auto-pick — help, README, changelog, doctor wording (#80)
help setup-host and the README now lead with what a bare run does (free
default → 10.88; existing bridge → converge; claimed default → auto-pick
10.89…10.127, announced) and demote BOX_SUBNET to what it is: the pin for
scripted hosts, honored or refused, never overridden. The changelog names
the drill/rehearsal payoff — nested box-in-box with zero flags. The
doctor's this-machine #80 verdict stops saying "setup-host now refuses
this" (it no longer does, it picks around it): a poisoned stack predates
the fix or was pinned onto the uplink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:22:00 +00:00
dan-claude-bot
5d893ba2e1 test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.

The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:22:00 +00:00
dan-claude-bot
fee1929d10 feat(setup-host): auto-pick a free subnet — nested box-in-box, zero flags (#80)
#86 shipped the refuse half of #80's fix #1; this completes the other half:
"refuse … or automatically select a non-colliding subnet". The bare
setup-host that used to stop an agent mid-drill inside a box now decides
the subnet itself. choose_subnet is the one place that decision happens,
before any mutation — BOX_GW, the bridge, the ACL carve-out, the firewall
and the doctor's expectations all derive from its answer. Four deliberate
cases:

1. explicit BOX_SUBNET — honored or refused, never silently overridden:
   a script that says 10.90 gets 10.90 or a loud stop. The claimant
   refusal and the bridge agree-gate are unchanged in spirit.
2. no pin, boxnet exists — converge to the bridge's own subnet: the
   bridge IS the pin (boxes hold leases on it). The old agree-gate
   refusal on a bare re-run against a moved bridge becomes convergence,
   announced when the bridge is off-default, silent on the plain
   default-host re-run. A FOREIGN claimant on the bridge's own subnet
   still refuses — that is #80's poisoned state, and converging would
   rebuild the blackouts; the refusal names the bridge move instead.
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, whose own uplink owns 10.88): scan
   10.89.0.0/24 … 10.127.0.0/24 in order, take the first free candidate,
   announce the pick, the claimant and the BOX_SUBNET pin for scripts;
   refuse only when every candidate is claimed. The scan only ever runs
   bridge-less — an existing bridge is case 2, which precedes it.

Pure over ip (via subnet_claimant and the bridge read), so test/cli.sh
drives every case against canned tables with the shim ip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:22:00 +00:00
Daniel Marin
a1eb2054ca
Merge pull request #89 from dan-claude-bot/fix/ufw-subnet-converge
box-firewall: converge the UFW carve-out off the live bridge; fail closed at boot (#86 follow-up)
2026-07-18 22:14:17 +01:00
Daniel Marin
9cf10d58a7
Merge pull request #88 from dan-claude-bot/feat/thin-templates
feat: thin templates — box mints, rig converges (#81)
2026-07-18 22:08:43 +01:00
dan-claude-bot
fb1f5e6f98 test(cli): pin the HOME=/root scar so a seed rewrite cannot drop it silently
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:52:15 +00:00
dan-claude-bot
24e67a3c2b fix(templates): pin HOME=/root on the seed's rig install — runcmd has no $HOME
Measured live on the first e2e mint: cloud-init runs runcmd as root but
with NO $HOME in the environment, and rig's installer (set -u) reads
$HOME for its DEST — the mint died with 'HOME: unbound variable'. The
seed line now pins HOME=/root explicitly, with the scar documented next
to it in every tenant template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:46 +00:00