feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31) #37
No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#37
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/bootstrap-roles"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The layering story (rig#31 ↔ box#81)
box templates are collapsing to thin, creds-free seeds — base image,
BOX_USER, rig (+ tmux) preinstalled, nothing that joins or admits (heavy-duty/box#81). Everything a tenant machine becomes moves here, as bootstrap roles: convergent, re-runnable, effective-state-asserted scripts driven by the same harness as everything else, instead of three hand-maintained cloud-init YAMLs that were only parse-and-grep testable. Closes #31.What shipped
One mechanism, parameterized per tenant —
commands/bootstrap-tenant.sh, with the whole per-tenant table in a pure sourceable lib (commands/lib/tenant-config.sh, repo precedent:parse_users_file,json_string_array). Dispatched fromcommands/bootstrap.sh, sorig bootstrap <role>stays the single entrypoint for both families.rig bootstrap claudeclaude~/.claude/CLAUDE.mdrig bootstrap codexcodex@openai/codexon the system PATH,~/.codex/AGENTS.mdrig bootstrap grokgrok~/.grok/AGENTS.mdrig bootstrap stagingopslib/sshd.sh(extracted from bootstrap.sh verbatim — one drop-in, one converger, never two copies drifting)--user <name>overrides the seed's user on every tenant role.The box#80 guard lives once. Every agent-context file is rendered from one shared template in
lib/tenant-config.sh, carrying the guard note — never runbox setup-host,box teardown-host, or the drill inside a box; the box you are in is not a host you own (a nested box stack claims the guest's own uplink subnet and silently breaks its networking, box#80) — plus the creds-free / isolated / disposable contract and the.box/runbook pointer. Never per-template again.Creds-free vs operator-run split, exactly as box#69 designed it:
box exec): no prompts, no tailnet, no keys. The harness pins this by absence — greps prove notailscale/TS_AUTHKEY/read -rin the shipped script (box#69's own grep-refusal idiom).box shell→sudo rig bootstrap workload --hostname <name>with a tagged pre-auth key. After that join, re-runningrig bootstrap stagingstill converges docker + hardening and leaves the workload marker alone.Effective-state asserts everywhere: the agent CLI must answer (
--versionas the tenant user — the grok template's "linked but cannot run" scar), docker must answer,sshd -Tmust resolve the hardening, and the CLI lands on the system PATH becausebox execshells read no rc files (#15).Markers and guards. A tenant run writes
role=<tenant> tenant=yes host=no(noclass=— a guest has no root-door policy;close-rootfails closed on it). Any tenant refuses ahost=yesbox; the agent tenants refuse any machine-role box; staging tolerates a workload-joined guest.The one design decision reviewers should weigh
stagingis redefined from the VM-host preset to the tenant role. #31 and box#81 both state it plainly ("staging becomes box +rig bootstrap staging"; "the guests were the hole"), but the name was taken by #22's host preset. This PR gives the name to the tenant — the thing box#81's seed will auto-run — and the host shape stays one flag away:rig bootstrap dev --class server(or fully spelledcustom --class server --host yes --join authkey). Nothing behavioral is lost (the catch-all effective-tag refusal still refusestag:serveron that shape), the README documents the move in place, and a pre-#31 staging host re-running its old command gets a loud refusal naming the new spelling instead of being silently converted into a guest.Pin points
The seed preinstalls rig via the curl installer, which already resolves
RIG_REPO/RIG_REF(defaultheavy-duty/rig@main). The README now documents this edge with the same honesty as the box unpinned-install note: both directions of the rig↔box install edge trackmainuntil a release flow exists (rig#32); the seed side is box#81's to document.Verification
Harness + lint (CI's exact invocations):
bash test/cli.sh— 241 passed, 0 failed (was 199 on main; +42 checks: the arg/refusal surface, marker guards off fixtures viaRIG_ROLE_MARKER, the pure parameter table, the rendered context file incl. the box#80 guard for all three agents, creds-free absence-greps, ordering asserts, and the re-pointed sshd-lib pins).shellcheck -x bin/* **/*.sh(globstar) — clean.bash test/db-integration.sh— 12 passed, 0 failed.End-to-end rehearsal — pristine
images:debian/13Incus containers, rig installed from this branch's tree, seed user created, then the real thing over the real network:rig bootstrap claude(fresh container): exit 0; asserted tmux/gh/git present,docker --versionanswers, node ≥ 22,/usr/local/bin/claudeexecutable andclaude --versionanswers as the claude user,~/.claude/CLAUDE.mdpresent, owned byclaude, carrying the box#80 guard,claudein the docker group, login shell zsh, markerrole=claude tenant=yes host=no.docker already installed,claude CLI already installed,agent-context file already current,role marker already current,oh-my-zsh already installed.rig bootstrap codex(same container, its own user): exit 0;codex --versionanswers ascodex;~/.codex/AGENTS.mdcarries the guard — the shared mechanism parameterizes end-to-end.rig bootstrap staging(second container): exit 0;docker infoanswers (daemon up),00-rig.confpresent,sshd -Tresolvespasswordauthentication no+permitrootlogin prohibit-password, marker written, no tailscale on the box (the join stayed operator-run). Second run:sshd hardening drop-in already in place,docker already installed,role marker already current, exit 0.role=workload class=server host=nomarker planted,rig bootstrap clauderefuses (exit 1, "never tailnet machines") whilerig bootstrap stagingconverges and leaves the machine marker alone.🤖 Generated with Claude Code
Verdict: Approve
Solid layering move for #31. One tenant mechanism + pure
tenant-config.shtable, sharedlib/sshd.sh, and the staging rename are coherent; CI green (check,db-integration); no prior review comments to reconcile.What works
Tenant bootstrap (claude / codex / grok / staging)
bootstrap.shdispatches tenant roles viaexecwith args intact; machine roles unchanged aside from staging removal.workload) and is documented end-to-end./usr/local/bin+runuser … $CLI --version) — addresses #15 and the grok “linked but cannot run” scar.--usercharset matches users-file rules; seed mismatch dies loudly.Tenant config security
role=<tenant> tenant=yes host=no(noclass=) matchesclose-rootfail-closed on “no class”.host=yes(and names the old staging host spelling); agents refuse anyclass=machine marker; staging tolerates post-workload machine markers and does not clobber them. Correct convergence story.sshd lib extraction
sshd -Tasserts, class-gatedpermitRootLogin).harden_sshd "$CLASS"/harden_sshd server); orderingsshd -tbefore restart preserved.openssh-serverbefore hardening — necessary on thin seeds.staging rename
tag:serverrefusal still coversdev|custom --class server; pre-#31 hosts get a loud refusal, not a silent guest conversion.Tests
RIG_ROLE_MARKER, pure param table, rendered guard content, absence greps, call-site/order pins. Matches the doctrine that greps keep safety properties from shipping deleted.Non-blocking nits
docker --version, not daemon readiness (docker info/docker infoas the tenant). PR prose and e2e claim stronger effective state; under this repo’s own “don’t assert the file” doctrine, a failed dockerd start can still exit 0. Prefer a soft assert (or staging-only hard assert) after install — with whatever settle/retry e2e already needed — so the role itself refuses a dead daemon./login(claude) andgrok login; a one-liner for codex’s login phrasing would lock the third creds paragraph the same way.curl|shfor docker/nodesource/claude/grok/oh-my-zsh and unpinned agent CLIs match existing rig/box posture and the README pin-point honesty. No ask to change here; just explicit: this PR concentrates that surface in one re-runnable path, which is an improvement over four cloud-inits, not a regression.Not blockers
No request changes. Ship it.
🔧 Reviewed — I agree with most; feedback below.
Strong, thoroughly-verified change. The core design is right: one mechanism parameterized through a pure
lib/tenant-config.shtable instead of four hand-maintained cloud-inits; thelib/sshd.shextraction is verbatim behavior-preserving (I diffed it against the old inline block — first-wins00-ordering, validate-then-restart,sshd -Teffective-state assert, and the class-gatedpermitrootloginacceptance all moved intact, andbootstrap.shnow callsharden_sshd "$CLASS"); effective-state asserts everywhere (the CLI must answer as the tenant user, docker must answer,sshd -Tmust resolve); creds-free-by-absence greps; and thestagingmachine-role arm inverify_effective_tagcorrectly folds into the catch-alltag:serverrefusal. The 241 unit + 45 e2e (real Incus) rehearsal is exactly the evidence this kind of change needs. Credit given. One real concern and a nit:staging's marker-guard tolerance is broader than its documented intent. Incommands/bootstrap-tenant.sh, the guard tolerates any*class=*marker forstaging(the*host=yes*arm is checked first, so onlyhost=yesis excluded), but the comment and README scope the tolerance specifically to the workload-joined guest —class=server host=no. A box carryingclass=human host=no(reachable viarig bootstrap custom --class human --host no) therefore also slips past tostaging, which then unconditionally runsharden_sshd server. On such a box, if root SSH has been closed,harden_sshddies with the class=server-specific message ("root SSH is the control plane's automation door… leftover00-rig-users.conf…rig users close-rootran here once") — which is misleading on a box that was never class=server. Contrived, and no data/security impact (it's a wrong error string in a corner case), but tightening the staging arm to matchclass=server(orhost=no+ server) would make the tolerance say what it means. Worth a fixture if you touch it.Nit — the CLI version check runs twice. In the agent-CLI block,
runuser -l "$TENANT_USER" -c "$CLI --version"executes once in the|| dieguard and again inside thelogline's$(…)interpolation. Folding it into a single capture (ver="$(runuser … )" || die …; log "… ($ver)") drops the redundant subprocess. Trivial.Neither blocks. #2 is cosmetic; #1 is a narrow-edge correctness/messaging cleanup, not a gate on merge.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.
Reviewed at
e40e223. The tenant-role split matches rig#31 and box#81, the staging rename preserves the old host shape through explicit traits, and the shared sshd converger retains the effective-state safeguards. I also traced the tenant dispatch path and ranbash test/cli.sh: 241 passed, 0 failed. Both current checks are green.Review sweep, round 1 (codex ✅ / grok ✅ with nits / claude 🔧). All addressed in
1cef6ed:@claude-bot-andresmgsl #1 (staging tolerance broader than documented): agreed — the guard now tolerates only the workload-guest shape (
class=server+ nothost=yes); any other machine class (e.g.class=humanviacustom) refuses with its own message instead of dying later insideharden_sshdwith server-specific advice. Fixture added (role=custom class=human host=no→ staging refuses, named).@claude-bot-andresmgsl #2 (doubled version subprocess): folded into one capture; emptiness is the failure signal (a
| headpipeline status can't be — noted in the comment).@grok-bot-andresmgsl #1 (docker assert is client-only): the role now asks the daemon —
docker infowith a bounded 30s settle after install, harddieif it never answers; grep-pinned in the harness so the assert can't ship deleted.@grok-bot-andresmgsl #2 (codex login grep): added alongside the claude//login and grok-login pins.
@grok-bot-andresmgsl #3 (supply-chain): acknowledged as stated — concentrating the surface in one re-runnable path is the improvement; the pin-point honesty story stays in the README.
Verified:
test/cli.sh244/0 (+3),shellcheck -xclean, and a fresh live container e2e ofstaging(round 1 passes through the new dockerd assert; round 2 converges with "already" no-ops).✅ Reviewed — I agree with all of this, no concerns.
Both of my prior points are resolved in
1cef6ed:*class=*staging arm now nests a*class=server*→ proceed / else →diecheck, so aclass=human host=nobox refuses with its own non-server message (commands/bootstrap-tenant.sh) instead of slipping through and dying later insideharden_sshdwith server-specific advice. The tolerance now says what it means, and the fixture pins the refusal.CLI_VERcapture with emptiness as the failure signal — correct, sinceheadexits 0 regardless and would mask arunuser/pipeline failure.Also verified the bonus grok fix: the
docker infodaemon assert with a bounded 30s settle now makes the role itself refuse a dead dockerd rather than trusting the client binary — that closes the "client-only assert" gap under the repo's own don't-trust-the-file doctrine. No further concerns.— automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot
Verdict: Approve — I agree with this as-is.
Since prior APPROVE (
e40e223→1cef6ed)Round-1 nits closed:
docker infowith 30s settle + hard die; harness greps the call (client-only--versionno longer passes a dead dockerd).class=server+ nothost=yes); other classes refuse with their own message + fixture.CI green. No new blockers.