Commit graph

82 commits

Author SHA1 Message Date
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
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
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
Claude
583ac25448 docs: README section for the staging bootstrap role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:51:58 +00:00
Claude
c4d64fb037 feat(bootstrap): staging role — the host archetype for box-minted staging VMs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:51:36 +00:00
Claude
1b68d3ca49 docs: plan for the staging bootstrap role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:23 +00:00
Claude
9551ad482f test(db): add real dump/restore round-trip probe + CI job + manual proof docs
The db PR only unit-tested arg parsing; this adds executable proof that
dump/restore actually works end to end.

- test/db-integration.sh: stands up two throwaway Postgres containers whose
  superusers DIFFER by construction (src_super vs dst_super), seeds a known
  checksummable fixture, runs the real `rig db dump`/`rig db restore`, and
  reads the rows back out — proving both invariants db.sh cares about: the
  code reads the container's OWN $POSTGRES_USER/$POSTGRES_DB (a hardcoded
  `postgres` would break on the non-default source superuser), and
  --no-owner --no-acl makes the dump portable across differing superusers (a
  plain dump would abort under ON_ERROR_STOP=1 on the missing role). Also
  asserts default-outfile naming, restore idempotency (--clean --if-exists),
  and the named-[db] scratch-database path. Skips cleanly (exit 0) when Docker
  is absent/unreachable or root is unobtainable; always cleans up via trap.

- ci.yml: separate `db-integration` job on ubuntu-latest (Docker preinstalled),
  kept apart from the fast shellcheck+cli.sh `check` job so an image pull can't
  slow lint feedback.

- README: "Verifying a dump/restore actually works" — the safe manual
  round-trip against a real Coolify container via a fresh scratch db, echoing
  "a backup you have never read back is not yet a backup."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:48:51 +00:00
Daniel Marin
0fae6d37c4
Merge pull request #20 from claude-hdb/feat/bootstrap-infer-tailnet-tag
bootstrap: infer the tailnet tag from the pre-auth key — verify the tag granted, not the one requested
2026-07-17 16:47:11 +01:00
Claude
92fa2a9860 bootstrap: infer the tailnet tag from the pre-auth key, verify the granted tag
rig used to pass --ts-tag to `tailscale up --advertise-tags`, stating the
tailnet tag a second time with no way to know whether its request and the
key's own tags agreed. It asserted the tag it REQUESTED, never the tag control
GRANTED — the sshd first-wins bug in a different hat, and the same scar (both
M900s joined tag:server, retagged by hand, unnoticed).

Collapse the two sources of truth onto one: the key.

- `tailscale up` drops --advertise-tags; the key's tags apply.
- After join, poll `tailscale status --json` for `.Self.Tags` (netmap ground
  truth, not `debug prefs`) until tags appear or BackendState=Running, on BOTH
  the fresh-join and already-joined paths.
- UNTAGGED -> hard refusal: `tailscale logout` to back the user-owned node out,
  then die naming the fix (mint a tagged key).
- Role policy moves onto the effective tag: a runner must not have tag:server
  among the tags the key actually granted. Strictly stronger than before.
- --ts-tag is removed, and dies exit 2 with a message pointing at the key
  (consuming its value), not an "unknown flag".
- New array-aware reader json_string_array in lib/runner-config.sh (jq-free,
  never fails under set -e), with its own unit tests; bootstrap sources the lib.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:27:09 +00:00
Claude
0bb6b638df feat(db): bring ad-hoc dump/restore on-box as rig db
Add `rig db dump <container> [outfile]` and
`rig db restore <artifact> <container> [db] [--yes]` — imperative on-box
PostgreSQL tooling, the interactive counterpart to the scheduled,
declarative `coolify backup install`.

