Commit graph

323 commits

Author SHA1 Message Date
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
dan-claude-bot
3a7a9f6c6b docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.

box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.

CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:46 +00:00
dan-claude-bot
a8d0f64e48 test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
  both the installer URL and the installer's env; RIG_REPO/RIG_REF override
  at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
  die on the host (bash =~ anchors the whole string — one clean line cannot
  sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
  shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
  cloud-init actually creates; a role-bearing seed installs rig carrying
  BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
  (comments may name what they refuse — #69's idiom) prove no agent CLI, no
  docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
  template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
  auto-run orders after the cloud-init wait and sits under the
  T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
  join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
  rig-less.

283 passed, 0 failed (main: 213); shellcheck -x clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:27 +00:00
dan-claude-bot
f4f25016d6 feat(templates): thin creds-free seeds — box mints, rig converges (#81)
The tenant content that lived in claude/codex/grok's cloud-init — agent CLI
installs, docker, node, the per-template agent-context heredocs — moves to
rig's bootstrap roles (rig#31), where it is convergent, idempotent and
testable end to end. What remains per template is a thin seed: the tenant
user, tmux (#65), and rig preinstalled — nothing that joins a tailnet or
admits credentials.

- BOX_BOOTSTRAP_ROLE: a template names the creds-free rig role cmd_new
  auto-runs inside the guest after cloud-init settles. The value is a role
  NAME by allowlist — anything shell-shaped dies at parse time, on the
  host. A failed role leaves the box up and names the re-run.
- render_userdata: the seed's ONE substitution. @RIG_REPO@/@RIG_REF@
  resolve from the mint environment (default heavy-duty/rig @ main —
  unpinned, the honest rig#29 treatment, until rig#32's releases); values
  are whole-string-validated before touching the YAML, because they land
  inside a runcmd shell line.
- templates/staging: the re-cut of #69's layering — user ops,
  BOX_REQUIRE_VM=1, BOX_AUTOSTART=1, role staging. The tailnet workload
  join holds a key and stays operator-run; cmd_new prints it as the next
  step and box never sees the key.
- blank stays a box with nobody home: no rig, no role, nothing auto-runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:27 +00:00
dan-claude-bot
56638d9654 docs: changelog — the UFW converge and the fail-closed boot window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
dan-claude-bot
ce9ec684d1 test(cli): drive box-firewall under a shim ufw, and the doctor's UFW findings
The real script, driven end to end (the setup-host seam): a fake ufw
serves canned status tables and logs every mutation, fake
nft/sysctl/iptables swallow the rest, the shim ip answers the
live-bridge read. Proven: a remapped bridge converges (stale tcp+udp
allows deleted, live gateway's inserted, live rules never deleted), an
agreeing host deletes nothing, a fresh host gets exactly the five
historical mutations, the unaddressed-bridge boot window mutates
NOTHING and says so (the fallback's absence is pinned to non-comment
lines), and a no-UFW host keeps its interface-scoped nft path — boot
window included. ufw_dns_findings is extracted and driven against the
same tables: agreement silent, stale carve-out flagged and named,
deny-with-no-allow flagged, untouched UFW host clean, stale-beside-live
named, other interfaces ignored; wiring grep-guarded. 297 checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
dan-claude-bot
fbad141a82 doctor: judge UFW's own DNS carve-out against the live gateway
The new ACL carve-out check compared only the incus ACL to the live
gateway — which setup-host now converges — so a remapped UFW host got a
clean bill while the stale UFW allow silently dropped box DNS: the
safety net had a blind spot precisely for UFW hosts using the #80
escape hatch (#86 review, item 1). ufw_dns_findings is pure text in
('ufw status' output, the network, the live gateway), findings out
(silence is agreement) — the gw_squat_signature seam, so the suite
drives it against canned tables. It flags a DNS allow that does not
match the gateway, our deny with no DNS allow at all, and stale allows
left beside a live one; a UFW host box-firewall never touched has
nothing to judge and stays clean. Wired into the admin Firewall section
wherever UFW is active and a bridge exists to compare against, with the
fix pointing at the now-converging box-firewall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
dan-claude-bot
ca1a759f3a box-firewall: converge the UFW carve-out off the live bridge; fail closed at boot
The UFW block was create-once: gated on 'a DENY on boxnet exists', it
pinned every UFW host to the gateway of the FIRST run. A bridge remapped
off a colliding subnet (#80's escape hatch) kept its stale
'allow ... to <old-gw> port 53' and never gained the live gateway's, so
box->gateway DNS died at box's own deny — the exact stale-carve-out
failure #86 rewrote the ACL path to prevent, left standing in the UFW
path (#86 review, item 1). The allows now converge on every run: DNS
allows aimed anywhere but the live gateway are deleted, then the live
set is ensured (ufw skips existing rules, so a fresh host gets the
identical five rules and a re-run is a no-op).

And the boot fallback GW=10.88.0.1 is gone (#86 review, item 2): with
the bridge not yet addressed when the service ran, a BOX_SUBNET host got
its carve-out built — and, once converging, converged — for the WRONG
gateway, a latent DNS drop. Fail closed instead: an unaddressed bridge
leaves the persisted UFW rules exactly as they are (they survive boots
on their own, and nothing else in this script needs the gateway — the
nft carve-out is interface-scoped) and says so on stderr; the next
setup-host run or service restart converges them once the bridge is up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
Daniel Marin
a4d6388b02
Merge pull request #86 from dan-claude-bot/fix/nested-host-guard
setup-host: refuse a claimed subnet (#80); BOX_SUBNET end-to-end; doctor learns the gateway-squat signature
2026-07-18 21:34:08 +01:00
Daniel Marin
b7468ecfe5
Merge pull request #87 from dan-claude-bot/fix/human-handoff-guard
fix: base the human auto-request on this handoff, not review history
2026-07-18 21:33:29 +01:00
dan-claude-bot
e877153e3c fix: base the human auto-request on THIS handoff, not review history
codex's late #85/#98 round-3 finding, valid post-merge: the needs-human
auto-request fired only when the human had NEVER reviewed, so any earlier
human comment or stale approval left a fully-approved PR labeled
needs-human with nobody actually requested — a wedged handoff.
human_request_needed() now asks whether a fresh head-current human review
is missing (live request or head-current approval → nothing to ask;
anything else → request). Five new fixtures cover the wedge, the stale
approval, the satisfied handoff, and request suppression (19 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:16:55 +00:00
Daniel Marin
7339377f61
Merge pull request #85 from dan-claude-bot/feat/label-automation
feat: label automation — state reconciler, path-scoped labeler, and CONTRIBUTING
2026-07-18 21:14:26 +01:00
dan-claude-bot
9d3fed8901 refactor: the author escalates — drop the agreement-body heuristic
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:51:52 +00:00
dan-claude-bot
53dc2b9047 docs: BOX_SUBNET, the setup-host refusal, and the doctor signature (#80)
README's setup-host section documents the subnet input and the refusal;
'box help setup-host' names BOX_SUBNET and the nested-box trap; 'box help
doctor' names the #80 signature it now judges; the changelog carries the
full story, including that suggested fix 4 (the agent-context guard note
for the templates) lands in heavy-duty/rig#31's bootstrap roles per the
thin-templates split (#81).

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