Commit graph

104 commits

Author SHA1 Message Date
dan-claude-bot
0dbcad2e3a test: pin exactly one on.push key
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:14:42 +00:00
dan-claude-bot
4b5d62e405 fix: one on.push key — YAML last-key-wins had dropped the tag door
grok's round-2 catch: two sibling push: maps under on: leave only the
second (branches: [main]) alive — the tag-push fallback stopped
triggering entirely. Both filters now live under one push key, the jobs
still split on the pushed ref, and a pin counts exactly one on.push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:14:13 +00:00
dan-claude-bot
053e12d51e 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, so no recursion and no
red run); if branch protection refuses, the step opens the bump PR
itself, loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:02:53 +00:00
dan-claude-bot
44717863a1 fix: the merge door rides pushes to main — fork PR tokens are read-only
Round-1 blocker (grok, claude on box#97's twin): 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 reads the version transition from event.before
(first-parent fallback for the all-zeros edge), 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 two
doors now split on the pushed ref: tags to the tag door, main to this one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:44:52 +00:00
dan-claude-bot
c0cd4a1b15 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), 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, green NOTICE no-op. Red now guards only bare
endstates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:42:27 +00:00
dan-claude-bot
d08ec8c6f6 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 — including every PR that improves this very workflow. The old assert
pair turned each of those merges into a red run on main. The fused decide
step reads the version against the PR base and answers all four 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:23:56 +00:00
dan-claude-bot
4ce1ab50aa feat: merging a release-labeled PR is the release (#47)
The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.

release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.

test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.

Fixes #47

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:55 +00:00
Daniel Marin
219b0fbd72
Merge pull request #45 from dan-claude-bot/fix/release-suite-stamped-tree
fix: the release suite accepts the ceremony's own tree
2026-07-19 15:22:15 +01:00
dan-claude-bot
589854ae0b fix: the release suite accepts the ceremony's own tree (#44)
test/release.sh demanded a literal '## Unreleased' heading extracting
non-empty with '#32' in it — all false by construction on the very tree
the release PR produces, so the first real 'release: 0.1.0' PR turned CI
red and the ceremony blocked itself. Both fork rehearsals missed it: they
tag a branch, which runs release.yml and never ci.yml. The guard now
asserts its actual purpose — the TOP section, whatever its name, extracts
non-empty via the exact function release.yml runs — and passes on both
legitimate tree states (verified on main's shape and on a stamped copy).

Fixes #44

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:48:41 +00:00
Daniel Marin
525e24acd6
Merge pull request #43 from dan-claude-bot/fix/silent-prompts
fix: headless credential prompts refuse loudly, naming their variable
2026-07-19 14:00:48 +01:00
dan-claude-bot
e72663ef62 fix: headless credential prompts refuse loudly, naming their variable (#42)
A bare 'read -rsp' with no tty exits non-zero and set -e ends the script
with no output at all: the release drill watched 'rig runner remove' exit 1
in complete silence, and a guest bootstrap stop mid-log the same way. Every
prompt now checks for a tty first and dies naming the variable that
unblocks an unattended run; every read is || die-guarded so EOF at a real
prompt also gets a last word. The no-bare-read test swept up runner
repoint's two prompts, which the issue had not counted.

Fixes #42

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:15:08 +00:00
Daniel Marin
fb0c3067e3
Merge pull request #41 from dan-claude-bot/fix/home-unset
install: derive $HOME from getent when the environment has none (#39)
2026-07-19 00:53:21 +01:00
dan-claude-bot
229fcd2bf0 install: derive $HOME from getent when the environment has none (#39)
cloud-init's runcmd runs the installer with no $HOME, and under set -u
the first expansion died with an unbound-variable stack instead of an
install — found live by box#88's seed, which pins HOME=/root as its own
scar. Derive the home from getent for the effective user (root included)
before any path comes from $HOME; when getent has no answer either,
refuse by name instead of a bash stack. Driven with a shim getent: the
derived-home install lands, and the no-answer refusal is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:36:05 +00:00
Daniel Marin
a3c69292a1
Merge pull request #40 from dan-claude-bot/feat/release-flow
feat: release flow — CHANGELOG, release.yml, and a tag-resolving installer (#32)
2026-07-18 23:17:12 +01:00
dan-claude-bot
834f0504d6 feat(install): default to the latest release — three channels, one installer (#32)
RIG_REF unset now means the latest RELEASE: the tag is resolved by
following the releases/latest redirect and reading the Location header
(no API, no token), and the download is archive/refs/tags/<tag>.tar.gz.
An explicit RIG_REF tries refs/tags first — a pin must outrank a branch
that shares its name — and falls back to refs/heads, which keeps
RIG_REF=main the dev channel. "Re-run any time to upgrade" now means
upgrade to the latest release.

Transitional, and loud about it: rig has no release until 0.1.0 is cut,
so the default channel dies naming RIG_REF=main as the way to install
today — never a silent fallback to main, because "I installed the latest
release" must not quietly mean "I installed whatever main was that
second". test/release.sh drives all three channels through the real
install.sh with a stubbed curl (no network in CI), and the resolve/
candidate functions are extracted and exercised the valid_version way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:57:13 +00:00
dan-claude-bot
b6205d81c5 feat(release): CHANGELOG.md and release.yml — a tag becomes the release it claims (#32)
The PR-then-tag half of box#83's flow, rig's side. CHANGELOG.md starts
with an Unreleased section (feature PRs land their entry as part of the
PR — box's convention, now written into CONTRIBUTING alongside the
release ritual). On a tag push, release.yml asserts the bare tag equals
the tree's own VERSION — a mismatch fails loudly and creates nothing —
then creates the GitHub release with that version's changelog section as
the body, extracted by changelog_section in .github/scripts/release-lib.sh:
one function, sourced by the workflow and driven by test/release.sh
against fixtures and the shipped CHANGELOG.md itself. No assets — for a
pure-bash tree, the tag's source tarball IS the package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:57:13 +00:00
Daniel Marin
e6b630b9f2
Merge pull request #37 from dan-claude-bot/feat/bootstrap-roles
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
2026-07-18 21:32:13 +01:00
Daniel Marin
7e57c10f01
Merge pull request #38 from dan-claude-bot/fix/human-handoff-guard
fix: base the human auto-request on this handoff, not review history
2026-07-18 21:31:51 +01:00
dan-claude-bot
1cef6ed751 review r1: staging tolerates only the workload guest; dockerd must answer; one CLI capture
- The staging marker tolerance now says what the docs meant: class=server
  with host=no only. A non-server machine (class=human via custom) refuses
  with its own message instead of dying later inside harden_sshd with
  server-specific advice. Fixture pins the refusal.
- The docker converge asserts the DAEMON answers (docker info, bounded
  30s settle), not just the client binary — grep-pinned.
- The agent-CLI version check is one capture serving assert and log;
  emptiness is the failure signal (head exits 0, a pipeline status can't be).
- Harness gains the codex login-flow context grep alongside claude/grok.

Verified: test/cli.sh 244/0, shellcheck -x clean, live container e2e
(staging round 1 + convergence round 2, dockerd answering).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:18:15 +00:00
Daniel Marin
ad33ffe844
Merge pull request #36 from dan-claude-bot/feat/versioned-installs
feat(install): versioned installs and a real uninstall — box#79's layout, ported (#35)
2026-07-18 21:18:04 +01:00
dan-claude-bot
ec7096c08e 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:57 +00:00
Daniel Marin
0ead75428b
Merge pull request #34 from dan-claude-bot/feat/label-automation
feat: label automation — state reconciler, path-scoped labeler, and CONTRIBUTING
2026-07-18 21:02:56 +01:00
dan-claude-bot
03e5deca56 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:53 +00:00
dan-claude-bot
e40e223ecb docs: README — the box tenants section, and where the staging preset went
The layering story in the README's own voice: thin seeds on the box side,
tenant roles here; the per-tenant table; the creds-free vs operator-run
split; the box#80 guard living once in the renderer; the tenant marker and
its guards; and the RIG_REPO/RIG_REF pin-point note — the seed's rig install
tracks main unpinned, documented with the same honesty as the box unpinned
note (the inverted edge, rig#32 is the release flow that gives it something
to pin to). The machine-role section loses the staging row and gains the
"where the preset went" note: the VM-host shape is dev --class server or
custom with all three traits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:27 +00:00
dan-claude-bot
739db6e633 feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.

The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.

'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.

Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
dan-claude-bot
f1ada543dc feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.

bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).

The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.

The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.

Closes #35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
dan-claude-bot
27fb934780 fix: verdict contract, head-bound approvals, serialized reconcile — and a testable state machine
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
  durable markers (Verdict: Approve / I agree with everything / leading ) —
  the gate to needs-human can actually close. Formal verdicts remain the
  contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
  PR in addressing (agent owes re-request) instead of promoting unreviewed
  code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
  per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
  edits warn instead of wedging; the self-heal claim now matches reality
  (dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
  test/labels-reconcile.sh proves 14 fixture transitions — comment-only
  agreement, stale approval, comment-without-verdict, human precedence and
  human-block — wired into CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:31:02 +00:00
dan-claude-bot
52dfc5c5c1 docs: reviews end in a verdict — approve or request changes, never a bare comment
Maintainer rule: a comment-only review is a non-verdict the state machine
(and the board) cannot read. Verdict carries blockingness only; nits ride an
approval, blockers — including verdict-gating questions — are request-changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:50:51 +00:00
dan-claude-bot
7109bb39d8 feat: label automation — the state reconciler, path-scoped labeler, and CONTRIBUTING
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:31:53 +00:00
Daniel Marin
7646346311
Merge pull request #33 from dan-claude-bot/docs/label-taxonomy
docs: LABELS.md — the label taxonomy (states, stale/blocked, scopes)
2026-07-18 19:20:06 +01:00
dan-claude-bot
ca37eb8450 docs: make the bootstrap trim idempotent — swallow delete of an already-gone label
All three bot reviewers flagged it: gh label delete is not an upsert, so a
second run of the safe-to-run block errored on the trim lines. Loop + '|| true'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:04:51 +00:00
Daniel Marin
539dee064a
Merge pull request #30 from dan-claude-bot/feat/close-root-reachability
users: finish #17 — close-root proves the door (sudo -n, per-user sshd -T), @root key seeding, runner row owned
2026-07-18 18:39:35 +01:00
Daniel Marin
0e2d66f456
Merge pull request #29 from dan-claude-bot/feat/class-model-remnants
feat: the #12/#25 remnants — README class table + box rename, unpinned-install note, box effective check, coolify marker warnings
2026-07-18 18:36:32 +01:00
dan-claude-bot
e07a8ab567 docs: LABELS.md — the label taxonomy (states, stale/blocked, scopes)
Documents the shared heavy-duty label design at the root: state labels
that answer 'who is the ball with' across the bot-review loop, sweep-managed
stale + blocked, rig's scope set, and the bootstrap block. State labels are
automation-owned; the reconciler workflow is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:34:49 +00:00
dan-claude-bot
a32d2b04cb fix(close-root): the gate judges AllowGroups/DenyGroups too — same door, other hinge
Round-2 convergence (codex + claude-bot): sshd enforces the group
directives against the candidate's ACTUAL membership, and the gate read
only the *Users pair — an admin outside 'AllowGroups sudo' still
reached ADMIN_OK=1, and root closed on a false proof. The gate now
resolves id -Gn and judges both group directives with the *Users
discipline: DenyGroups flags on a held-group literal or ANY
pattern/host-qualified token; AllowGroups, when set, passes only on a
literal token naming a held group (a pattern that would admit proves
nothing — over-refusing stays the safe error). id failing yields no
groups, which makes a set AllowGroups flag: fail closed there too.

Both requested regressions ride the sourced lib (unmet AllowGroups,
DenyGroups naming a held group) plus the pattern/pass cases, and grep
guards pin the shipped gate to the verdicts and to real membership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:17:39 +00:00
dan-claude-bot
af327cb660 docs(bootstrap): finish the sentence the doctor-gate edit truncated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:16:29 +00:00
dan-claude-bot
a5b48d5b2c fix(close-root): DenyUsers judged fail-closed — patterns and USER@HOST flag
All three reviewers, same substance, and they were right that it was
lockout-adjacent: the literal grep passed a candidate whom a DenyUsers
PATTERN really denies ('DenyUsers dan*' vs admin 'dan'), and the door
closed on a false proof. The judgment now lives in the lib as a pure
deny_verdict: a literal hit flags, and so does ANY pattern or
host-qualified token — a token the check cannot prove irrelevant counts
as a hit, never as a pass. The asymmetry with AllowUsers is now the
same direction on both sides: every error closes toward repair, never
toward a welded-shut door.

Also (claude-bot): the -C probe resolves Match blocks against a
synthetic addr=127.0.0.1, so Match Address is out of the local proof's
scope — named in --help, the README, and the gate's comment, so the
separate-session advisory reads as load-bearing, not ceremony.

Regressions ride the sourced lib: wildcard (the review's dan* case),
'?', USER@HOST, literal hit, irrelevant-literals pass, plus a grep
guard that the shipped gate consults deny_verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:02:48 +00:00
dan-claude-bot
cee7d4575e fix(bootstrap): gate the host-set-up claim on 'box doctor', not on PATH
All three reviewers, same substance: 'command -v box' proves the CLI
landed, not that setup-host took effect — and the success line claimed
both. The claim is now split to match its proofs: PATH proves the
install; 'box doctor' (box's own effective-state verdict — the daemon
stays box's domain) gates "host set up". A failed doctor WARNS with the
verdict verb and the manual path, and claims nothing it cannot prove.

Also: the coolify marker guard matched 'role=control-plane ' by its
trailing space, coupling it to the marker's field formatting — a bare
'role=control-plane' line now reads the same (claude-bot's nit), with a
fixture proving it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:59:43 +00:00
dan-claude-bot
2396880f09 docs: plan — close-root proves the door + @root seeding, what remained of #17
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:54:03 +00:00
dan-claude-bot
ceceb71e34 docs(users): own the runner-row divergence from #17 — server class keeps root, deliberately, runner included
#17's table said runner 'can close root once an admin user is proven'; the
class model (#26) superseded the per-role call, and close-root refuses on
class=server — runner's class. The gate does not change: the refusal message
now explains itself (server-class machines are automation identities whose
management plane IS root SSH; a CI box meant to be administered like a human
machine is --class human at bootstrap, not an exception), and the README's
identity-model section records the divergence in one paragraph. README also
documents the @root seed token and close-root's reachability proofs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:54:03 +00:00
dan-claude-bot
d1b6fec5f8 feat(users): close-root proves the door opens, not that it should — sudo -n and per-user sshd -T join the gate
The StrictModes-shaped gate reads files, and files can all look right while
the door stays shut: a sudoers drop-in that never landed, an AllowUsers or
Match block elsewhere in sshd's config. #17 names the two checks that
interrogate behavior instead, and they now run per candidate, additively,
before the drop-in installs: 'runuser -u <admin> -- sudo -n true' (NOPASSWD
sudo answers or it does not — -n never prompts; a missing runuser skips the
proof with a loud warning rather than blocking the door on a missing
prover), and 'sshd -T -C user=<admin>,host=...,addr=...' (the per-user
EFFECTIVE config — pubkeyauthentication yes, no literal DenyUsers hit,
AllowUsers if set must name them; Allow/Deny patterns match literally, fail
closed). The one thing no local check can prove remains possession of the
private key — the separate-session advisory stays load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:53:50 +00:00
dan-claude-bot
fff45a9835 feat(users): @root seeds the admin's keys from root's own — the one source that cannot lock you out
The headline of #17: rig can verify a lot locally, but never that the
operator HOLDS the admin's private key. Seeding authorized_keys from root's
current /root/.ssh/authorized_keys turns that unprovable claim into a proven
one — the operator is connected as root right now using one of those keys.
The users file gains the literal key-field token '@root', shape-validated in
the parse pass (exit 2, pre-root-check, testable non-root); apply resolves
it once after the root check, dies with the repair when root has no keys to
seed, copies key lines verbatim (options included — rig will not silently
widen what a key can do), and writes seeded keys first with literal lines
appended, so the cmp-guard keeps re-runs convergent to root's then-current
keys plus the literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:53:30 +00:00
dan-claude-bot
900697bdc2 feat(coolify): warn when the role marker names a non-control-plane box — advisory, never a gate (#25)
Issue #25 named this consumer when it introduced /etc/rig/role: 'rig
<cmd> sanity warnings later (e.g. coolify install on a non-control-plane
box)'. Both coolify verbs now read the marker through the lib's
read_role_marker (RIG_ROLE_MARKER overrides the path for fixtures, repo
precedent) and warn when it names any role but control-plane — the
likeliest story is the wrong SSH session about to put a control plane on
a workload box.

The marker stays advisory: it may be absent (pre-marker boxes,
hand-built boxes) and absence stays silent — warning there would nag
every legitimate run — and a present-but-different marker warns and
proceeds, because an advisory file must never outrank the operator
(contrast close-root, where the marker IS the gate: shutting the root
door blind is irreversible in a way an extra Coolify is not). The check
sits after arg validation and before the root check, so exit codes are
untouched (usage stays 2, the root refusal stays 1) and the harness
proves it non-root.

Tests drive the live matrix through fixture markers (warns on workload,
silent on control-plane and on absence, still exits 1 at the root
check) and pin the warning's presence in both shipped scripts for
root-run environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:46 +00:00
dan-claude-bot
34f1986da0 feat(bootstrap): prove box landed on PATH after a claimed install success — don't trust exit codes (#12)
Issue #12's review comment named the failure shape exactly: box's
setup-host is written for a sudo-capable user and one of its paths exits
0 after only adding a group, asking for a re-login — so an installer's
exit code can claim a success that never took effect. That is the sshd
first-wins bug's shape, and rig's doctrine is to assert effective state.

The check stays deliberately light: command -v box proves the one
artifact rig asked the installer for. Anything deeper — daemon, pool,
network — is box's domain; rig never interrogates Incus, so the success
log hands the operator 'box doctor' (box's own effective-state verdict)
instead of reimplementing it. A hollow success WARNS with the manual
pointer, never dies: box is the host extra, and the OS+tailnet core is
already done and asserted by the time this block runs.

Tests grep the shipped script (the check needs root + network to
exercise): the call, the warn wording, the delegation to box doctor,
and a fail-closed line-number assert that the check follows the
installer run. Rides along: the README rename greps (#12) — the stale
heavy-duty/claudebox slug is negative-grepped out for good.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:46 +00:00
dan-claude-bot
d0ec6a6548 docs(readme): the #12/#25 documentation remnants — box rename, per-role identity table, unpinned-install note
Three content-only edits. The philosophy line pointed at
heavy-duty/claudebox, a slug that only works through a GitHub redirect;
the repo is heavy-duty/box now (#12). The identity model section gets
issue #25's at-a-glance class comparison, translated onto the traits
that replaced the class binary, plus the who-installs-what /
who-runs-as-what paragraph. And the host-class box install now says out
loud that it tracks a moving heavy-duty/box@main — box cuts no tags and
its installer resolves refs/heads only, so there is nothing to pin to;
BOX_REPO/BOX_REF are the pin points the day that changes, and
RIG_SKIP_BOX_INSTALL=1 the opt-out. Issue #12's decision was that
silently tracking main on the box that runs the agents is the option
not to pick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:16 +00:00
dan-claude-bot
ea91a00b98 docs(plans): class-model remnants plan — what still keeps #12 and #25 open after PRs #27/#28
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:16 +00:00
Daniel Marin
7d11ce23f6
Merge pull request #28 from dan-claude-bot/feat/host-installs-box
feat(bootstrap): host-class machines install box + run setup-host (#12, #25)
2026-07-18 01:25:20 +01:00
dan-claude-bot
f3cfa7c358 feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.

rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.

- Convergent: box's installer is a no-op once box is installed, so re-running
  bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
  pointer) when curl or the network is missing — box is the host EXTRA, so a
  failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
  it claims never installs box on a half-built host.

The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.

Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).

Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
Daniel Marin
be3761a5df
Merge pull request #27 from dan-claude-bot/feat/users-and-traits
Machine traits + fleet users: class/host/join presets and rig users apply/status/close-root (#26 + #24)
2026-07-17 22:24:50 +01:00
Dan Claude Van Damme
c44a645670 fix(users): close-root no-op must prove the door, not the file
The clean-file fast path exited before the sshd -T assertion, so matching
bytes alone bought the 'root already closed' claim. Two ways that lies: an
earlier-sorting drop-in wins the first-wins fight while our file sits
pretty, and a prior run that died between install and restart leaves a
daemon that never read the file — sshd -T can't see that one either, since
it re-parses disk rather than interrogating the running daemon.

Now the no-op is taken only when the bytes match AND systemd says sshd
started strictly after the newest mtime across everything sshd reads (main
config, drop-in dir, drop-ins); anything less restarts behind the same
sshd -t gate, and the effective-config assertion runs on every path before
any success claim. Harness pins both: assert-before-claim ordering and the
daemon-start-vs-config-mtime proof.

Addresses PR #27 review (clean-file fast path convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:49:12 +00:00