Commit graph

295 commits

Author SHA1 Message Date
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
Claude
00c54a342d docs: plan addendum — review findings and the contract refinements they forced
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:02:09 +00:00
Claude
3eeab687d0 fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:

- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
  not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
  role rig silently root-equivalent through the very command that granted it.
  Identity management now refuses any sudo invoker outside rig-admin; direct
  root (bring-up, a root shell) proceeds.

- Offboarding revokes SSH, not just the password: a '!'-locked password is
  not a closed door under UsePAM — Debian sshd still honors the pubkey. A
  dropped user's account is now expired (usermod -L -e 1, the switch PAM
  actually enforces) and authorized_keys is renamed to
  authorized_keys.revoked-by-rig — access revoked, data kept, convergence
  never destroys. Present users get their expiry cleared idempotently, so a
  re-added user comes back to life.

- The ledger remembers: two-field lines ('name active' / 'name revoked',
  legacy bare names read as active), so dropped users no longer vanish from
  rig's memory on the next rewrite. status now reports the ledger state
  corroborated by the account's real expiry — passwd -S read L for everyone
  (apply locks all passwords always), so its locked/active was meaningless —
  and flags a mismatch loudly as drift.

- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
  and mode converge on every run, not only when content changes — StrictModes
  treats them as load-bearing, so drifted perms were a broken login that
  "already converged" lied about. Only the content write stays cmp-guarded.

- close-root's admin-door gate checks the StrictModes shape per candidate —
  ownership, group/world-writability of home/.ssh/authorized_keys, a real
  login shell, an unexpired account — and names which check failed. It proves
  the door SHOULD open, not that it does; the separate-session advisory stays
  load-bearing.

- Usernames are validated in the parser's one-pass refusal matrix
  (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
  stream, and a leading '-' read as a useradd flag mid-convergence.

- The box role is trait-aware: on a host=no box an absent incus group skips
  the role with a warning and converges everything else — one box-role user
  in a fleet-wide file must not abort apply everywhere VMs don't live.
  host=yes still dies pointing at box setup-host; a classless marker warns
  toward a bootstrap re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
Claude
062dad4ead fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion
Three refusals, one doctrine: detect, refuse, name the repair — and never
back out state rig did not create.

- verify_effective_tag grows the same <back-out|keep> mode discipline as
  verify_user_owned. First join keeps the logout-and-die on an untagged key;
  the already-joined path now refuses WITHOUT logout — the untagged node may
  be a login-joined workstation (untagged by design) that a join=authkey
  re-run must not tear off the tailnet. The die names both ways out.

- verify_user_owned fails CLOSED on a stalled backend: empty tags is its
  success signal, so a 30s poll that never saw Running waved a tagged node
  on a slow tailscaled through as user-owned. state!=Running now dies in
  both modes, logging nothing out — nothing was verified, so the repair is
  to re-run and verify, not to undo a join that may be fine.

- The permitrootlogin acceptance is class-gated. class=human keeps
  no|prohibit-password|without-password (`no` is the close-root state).
  class=server accepts only prohibit-password|without-password: root SSH is
  the control plane's automation door, and `no` there means a leftover
  00-rig-users.conf from a former class=human life has fleet management
  silently dead. Refused loudly, drop-in named, never auto-removed —
  silently reopening a root door is worse than a loud stop.

Harness greps pin all three die messages so a deleted guard cannot ship
green (repo precedent: the tag-refusal greps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
Claude
22ef458b1a docs: README identity model — traits, presets, fleet users, root's two fates
The bootstrap section now documents what shipped: roles as presets over the
three orthogonal traits (class/host/join, every one overridable, custom
states all of them), the derived tag:server policy, the /etc/rig/role marker
that records effective traits so an overridden role never lies, and the
join=login path where the tag assertion inverts — untagged is asserted, a
tag is the refusal. A new identity-model section carries the hybrid access
model: named operators on every class, humans never entering as root, class
deciding root SSH's fate after `rig users apply` — closed on human, kept as
the control plane's automation door on server — with the detection benefit
and the honest attribution-not-privilege caveat stated plainly. Per-command
sections cover apply/status/close-root, including the first-wins drop-in
mechanism and the README-only from= guidance for Coolify's key on servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:32:23 +00:00
Claude
2dc396d557 feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
Claude
9bdc4db575 feat(users): declarative operators — apply/status over a users file, every class
Operators become a declared fact, not an accumulation of adduser runs: a
line-based, bash-parseable users file (no YAML, no jq — a rig box has
neither) names each user, their roles, and their keys, and apply converges
the box to exactly that. Roles map to groups (admin→rig-admin with full
NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no
sudo — box's setup-host owns Incus, rig only asserts the group). Every
password stays locked always; the SSH key at the door is the
authentication. A user dropped from the file is found via the /etc/rig/users
ledger and locked, never deleted — deleting frees the uid and rots
attribution. The sudoers drop-in lands only after visudo -c passes, because
a bad file under sudoers.d takes down all of sudo. Class never gates apply
(#26: a shared root login is unattributable, so operators belong on every
class); the marker only colors what root SSH does next. The whole file is
validated in one pass before the root check, every error named with its
line, so refusals are provable in the non-root harness through the sourced
parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
Claude
f2d343b0ef feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker
Roles become presets over three orthogonal traits declared in one map:
class (who lives here), host (runs VMs), join (authkey or interactive
login). Every per-role behavior now keys off the traits — the /dev/kvm
advisory rides host=yes, the next-steps log rides class and host — and
tag:server is derived policy, not a trait: only control-plane and
workload are shapes the control plane manages, so every other role
refuses the effective tag. join=login inverts the tag assertion (a
user-owned node must come up untagged; a tag is refused and backed out
on first join, refused without back-out on a box already joined) and
refuses a set TS_AUTHKEY before the root check. The verified shape is
recorded convergently in /etc/rig/role as ground truth for rig users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:15:06 +00:00
Claude
35ce40d05e docs: plan for machine traits + fleet users (#26 + #24, one release)
Traits (class/host/join) under the existing roles with dev/workstation/custom
presets and the /etc/rig/role marker; rig users apply/status/close-root under
the hybrid access model decided in #26's comments: operators on every class,
root SSH closed on class=human, kept as the control plane's automation
identity on class=server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:10:20 +00:00
Daniel Marin
249dbf3b79
Merge pull request #18 from claude-hdb/feat/rig-db
feat(db): bring ad-hoc dump/restore on-box as `rig db` (Closes #15)
2026-07-17 16:55:17 +01:00