Key decisions:
- Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner
  --no-acl` is mandatory for cross-instance restores: the target's
  superuser differs (Coolify randomizes it), so a plain dump aborts under
  ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role.
- $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted
  `sh -c`), never hardcoded to `postgres` on the host.
- restore connects as the container's own superuser and runs with
  ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a
  shared container, passed in via a container env var rather than string
  splicing.
- restore overwrites the target, so it prompts y/N; --yes/--force is the
  automation bypass. Artifact existence/non-emptiness is checked before
  the confirm gate and before anything touches the DB.
- dump uses pipefail + a sibling temp promoted only on success, and
  refuses to keep an empty artifact — a failed pg_dump must never leave a
  plausible-looking .gz behind.

Args are validated before the root check (testable without root); guards
are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and
a `### rig db` README section.

Closes #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
Daniel Marin
d8055e2525
Merge pull request #14 from claude-hdb/fix/runner-install-repo-guard
fix(runner): install refuses a box registered to another repo
2026-07-13 21:00:46 +01:00
e2a0951a91 ci: enumerate shellcheck inputs with globstar, not by hand
The hand-listed inputs under-covered silently: commands/*.sh does not glob
into lib/, and the previous fix patched that by adding one more path — which
leaves the same hole open for the next subdirectory. Verified: with a broken
script at commands/deep/nested/bad.sh, the hand-listed invocation still exits 0.

`shopt -s globstar` + bin/* **/*.sh covers every script in the repo, including
the extensionless bin/ entrypoints. The list is printed before it runs, so
coverage is visible in the CI log rather than assumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:59:28 +00:00
9c6c6c9477 fix(ci): shellcheck -x, and lint commands/lib/ at all
The runner-* commands source commands/lib/runner-config.sh. Without -x,
shellcheck will not follow that source even though the files carry a
`source=SCRIPTDIR/...` directive, so it reported SC1091 on all three —
and shellcheck exits non-zero on any finding, info-level included.

commands/*.sh does not glob into lib/, so runner-config.sh was being
linted by nothing at all. List it explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:56:33 +00:00
d4ab362964 fix(runner): install refuses a box registered to another repo
`rig runner install --repo <B>` on a box already registered to repo A
treated the mere existence of .runner as "already registered", skipped
configure, restarted the service still pointed at A, and reported success.
--repo was accepted, validated, and then ignored — leaving B with zero
runners and its `runs-on` jobs queued against one that will never come.

This is the natural next command after a partial `repoint`, and the failure
is worse than a no-op: moving a runner between repos is a trust-boundary
act, so quietly putting it back on the old one defeats the point of the move.

Gate install on the repo .runner actually names. Convergence — the property
worth keeping — is untouched: re-running against the repo the box is already
on still skips registration, never prompts for a token, and exits 0.
Skipping when the repo *differs* was never convergence, only a silently
ignored argument, so it now fails and names both repos, pointing at
`runner repoint` (move) or `runner remove` (start over). An unreadable
.runner is refused too — it is no licence to assume a match.

The .runner reader that `status` and `repoint` each carried is lifted into
commands/lib/runner-config.sh, which now also holds the guard. Its json_field
no longer dies bare under `set -o pipefail` when a key is missing, which is
what `status`'s own ${REPO_URL:-unknown} fallback always assumed.

Tests: the guard is exercised against a fixture .runner (refuses another repo
naming both, points at repoint, no-ops on the same repo, passes an
unregistered box, refuses an unreadable one) plus an ordering assertion that
it precedes svc.sh start — reaching it through the CLI would need root and a
really-registered runner, which the dependency-free harness cannot fabricate.
All three mutants (guard deleted, guard comparing nothing, guard moved below
the service start) go red.

Closes #13
2026-07-13 14:57:28 +00:00
Daniel Marin
af7e8b4071
Merge pull request #11 from claude-hdb/feat/runner-lifecycle
feat(runner): status, remove, and repoint — the runner lifecycle verbs
2026-07-13 14:33:10 +01:00
deb6f653e3 docs: README sections for runner status, remove, and repoint 2026-07-13 13:25:27 +00:00