2026-07-10 20:40:07 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Dependency-free CLI assertions. Run: bash test/cli.sh
|
|
|
|
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
|
|
|
|
set -u
|
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
|
PASS=0 FAIL=0
|
|
|
|
|
|
|
|
|
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
|
|
|
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
|
|
|
# contains want_substr.
|
|
|
|
|
check() {
|
|
|
|
|
local desc="$1" want="$2" substr="$3"; shift 3
|
|
|
|
|
local out rc
|
|
|
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
|
|
|
if [ "$rc" -ne "$want" ]; then
|
|
|
|
|
echo "FAIL: $desc — exit $rc, wanted $want"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
2026-07-10 20:53:32 +00:00
|
|
|
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
2026-07-10 20:40:07 +00:00
|
|
|
echo "FAIL: $desc — output missing '$substr'"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
|
|
|
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 08:25:48 +00:00
|
|
|
check "no args shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig"
|
|
|
|
|
check "--help exits 0" 0 "usage:" "$ROOT/bin/rig" --help
|
|
|
|
|
check "help exits 0" 0 "usage:" "$ROOT/bin/rig" help
|
|
|
|
|
check "unknown command exits 2" 2 "unknown command" "$ROOT/bin/rig" frobnicate
|
|
|
|
|
check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify
|
2026-07-10 20:40:07 +00:00
|
|
|
|
2026-07-10 20:43:24 +00:00
|
|
|
check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help
|
|
|
|
|
check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
|
|
|
|
check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope
|
|
|
|
|
check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname
|
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
|
|
|
# --ts-tag is REMOVED, not demoted: the tag now comes from the pre-auth key and
|
|
|
|
|
# rig verifies the GRANTED tag after join. The old runner-refuses-tag:server test
|
|
|
|
|
# asserted the request-time refusal THROUGH this flag; that policy now lives on
|
|
|
|
|
# the EFFECTIVE tag and needs a real tailnet, so it belongs to the rehearsal, not
|
|
|
|
|
# here. What this harness CAN prove is that the flag dies with a message pointing
|
|
|
|
|
# at the key (exit 2, a usage error), rather than an "unknown flag" that would
|
|
|
|
|
# leave an operator guessing where the tag went — value present or absent.
|
|
|
|
|
check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pre-auth key" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
|
|
|
|
|
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" runner --ts-tag
|
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
|
|
|
# staging is a box TENANT role since #31 (the guest, not the VM host), and it
|
|
|
|
|
# never joins the tailnet — but --ts-tag on it must still die with a story,
|
|
|
|
|
# not an "unknown flag": scripts from its trait-preset life may pass it, and
|
|
|
|
|
# the message must say where both the tag AND the join went.
|
|
|
|
|
check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \
|
2026-07-17 15:51:36 +00:00
|
|
|
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
|
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
|
|
|
# The old staging effective-tag refusal guarded the VM-HOST shape, which now
|
|
|
|
|
# rides the traits (custom/dev --class server) — the catch-all tag:server
|
|
|
|
|
# refusal must still own that shape, so grep the general die instead.
|
|
|
|
|
check "bootstrap: the catch-all tag:server refusal is present" 0 "" \
|
|
|
|
|
grep -q "Only control-plane and workload are managed by the control plane" "$ROOT/commands/bootstrap.sh"
|
2026-07-17 19:15:06 +00:00
|
|
|
# --- traits: roles are presets, every trait individually settable (#26) -----
|
|
|
|
|
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
|
|
|
|
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
|
|
|
|
|
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
|
|
|
|
|
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
|
|
|
|
|
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
|
|
|
|
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
|
|
|
|
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
|
|
|
|
|
# must die BEFORE the root check — provable non-root, which also proves the
|
|
|
|
|
# preset actually landed.
|
|
|
|
|
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
|
|
|
|
|
# A trait override changes derived behavior, provable non-root: dev is
|
|
|
|
|
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
|
|
|
|
|
# --join login flips it into the TS_AUTHKEY refusal.
|
|
|
|
|
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
|
|
|
|
|
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
|
|
|
|
|
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
|
|
|
|
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
|
|
|
|
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
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
|
|
|
# Re-running with join=authkey on a box that was legitimately login-joined
|
|
|
|
|
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
|
|
|
|
|
# out a join this run did not perform would tear down a user-owned workstation;
|
|
|
|
|
# the already-joined path must refuse WITHOUT logout and name both repairs.
|
|
|
|
|
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
|
|
|
|
|
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
|
|
|
|
|
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
|
|
|
|
|
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
|
|
|
|
|
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
|
|
|
|
|
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
|
|
|
|
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
|
2026-07-17 19:15:06 +00:00
|
|
|
# The marker is the traits' ground truth for rig users; assert the write exists.
|
|
|
|
|
check "bootstrap: role marker write is present" 0 "" \
|
|
|
|
|
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
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
|
|
|
# --- host-class box install (issues #12, #25) --------------------------------
|
|
|
|
|
# A host=yes box finishes the job: bootstrap installs the box CLI globally and
|
|
|
|
|
# lets box's own setup-host build the Incus stack. The install itself runs as
|
|
|
|
|
# root, over the network, against a real host — none of which this harness can
|
|
|
|
|
# fabricate — so, exactly like the tag refusals and the runner repo guard, prove
|
|
|
|
|
# the shipped script by grepping its load-bearing pieces.
|
|
|
|
|
# The step is guarded on host=yes: the exact guard line (no `&&`, unlike the
|
|
|
|
|
# /dev/kvm advisory) belongs to the box block alone. The `\$HOST` is a literal
|
|
|
|
|
# we grep for in the script — single quotes are the point, as in the db checks.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "bootstrap: box install is guarded on host=yes" 0 "" \
|
|
|
|
|
grep -qxE 'if \[ "\$HOST" = "yes" \]; then' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# It runs box's OWN global installer with BOX_YES=1 (non-interactive AND keeps
|
|
|
|
|
# setup-host, so box builds Incus rather than only dropping the CLI on PATH).
|
|
|
|
|
check "bootstrap: box install runs box's installer non-interactively" 0 "" \
|
|
|
|
|
grep -q "BOX_YES=1 bash" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# Pin points: BOX_REPO / BOX_REF override the source, default heavy-duty/box@main.
|
|
|
|
|
check "bootstrap: box source is pinnable, defaults to heavy-duty/box@main" 0 "" \
|
|
|
|
|
grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# Opt-out for rehearsals / offline / hand-managed hosts.
|
|
|
|
|
check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \
|
|
|
|
|
grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# The DESIGN LAW rig users apply also enforces: rig NEVER apt-installs Incus —
|
|
|
|
|
# box's setup-host is the single owner of the daemon and its group. A grep that
|
|
|
|
|
# finds nothing (exit 1) is the pass; a stray `apt-get install ... incus` would
|
|
|
|
|
# make it exit 0 and fail the check, so the law cannot silently erode.
|
|
|
|
|
check "bootstrap: rig never apt-installs incus (box owns the daemon)" 1 "" \
|
|
|
|
|
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# Ordering is the safety property: box must be installed only AFTER the role
|
|
|
|
|
# marker is written, so a box that failed to become what it claims (tag refused,
|
|
|
|
|
# join backed out — all of which die above) never installs box on a half-built
|
|
|
|
|
# host. Compare line numbers, same idiom as the visudo/sshd -t ordering asserts.
|
|
|
|
|
# Defaults fail closed (marker missing -> huge, box missing -> 0 -> fails).
|
|
|
|
|
# $MARKER_TMP is a literal we grep for in the script — single quotes intended.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
box_marker_at="$(grep -n 'install -m 0644 "$MARKER_TMP"' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
box_install_at="$(grep -n 'BOX_YES=1 bash' "$ROOT/commands/bootstrap.sh" | grep -v 'BOX_MANUAL=' | tail -n1 | cut -d: -f1)"
|
|
|
|
|
check "bootstrap: box install runs after the role marker write" \
|
|
|
|
|
0 "" test "${box_marker_at:-999999}" -lt "${box_install_at:-0}"
|
|
|
|
|
# On the skip/failure paths, keep pointing operators at the manual command so a
|
|
|
|
|
# host whose box did not install is never left without the next move.
|
|
|
|
|
check "bootstrap: box skip/failure keeps a pointer to the manual install" 0 "" \
|
|
|
|
|
grep -q "prepare Incus" "$ROOT/commands/bootstrap.sh"
|
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
|
|
|
# "Don't trust exit codes" (#12): box's installer can exit 0 having done less
|
|
|
|
|
# than it claims (its setup-host has a path that exits 0 after only adding a
|
|
|
|
|
# group, asking for a re-login). After a claimed success bootstrap must prove
|
|
|
|
|
# the one artifact it asked for — box on PATH — and a hollow success WARNS,
|
|
|
|
|
# never dies: box is the host extra. Exercising it needs root + the network,
|
|
|
|
|
# so grep the shipped script (repo precedent: the tag-refusal greps). Match
|
|
|
|
|
# the CALL, not the word — the rationale comment says `command -v box` too.
|
|
|
|
|
check "bootstrap: a box-install success is verified, not trusted" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*if command -v box' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
check "bootstrap: a hollow box-install success warns, never dies" 0 "" \
|
|
|
|
|
grep -q "reported success but no 'box' is on PATH" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# Ordering: the effective check must sit AFTER the installer run it verifies.
|
|
|
|
|
# Line-number compare, defaults fail closed (same idiom as the marker/install
|
|
|
|
|
# ordering assert above; box_install_at is computed there).
|
|
|
|
|
box_check_at="$(grep -nE '^[[:space:]]*if command -v box' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "bootstrap: the effective check follows the installer run" \
|
|
|
|
|
0 "" test "${box_install_at:-999999}" -lt "${box_check_at:-0}"
|
2026-07-18 16:59:43 +00:00
|
|
|
# rig's delegation law caps the check's depth: rig never interrogates Incus —
|
|
|
|
|
# the host verdict is box's own verb, and the "host set up" CLAIM is gated on
|
|
|
|
|
# it. Two asserts: the gate exists as a call (not just prose naming the verb),
|
|
|
|
|
# and the claim line sits inside/after it (line order, fail-closed defaults —
|
|
|
|
|
# a claim that outruns its proof is exactly the overclaim this closes).
|
|
|
|
|
check "bootstrap: the host-set-up claim is gated on box doctor" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*if box doctor' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
doctor_at="$(grep -nE '^[[:space:]]*if box doctor' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
claim_at="$(grep -n 'box installed and host set up' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "bootstrap: the claim follows the doctor gate" \
|
|
|
|
|
0 "" test "${doctor_at:-999999}" -lt "${claim_at:-0}"
|
|
|
|
|
check "bootstrap: a failed doctor warns without claiming the host" 0 "" \
|
|
|
|
|
grep -q "the CLI landed, the host stack is unproven" "$ROOT/commands/bootstrap.sh"
|
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
|
|
|
# --- README: the box rename (#12) --------------------------------------------
|
|
|
|
|
# The philosophy line must point at heavy-duty/box — the old claudebox slug
|
|
|
|
|
# only works through a GitHub redirect that one squatted rename away from
|
|
|
|
|
# breaking (box's own installer was already bitten by the rename once). A
|
|
|
|
|
# negative grep (exit 1 = pass) keeps the stale slug from creeping back.
|
|
|
|
|
check "README: no stale heavy-duty/claudebox links" 1 "" \
|
|
|
|
|
grep -n "heavy-duty/claudebox" "$ROOT/README.md"
|
|
|
|
|
check "README: points at heavy-duty/box" 0 "" \
|
|
|
|
|
grep -q "github.com/heavy-duty/box" "$ROOT/README.md"
|
2026-07-10 20:43:24 +00:00
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
|
2026-07-11 18:25:47 +00:00
|
|
|
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
|
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
|
|
|
# staging dispatches to the tenant mechanism now; reaching ITS root check
|
|
|
|
|
# through bootstrap.sh proves the dispatch and the tenant arg pass in one go.
|
|
|
|
|
# RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs
|
|
|
|
|
# before the root check, and the machine running this harness may well have
|
|
|
|
|
# a real /etc/rig/role of its own.
|
|
|
|
|
check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER=/nonexistent/rig-role "$ROOT/commands/bootstrap.sh" staging
|
2026-07-17 19:15:06 +00:00
|
|
|
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
|
|
|
|
|
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
|
|
|
|
|
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
|
2026-07-10 20:43:24 +00:00
|
|
|
else
|
2026-07-11 18:25:47 +00:00
|
|
|
echo "skip: bootstrap non-root refusals (running as root)"
|
2026-07-10 20:43:24 +00:00
|
|
|
fi
|
|
|
|
|
|
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
|
|
|
# --- box tenant roles (#31): claude|codex|grok|staging ------------------------
|
|
|
|
|
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
|
|
|
|
|
# parameterized per tenant through lib/tenant-config.sh, dispatched from
|
|
|
|
|
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real
|
|
|
|
|
# converge needs root, a tenant user, and the network — the container
|
|
|
|
|
# rehearsal's job — so the harness proves what it can non-root: the whole
|
|
|
|
|
# arg/refusal surface, the pure parameter table, the rendered agent-context
|
|
|
|
|
# file (guard note included), and grep-pins on the shipped script.
|
|
|
|
|
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
|
|
|
|
|
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
|
|
|
|
|
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude --nope
|
|
|
|
|
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude --user
|
|
|
|
|
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude --user 'fo|o'
|
2026-07-18 20:18:15 +00:00
|
|
|
# The docker converge asserts the DAEMON answers, not just the client binary —
|
|
|
|
|
# a dead dockerd passing `docker --version` is the "linked but cannot run"
|
|
|
|
|
# scar in daemon form. Grep-pinned so the assert cannot ship deleted.
|
|
|
|
|
check "tenant: dockerd effective-state assert is present" 0 "" \
|
|
|
|
|
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
|
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
|
|
|
# The machine-role traits die with the tenant story, never "unknown flag" — an
|
|
|
|
|
# operator reaching for --hostname must learn where the trait family went.
|
|
|
|
|
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
|
|
|
|
"$ROOT/commands/bootstrap-tenant.sh" claude --class human
|
|
|
|
|
check "tenant: --hostname dies the same way" 2 "have no traits" \
|
|
|
|
|
"$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest
|
|
|
|
|
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant
|
|
|
|
|
# mechanism with args intact (--help reaching the TENANT usage proves both).
|
|
|
|
|
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|grok|staging" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" claude --help
|
|
|
|
|
# The marker guard fires BEFORE the root check (repo precedent: the coolify
|
|
|
|
|
# marker warning), so the refusals are provable here off fixture markers. A
|
|
|
|
|
# VM host (host=yes) refuses for every tenant — and names the staging rename,
|
|
|
|
|
# because a pre-#31 staging HOST re-running its old command is exactly who
|
|
|
|
|
# lands here. An agent tenant refuses ANY machine-role box; staging tolerates
|
2026-07-18 20:18:15 +00:00
|
|
|
# ONLY class=server with host=no — that is the staging guest after its
|
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
|
|
|
# operator-run workload join, and re-converging it is what convergence is for.
|
2026-07-18 20:18:15 +00:00
|
|
|
# A non-server machine (class=human via custom) is NOT that guest, and server
|
|
|
|
|
# hardening would die at it with server-specific messaging — refuse instead.
|
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
|
|
|
TEN_FIX="$(mktemp -d)"
|
|
|
|
|
printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host"
|
|
|
|
|
printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine"
|
2026-07-18 20:18:15 +00:00
|
|
|
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human"
|
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
|
|
|
printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
2026-07-18 20:18:15 +00:00
|
|
|
check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging
|
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
|
|
|
check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude
|
|
|
|
|
check "tenant: the host refusal names the old staging preset's new spelling" 1 "custom --class server --host yes" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging
|
|
|
|
|
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
|
|
|
|
|
# the root check, and the harness machine may carry a real /etc/rig/role.
|
|
|
|
|
check "tenant: claude parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude
|
|
|
|
|
check "tenant: codex parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex
|
|
|
|
|
check "tenant: grok parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok
|
|
|
|
|
check "tenant: staging tolerates a workload-joined guest's marker" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging
|
|
|
|
|
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude
|
|
|
|
|
else
|
|
|
|
|
echo "skip: tenant non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
rm -rf "$TEN_FIX"
|
|
|
|
|
|
|
|
|
|
# The per-tenant parameter table and the agent-context renderer are pure lib
|
|
|
|
|
# functions on purpose (repo precedent: parse_users_file, json_string_array):
|
|
|
|
|
# the CLI path to them sits behind root + a real tenant user, so the harness
|
|
|
|
|
# proves them here, sourced, non-root and network-free.
|
|
|
|
|
tuser() { bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; }
|
|
|
|
|
tpath() { bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; }
|
|
|
|
|
tctx() { bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; }
|
|
|
|
|
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude
|
|
|
|
|
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging
|
|
|
|
|
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude /home/claude
|
|
|
|
|
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex /home/codex
|
|
|
|
|
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok /home/grok
|
|
|
|
|
check "tenant params: staging has no context file" 1 "" tpath staging /home/ops
|
|
|
|
|
# The box#80 guard note lives ONCE, in the renderer, and every agent's file
|
|
|
|
|
# carries it — the layering decision's whole point: never per-template again.
|
|
|
|
|
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude
|
|
|
|
|
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex
|
|
|
|
|
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok
|
|
|
|
|
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude
|
|
|
|
|
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude
|
|
|
|
|
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude
|
|
|
|
|
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude
|
2026-07-18 20:18:15 +00:00
|
|
|
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex
|
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
|
|
|
check "tenant context: grok names its login flow" 0 "grok login" tctx grok
|
|
|
|
|
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging
|
|
|
|
|
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
|
|
|
|
|
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
|
|
|
|
|
# apt-installs incus. A grep that finds nothing (exit 1) is the pass.
|
|
|
|
|
check "tenant: never touches the tailnet" 1 "" \
|
|
|
|
|
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
check "tenant: non-interactive — nothing prompts" 1 "" \
|
|
|
|
|
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
|
|
|
|
|
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
# staging's posture rides the SAME hardening code as the machine roles — the
|
|
|
|
|
# shared lib call is the anti-drift property, so pin the call, not the words.
|
|
|
|
|
check "tenant: staging hardens through the shared sshd lib" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
check "tenant: docker lands via docker's own installer" 0 "" \
|
|
|
|
|
grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
# The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must
|
|
|
|
|
# land on the SYSTEM path — and a claimed install is verified, not trusted:
|
|
|
|
|
# it must ANSWER as the tenant user (the grok template's scar: linked but
|
|
|
|
|
# cannot run). The $CLI/$TENANT_USER are literals we grep for in the script.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "tenant: the agent CLI lands on the system PATH" 0 "" \
|
|
|
|
|
grep -qF '/usr/local/bin/$CLI' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "tenant: the CLI install is verified as the tenant user" 0 "" \
|
|
|
|
|
grep -qF 'runuser -l "$TENANT_USER" -c "$CLI --version"' "$ROOT/commands/bootstrap-tenant.sh"
|
|
|
|
|
# Ordering is the safety property, as with bootstrap's marker-then-box assert:
|
|
|
|
|
# the tenant marker may only describe converges that already happened, so the
|
|
|
|
|
# write sits after the context-file converge. Defaults fail closed.
|
|
|
|
|
ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "tenant: the marker write follows the context-file converge" \
|
|
|
|
|
0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}"
|
|
|
|
|
|
2026-07-10 20:50:41 +00:00
|
|
|
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
|
|
|
|
|
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
|
|
|
|
|
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
|
|
|
|
|
check "coolify: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "coolify: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
|
|
|
|
else
|
|
|
|
|
echo "skip: coolify non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
feat(coolify): install the control-plane dump as a systemd timer
The Coolify control-plane database holds the GitHub App private key, every
registered server's SSH key, and every environment value for every environment
it manages. Backing it up was a manual runbook step, and the dump script lived
in cast — the off-box tool, whose src never references it. It runs on the box,
as root, under a scheduler: that is rig's job description.
It matters beyond tidiness. The dump is forensics, not a restore path — a lost
control plane is rebuilt fresh and reconciled from the manifest. So there will
be a next control-plane box, and as a runbook step it was born un-backed-up,
depending on someone remembering mid-incident. Now it is backed up from birth.
rig installs the machinery and templates /etc/coolify-dump.env empty at 0600,
never reading it back — no credential passes through rig. The script's own
guards make an unfilled file fail the unit loudly rather than ship plaintext.
systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets,
failures surface in systemctl status instead of being mailed into the void, and
Persistent=true catches a run missed while the box was down.
Two hazards the cast script missed, carried into the unit:
- aws-cli >= 2.23 enables default upload checksums that S3-compatible backends
reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to
when_required.
- A failed pg_dump piped into age still yields a valid, tiny, encrypted file
that uploads cleanly every night and looks exactly like a working backup. The
script now refuses to upload an empty artifact.
Closes #8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
|
|
|
check "bare coolify backup shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify backup
|
|
|
|
|
check "coolify backup: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" coolify backup frobnicate
|
|
|
|
|
check "coolify backup: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-backup-install.sh" --help
|
|
|
|
|
check "coolify backup: schedule needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --schedule
|
|
|
|
|
check "coolify backup: pg-container needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --pg-container
|
|
|
|
|
check "coolify backup: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-backup-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "coolify backup: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: coolify backup non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-18 14:50:46 +00:00
|
|
|
# --- role-marker sanity: coolify verbs off the control plane (#25) -----------
|
|
|
|
|
# Both coolify commands read /etc/rig/role and WARN — never die — when the
|
|
|
|
|
# marker names a non-control-plane role: the likeliest story is the wrong SSH
|
|
|
|
|
# session, but the marker is advisory and must not outrank the operator. The
|
|
|
|
|
# warning fires BEFORE the root check (same testability rule as arg errors),
|
|
|
|
|
# so a non-root run prints it and then hits the root refusal — provable here
|
|
|
|
|
# with RIG_ROLE_MARKER pointed at fixtures (repo precedent: the close-root
|
|
|
|
|
# marker gate). Counting fires proves silence too: a control-plane marker, an
|
|
|
|
|
# absent marker, and a marker-less box must all stay quiet, because warning on
|
|
|
|
|
# absence would nag every pre-marker box on every legitimate run.
|
|
|
|
|
marker_warns() { # marker_warns <marker_path> <cmd...> — how many warnings fired
|
|
|
|
|
local marker="$1"; shift
|
|
|
|
|
env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true
|
|
|
|
|
}
|
|
|
|
|
MARKER_FIX="$(mktemp -d)"
|
|
|
|
|
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_FIX/workload"
|
|
|
|
|
printf 'role=control-plane class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane"
|
2026-07-18 16:59:43 +00:00
|
|
|
printf 'role=control-plane\n' > "$MARKER_FIX/bare-control-plane"
|
2026-07-18 14:50:46 +00:00
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "coolify: warns on a non-control-plane marker" 0 "1" \
|
|
|
|
|
marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
|
|
|
|
check "coolify: control-plane marker stays silent" 0 "0" \
|
|
|
|
|
marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
2026-07-18 16:59:43 +00:00
|
|
|
# A bare marker line with no trailing traits must read the same as the full
|
|
|
|
|
# one — the guard must not couple to the marker's field formatting.
|
|
|
|
|
check "coolify: a bare 'role=control-plane' line (no traits) stays silent" 0 "0" \
|
|
|
|
|
marker_warns "$MARKER_FIX/bare-control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
2026-07-18 14:50:46 +00:00
|
|
|
check "coolify: absent marker stays silent (advisory, not a gate)" 0 "0" \
|
|
|
|
|
marker_warns "$MARKER_FIX/absent" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
|
|
|
|
# The warning must stay a warning: the run proceeds past it and stops at the
|
|
|
|
|
# root check (exit 1), never turned into a marker refusal.
|
|
|
|
|
check "coolify: the marker warns but never refuses" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
|
|
|
|
check "coolify backup: warns on a non-control-plane marker" 0 "1" \
|
|
|
|
|
marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
check "coolify backup: control-plane marker stays silent" 0 "0" \
|
|
|
|
|
marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
check "coolify backup: the marker warns but never refuses" 1 "must run as root" \
|
|
|
|
|
env RIG_ROLE_MARKER="$MARKER_FIX/workload" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: coolify role-marker warning checks (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
rm -rf "$MARKER_FIX"
|
|
|
|
|
# Root runs skip the live checks above, so also pin the warning's presence in
|
|
|
|
|
# both shipped scripts — a deleted advisory cannot ship green (repo precedent:
|
|
|
|
|
# the staging/runner tag greps).
|
|
|
|
|
check "coolify: marker warning present in the shipped script" 0 "" \
|
|
|
|
|
grep -q "not a control-plane box" "$ROOT/commands/coolify-install.sh"
|
|
|
|
|
check "coolify backup: marker warning present in the shipped script" 0 "" \
|
|
|
|
|
grep -q "not a control-plane box" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
|
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
|
|
|
# --- rig db (ad-hoc dump/restore) -------------------------------------------
|
|
|
|
|
check "bare db shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" db
|
|
|
|
|
check "db --help exits 0" 0 "usage:" "$ROOT/bin/rig" db --help
|
|
|
|
|
check "db bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" db frobnicate
|
|
|
|
|
check "db dump: --help exits 0" 0 "usage:" "$ROOT/commands/db.sh" dump --help
|
|
|
|
|
check "db dump: container required, exit 2" 2 "needs a container" "$ROOT/commands/db.sh" dump
|
|
|
|
|
check "db dump: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/db.sh" dump --nope
|
|
|
|
|
check "db restore: artifact required, exit 2" 2 "needs an artifact" "$ROOT/commands/db.sh" restore
|
|
|
|
|
check "db restore: container required, exit 2" 2 "needs a target container" \
|
|
|
|
|
"$ROOT/commands/db.sh" restore /tmp/whatever.sql.gz
|
|
|
|
|
check "db restore: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/db.sh" restore --nope
|
|
|
|
|
# Artifact existence is checked BEFORE docker/root, so a fat-fingered path fails
|
|
|
|
|
# clearly and cheaply — and is testable here without root or a live container.
|
|
|
|
|
check "db restore: missing artifact fails before the docker/root path" \
|
|
|
|
|
1 "artifact not found" "$ROOT/commands/db.sh" restore /no/such/artifact.sql.gz somecontainer --yes
|
|
|
|
|
|
|
|
|
|
# The two DB invariants live as embedded command strings (single-quoted sh -c),
|
|
|
|
|
# not an extractable heredoc, so guard them directly: dropping --no-owner/--no-acl
|
|
|
|
|
# breaks every cross-instance restore, and hardcoding a role instead of the
|
|
|
|
|
# container's own $POSTGRES_USER/$POSTGRES_DB is wrong on Coolify's randomized
|
|
|
|
|
# superuser. ON_ERROR_STOP=1 is what makes a bad restore fail instead of limp.
|
|
|
|
|
check "db dump embeds --no-owner --no-acl" 0 "" \
|
|
|
|
|
grep -qF -- "--no-owner --no-acl" "$ROOT/commands/db.sh"
|
|
|
|
|
# The $POSTGRES_USER below is a LITERAL we grep for in db.sh (it must read the
|
|
|
|
|
# container's env, not the host's) — single quotes are the point here.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "db dump reads the container's own \$POSTGRES_USER/\$POSTGRES_DB" 0 "" \
|
|
|
|
|
grep -qF 'pg_dump -U "$POSTGRES_USER"' "$ROOT/commands/db.sh"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "db restore connects as the container's own \$POSTGRES_USER" 0 "" \
|
|
|
|
|
grep -qF 'psql -U "$POSTGRES_USER"' "$ROOT/commands/db.sh"
|
|
|
|
|
check "db restore uses ON_ERROR_STOP=1" 0 "" \
|
|
|
|
|
grep -qF "ON_ERROR_STOP=1" "$ROOT/commands/db.sh"
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
# Valid args, so validation passes and we reach the root guard.
|
|
|
|
|
check "db dump: refuses non-root" 1 "must run as root" "$ROOT/commands/db.sh" dump somecontainer
|
|
|
|
|
# Restore needs a real, non-empty artifact to get PAST the artifact check and
|
|
|
|
|
# reach the root guard; --yes skips the confirm prompt so the check is exit-clean.
|
|
|
|
|
DB_ART="$(mktemp)"; printf 'SELECT 1;\n' > "$DB_ART"
|
|
|
|
|
check "db restore: refuses non-root" 1 "must run as root" \
|
|
|
|
|
"$ROOT/commands/db.sh" restore "$DB_ART" somecontainer --yes
|
|
|
|
|
rm -f "$DB_ART"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: db non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-11 17:41:09 +00:00
|
|
|
check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner
|
|
|
|
|
check "runner: --help exits 0" 0 "usage:" "$ROOT/commands/runner-install.sh" --help
|
|
|
|
|
check "runner: repo required, exit 2" 2 "--repo" "$ROOT/commands/runner-install.sh" --version 2.335.1
|
2026-07-11 18:44:43 +00:00
|
|
|
check "runner: version needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version
|
2026-07-11 17:41:09 +00:00
|
|
|
check "runner: repo needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo
|
|
|
|
|
check "runner: rejects bad repo slug" 2 "owner/repo" "$ROOT/commands/runner-install.sh" --repo not-a-slug --version 2.335.1
|
|
|
|
|
check "runner: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 --user root
|
|
|
|
|
check "runner: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "runner: refuses non-root" 1 "must run as root" env RUNNER_TOKEN=x "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1
|
|
|
|
|
else
|
|
|
|
|
echo "skip: runner non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate
|
|
|
|
|
|
2026-07-19 12:15:08 +00:00
|
|
|
# --- headless prompts refuse loudly (issue #42) ------------------------------
|
|
|
|
|
# The three credential prompts (TS_AUTHKEY, RUNNER_TOKEN, RUNNER_REMOVE_TOKEN)
|
|
|
|
|
# used to be bare `read -rsp`: with no tty, read exits non-zero and `set -e`
|
|
|
|
|
# ends the script with NO output at all — the drill watched a bootstrap die
|
|
|
|
|
# mid-converge with exit 1 and nothing to grep. Each prompt now refuses first,
|
|
|
|
|
# naming its variable. The prompts live behind the root check (and, for the
|
|
|
|
|
# runner pair, behind a real registration), so the harness cannot reach them
|
|
|
|
|
# non-root; grep the guards so a deleted one cannot ship green (repo
|
|
|
|
|
# precedent: the login-path tag refusals above).
|
|
|
|
|
check "bootstrap: headless TS_AUTHKEY prompt refuses loudly" 0 "" \
|
|
|
|
|
grep -q 'TS_AUTHKEY is unset and stdin is not a tty' "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
check "runner install: headless token prompt refuses loudly" 0 "" \
|
|
|
|
|
grep -q 'RUNNER_TOKEN is unset and stdin is not a tty' "$ROOT/commands/runner-install.sh"
|
|
|
|
|
check "runner remove: headless token prompt refuses loudly" 0 "" \
|
|
|
|
|
grep -q 'RUNNER_REMOVE_TOKEN is unset and stdin is not a tty' "$ROOT/commands/runner-remove.sh"
|
|
|
|
|
# The EOF-at-the-prompt path (Ctrl-D on a real tty) must also die with a last
|
|
|
|
|
# word rather than ride set -e into silence: every read is `|| die`-guarded,
|
|
|
|
|
# so a bare `read -rsp` (no `||` on its line) must not exist anywhere.
|
|
|
|
|
check "prompts: no bare read -rsp remains" 1 "" \
|
|
|
|
|
grep -RE 'read -rsp[^|]*$' "$ROOT/commands/"
|
|
|
|
|
|
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
|
|
|
# --- runner install: --repo must agree with what the box is already on -------
|
|
|
|
|
# The bug: `install --repo B` on a box registered to repo A skipped configure,
|
|
|
|
|
# restarted the service on A, and reported success — --repo accepted, validated,
|
|
|
|
|
# then ignored. The guard is exercised here through the shared lib, against a
|
|
|
|
|
# fixture .runner: reaching it via the CLI needs root AND a really-registered
|
|
|
|
|
# runner, neither of which this harness can fabricate.
|
|
|
|
|
guard() { # guard <runner_dir> <owner/repo>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
assert_runner_repo "$2" "$3"' _ "$ROOT" "$1" "$2"
|
|
|
|
|
}
|
|
|
|
|
REG_DIR="$(mktemp -d)" # a box registered to acme/alpha
|
|
|
|
|
EMPTY_DIR="$(mktemp -d)" # a box with no runner at all
|
|
|
|
|
printf '%s\n' '{"agentId":7,"agentName":"ci-box","gitHubUrl":"https://github.com/acme/alpha","workFolder":"_work"}' \
|
|
|
|
|
> "$REG_DIR/.runner"
|
|
|
|
|
|
|
|
|
|
check "runner install: refuses a repo the box is not registered to" \
|
|
|
|
|
1 "already registered to https://github.com/acme/alpha" guard "$REG_DIR" acme/beta
|
|
|
|
|
check "runner install: the refusal names the repo that was asked for" \
|
|
|
|
|
1 "not https://github.com/acme/beta" guard "$REG_DIR" acme/beta
|
|
|
|
|
check "runner install: the refusal points at repoint" \
|
|
|
|
|
1 "rig runner repoint --repo acme/beta" guard "$REG_DIR" acme/beta
|
|
|
|
|
# Convergence is the property worth keeping: same repo stays a clean no-op.
|
|
|
|
|
check "runner install: the repo it is already on is a no-op" \
|
|
|
|
|
0 "" guard "$REG_DIR" acme/alpha
|
|
|
|
|
check "runner install: an unregistered box passes the guard" \
|
|
|
|
|
0 "" guard "$EMPTY_DIR" acme/beta
|
|
|
|
|
# A .runner rig cannot read is not a licence to assume it matches.
|
|
|
|
|
printf '%s\n' '{"agentName":"ci-box"}' > "$REG_DIR/.runner"
|
|
|
|
|
check "runner install: refuses an unreadable registration" \
|
|
|
|
|
1 "names no repository" guard "$REG_DIR" acme/alpha
|
|
|
|
|
rm -rf "$REG_DIR" "$EMPTY_DIR"
|
|
|
|
|
|
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
|
|
|
# --- json_string_array: json_field's array-aware sibling ---------------------
|
|
|
|
|
# bootstrap reads `.Self.Tags` (a JSON array) out of `tailscale status --json` to
|
|
|
|
|
# assert the tag control GRANTED the node — and a rig box has no jq. Exercise the
|
|
|
|
|
# reader against fixture netmaps here, the same shared-lib way the guard above is:
|
|
|
|
|
# the bootstrap path that calls it needs a real tailnet this harness cannot fake.
|
|
|
|
|
tags() { # tags <file> — prints one tag per line, exactly like the reader
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
json_string_array "$2" Tags' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
tags_count() { # tags_count <file> — prints how many tags were read (0 if none)
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
json_string_array "$2" Tags | grep -c . || true' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
tags_empty() { # tags_empty <file> — exit 0 iff the reader prints NOTHING
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
[ -z "$(json_string_array "$2" Tags)" ]' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third
|
|
|
|
|
FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard
|
|
|
|
|
cat > "$FIX_TAGGED" <<'JSON'
|
|
|
|
|
{
|
|
|
|
|
"BackendState": "Running",
|
|
|
|
|
"Self": {
|
|
|
|
|
"HostName": "ci-box",
|
|
|
|
|
"Tags": [
|
|
|
|
|
"tag:ci",
|
|
|
|
|
"tag:build"
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"Peer": {
|
|
|
|
|
"nodekey:abc": {
|
|
|
|
|
"HostName": "coolify-box",
|
|
|
|
|
"Tags": [
|
|
|
|
|
"tag:server"
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
JSON
|
|
|
|
|
cat > "$FIX_UNTAGGED" <<'JSON'
|
|
|
|
|
{
|
|
|
|
|
"BackendState": "Running",
|
|
|
|
|
"Self": {
|
|
|
|
|
"HostName": "user-owned-box"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
JSON
|
|
|
|
|
check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED"
|
|
|
|
|
check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED"
|
|
|
|
|
# Self precedes Peer in the netmap, so the FIRST "Tags" is the node's own: exactly
|
|
|
|
|
# two elements read proves the peer's tag:server did not leak into Self's tags.
|
|
|
|
|
check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED"
|
|
|
|
|
# An absent key omits itself (Go omitempty), never emits []: empty is the signal
|
|
|
|
|
# bootstrap turns into a hard untagged-key refusal, so it must read as empty here.
|
|
|
|
|
check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED"
|
|
|
|
|
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED"
|
|
|
|
|
|
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
|
|
|
# The guard is only worth something if it runs BEFORE the box is touched: the
|
|
|
|
|
# token prompt, the download, configure and svc.sh start all come after it.
|
|
|
|
|
# Ordering is the whole fix, so assert it rather than trust it.
|
|
|
|
|
# Matches the CALL, not the word: the comment above it mentions assert_runner_repo
|
|
|
|
|
# too, and a plain grep would keep finding that after the call itself was deleted.
|
|
|
|
|
# The defaults fail closed, so a guard that is gone cannot read as one that merely
|
|
|
|
|
# sits early in the file.
|
|
|
|
|
guard_at="$(grep -nE '^[[:space:]]*assert_runner_repo ' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
start_at="$(grep -n 'svc.sh start' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "runner install: the repo guard precedes svc.sh start" \
|
|
|
|
|
0 "" test "${guard_at:-999999}" -lt "${start_at:-0}"
|
|
|
|
|
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
check "runner status: --help exits 0" 0 "usage:" "$ROOT/commands/runner-status.sh" --help
|
|
|
|
|
check "runner status: user needs value" 2 "needs a value" "$ROOT/commands/runner-status.sh" --user
|
|
|
|
|
check "runner status: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-status.sh" --user root
|
|
|
|
|
check "runner status: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-status.sh" --nope
|
|
|
|
|
|
|
|
|
|
check "runner remove: --help exits 0" 0 "usage:" "$ROOT/commands/runner-remove.sh" --help
|
|
|
|
|
check "runner remove: user needs value" 2 "needs a value" "$ROOT/commands/runner-remove.sh" --user
|
|
|
|
|
check "runner remove: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-remove.sh" --user root
|
|
|
|
|
check "runner remove: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-remove.sh" --nope
|
|
|
|
|
|
|
|
|
|
check "runner repoint: --help exits 0" 0 "usage:" "$ROOT/commands/runner-repoint.sh" --help
|
|
|
|
|
check "runner repoint: repo required" 2 "--repo" "$ROOT/commands/runner-repoint.sh"
|
|
|
|
|
check "runner repoint: repo needs value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo
|
|
|
|
|
check "runner repoint: rejects bad slug" 2 "owner/repo" "$ROOT/commands/runner-repoint.sh" --repo not-a-slug
|
|
|
|
|
check "runner repoint: labels need value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --labels
|
|
|
|
|
check "runner repoint: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --user root
|
|
|
|
|
check "runner repoint: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-repoint.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "runner status: refuses non-root" 1 "must run as root" "$ROOT/commands/runner-status.sh"
|
|
|
|
|
check "runner remove: refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RUNNER_REMOVE_TOKEN=x "$ROOT/commands/runner-remove.sh"
|
|
|
|
|
# --local too: the token-free path must still not be runnable by the runner user.
|
|
|
|
|
check "runner remove: --local refuses non-root" 1 "must run as root" \
|
|
|
|
|
"$ROOT/commands/runner-remove.sh" --local
|
|
|
|
|
check "runner repoint: refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RUNNER_REMOVE_TOKEN=x RUNNER_TOKEN=y "$ROOT/commands/runner-repoint.sh" --repo acme/widgets
|
|
|
|
|
else
|
|
|
|
|
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
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
|
|
|
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
|
|
|
|
|
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
|
|
|
|
|
|
|
|
|
|
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
|
|
|
|
|
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
|
|
|
|
|
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
|
|
|
|
|
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
|
|
|
|
|
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
|
|
|
|
|
|
|
|
|
|
# --- users file refusal matrix, through the sourced parser -------------------
|
|
|
|
|
# Reaching the parser via the CLI stops at the root check; it is pure and
|
|
|
|
|
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
|
|
|
|
|
# so the refusals are proven here against fixtures, non-root and network-free.
|
|
|
|
|
parse() { # parse <file> — the users-file parser, exactly as apply runs it
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"
|
|
|
|
|
parse_users_file "$2"' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
|
|
|
|
|
FIX_BAD="$(mktemp)" # rewritten per refusal below
|
|
|
|
|
cat > "$FIX_OK" <<'USERS'
|
|
|
|
|
# fleet operators
|
|
|
|
|
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
|
|
|
|
|
dan admin,box ssh-ed25519 AAAAC3second dan@desk
|
|
|
|
|
|
|
|
|
|
maria rig ssh-ed25519 AAAAC3fixture maria@mac
|
|
|
|
|
USERS
|
|
|
|
|
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
|
|
|
|
|
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
|
|
|
|
|
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
|
|
|
|
|
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin' > "$FIX_BAD"
|
|
|
|
|
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
|
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
|
|
|
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
|
|
|
|
|
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
|
|
|
|
|
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
|
|
|
|
|
# line and the rule, like every other parser refusal.
|
|
|
|
|
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
|
|
|
|
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
|
|
|
|
|
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
|
|
|
|
check "users parser: leading-dash username is refused" 1 "invalid username" parse "$FIX_BAD"
|
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
|
|
|
check "users parser: valid file emits dan (both keys' roles agree)" \
|
|
|
|
|
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
|
|
|
|
|
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
|
|
|
|
|
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
|
|
|
|
|
# A single invocation, both messages asserted from its one stderr.
|
|
|
|
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
|
|
|
|
|
MULTI_ERRS="$(mktemp)"
|
|
|
|
|
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
|
|
|
|
|
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
|
|
|
|
|
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
|
|
|
|
|
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
|
|
|
|
|
rm -f "$MULTI_ERRS"
|
|
|
|
|
|
2026-07-18 14:53:30 +00:00
|
|
|
# --- '@root': seed the admin's keys from root's own authorized_keys (#17) ----
|
|
|
|
|
# The operator provably holds a root private key — they SSHed in with it to
|
|
|
|
|
# run apply at all — so seeding root's CURRENT authorized_keys is the one key
|
|
|
|
|
# source that cannot lock them out. The parser owns only the token's SHAPE
|
|
|
|
|
# (reading /root/.ssh needs root and is apply's business), so the shape is
|
|
|
|
|
# proven here: the exact token parses, trailing material is refused, literal
|
|
|
|
|
# key lines mix (append semantics), a second '@root' is a duplicate, and root
|
|
|
|
|
# cannot seed itself.
|
|
|
|
|
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
|
|
|
|
|
check "users parser: '@root' is a valid key field" 0 "dan|admin|@root" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin @root ssh-ed25519 AAAA x' > "$FIX_BAD"
|
|
|
|
|
check "users parser: '@root' takes no trailing material" 1 "whole key field" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin @root' 'dan admin ssh-ed25519 AAAAC3lit dan@desk' > "$FIX_BAD"
|
|
|
|
|
check "users parser: '@root' mixes with literal key lines" \
|
|
|
|
|
0 "dan|admin|ssh-ed25519 AAAAC3lit dan@desk" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin @root' 'dan admin @root' > "$FIX_BAD"
|
|
|
|
|
check "users parser: a second '@root' line is a duplicate" 1 "duplicate key line" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'root admin @root' > "$FIX_BAD"
|
|
|
|
|
check "users parser: root cannot seed from itself" 1 "not a rig-managed user" parse "$FIX_BAD"
|
|
|
|
|
# The empty-seed refusal (root has no authorized_keys) sits behind the root
|
|
|
|
|
# check — /root/.ssh is unreadable before it — so grep the die message, the
|
|
|
|
|
# same way every root-only refusal in this harness is pinned.
|
|
|
|
|
check "users apply: '@root' with a keyless root dies naming the repair" 0 "" \
|
|
|
|
|
grep -q "root has no authorized_keys" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
|
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
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
# A VALID fixture proves the whole file-validation pass sits before the
|
|
|
|
|
# root check — a parse failure here would exit 2, not 1.
|
|
|
|
|
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
|
2026-07-18 14:53:30 +00:00
|
|
|
# An '@root' fixture reaching the root check proves the token is parse-pass
|
|
|
|
|
# validation, not a runtime surprise.
|
|
|
|
|
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
|
|
|
|
|
check "users apply: '@root' fixture parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
"$ROOT/commands/users-apply.sh" --file "$FIX_BAD"
|
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
|
|
|
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: users non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
rm -f "$FIX_OK" "$FIX_BAD"
|
|
|
|
|
|
|
|
|
|
# Validate-then-apply: `visudo -c` must pass before anything lands in
|
|
|
|
|
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
|
|
|
|
|
# out of the escalation path apply just granted. Assert the order in the file,
|
|
|
|
|
# matching the calls rather than comments (repo precedent: the runner-install
|
|
|
|
|
# repo-guard ordering check). Defaults fail closed.
|
|
|
|
|
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "users apply: visudo -c precedes the sudoers install" \
|
|
|
|
|
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
|
|
|
|
|
|
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
|
|
|
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
|
|
|
|
|
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
|
|
|
|
|
# --file <me-as-admin>` turns role rig root-equivalent through this very
|
|
|
|
|
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
|
|
|
|
|
# refusal in both identity-management commands (repo precedent: the
|
|
|
|
|
# staging/runner tag greps).
|
|
|
|
|
check "users apply: invoker gate refusal is present" 0 "" \
|
|
|
|
|
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users close-root: invoker gate refusal is present" 0 "" \
|
|
|
|
|
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
|
|
|
|
|
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
|
|
|
|
|
# switch PAM actually honors, and the keys are renamed, never deleted
|
|
|
|
|
# (convergence never destroys). Needs root + real accounts, so grep both moves.
|
|
|
|
|
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
|
|
|
|
|
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users apply: revoked keys are renamed, never deleted" 0 "" \
|
|
|
|
|
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
# A fleet-wide users file must not abort apply on a host=no box just because
|
|
|
|
|
# it names a box-role user somewhere in the fleet: the box role binds where
|
|
|
|
|
# VMs live, so on host=no it skips (with a warning) and everything else —
|
|
|
|
|
# admins included — still converges.
|
|
|
|
|
check "users apply: box role skips on a host=no box" 0 "" \
|
|
|
|
|
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
|
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
|
|
|
# --- users close-root: the human-class root-door shutter ---------------------
|
|
|
|
|
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
|
|
|
|
|
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
|
|
|
|
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
|
|
|
|
|
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
|
|
|
|
|
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
|
|
|
|
|
# glob makes, so a renamed drop-in cannot silently lose the fight.
|
|
|
|
|
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
|
|
|
|
|
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
|
|
|
|
|
check "users close-root: drop-in name is the load-bearing one" 0 "" \
|
|
|
|
|
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
|
|
|
|
|
# on a box whose only door is SSH (exactly what this box is about to become),
|
|
|
|
|
# bouncing the daemon into a config it refuses to parse leaves no way back in.
|
|
|
|
|
# Match the call, not the word (repo precedent: the repo-guard ordering check);
|
|
|
|
|
# defaults fail closed.
|
|
|
|
|
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "users close-root: sshd -t precedes the ssh restart" \
|
|
|
|
|
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
|
2026-07-17 20:49:12 +00:00
|
|
|
# Convergence is a claim about the DOOR, not the file. Matching bytes can hide
|
|
|
|
|
# an earlier-sorting override (first-wins) or a daemon that died between
|
|
|
|
|
# install and restart and never read the file — so the no-op message may only
|
|
|
|
|
# be spoken after the effective-config assertion (`sshd -T`), and the no-op
|
|
|
|
|
# branch may only be TAKEN when the daemon provably started after the last
|
|
|
|
|
# change to sshd's config inputs. Pin both: the assert-before-claim ordering,
|
|
|
|
|
# and the daemon-start-vs-config-mtime proof's presence.
|
|
|
|
|
efft_at="$(grep -n 'sshd -T' "$ROOT/commands/users-close-root.sh" | grep -v '^[0-9]*:#' | head -n1 | cut -d: -f1)"
|
|
|
|
|
noop_at="$(grep -n 'nothing to do' "$ROOT/commands/users-close-root.sh" | tail -n1 | cut -d: -f1)"
|
|
|
|
|
check "users close-root: no-op claim sits after the effective-config assert" \
|
|
|
|
|
0 "" test "${efft_at:-999999}" -lt "${noop_at:-0}"
|
|
|
|
|
check "users close-root: no-op needs a daemon start newer than the config" 0 "" \
|
|
|
|
|
grep -q "ExecMainStartTimestamp" "$ROOT/commands/users-close-root.sh"
|
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
|
|
|
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
|
|
|
|
|
# non-empty authorized_keys behind group/world-writable perms is a key sshd
|
|
|
|
|
# rejects — closing root behind it welds the only door shut. The full gate
|
|
|
|
|
# needs root + real accounts, so grep the load-bearing check's wording.
|
|
|
|
|
check "users close-root: gate checks the StrictModes shape" 0 "" \
|
|
|
|
|
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
|
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
|
|
|
# The reachability proofs (#17): the shape checks prove the door SHOULD open;
|
|
|
|
|
# these prove what can be proven from inside — NOPASSWD sudo actually answers
|
|
|
|
|
# (`runuser ... sudo -n true`) and sshd's per-user EFFECTIVE config accepts
|
|
|
|
|
# the login (`sshd -T -C user=...`). Both need root, real accounts, and a
|
|
|
|
|
# live sshd, so grep the calls — and pin their ordering BEFORE the drop-in
|
|
|
|
|
# install, because reachability proven after the door shut is no proof at
|
|
|
|
|
# all. Match the calls, not the words (comments mention neither literal);
|
|
|
|
|
# defaults fail closed.
|
|
|
|
|
# The $a/$TMP/$DROPIN below are LITERALS we grep for in the script — single
|
|
|
|
|
# quotes are the point, as in the db and box-install checks.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "users close-root: gate proves NOPASSWD sudo answers" 0 "" \
|
|
|
|
|
grep -qF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
check "users close-root: gate resolves sshd's per-user config" 0 "" \
|
|
|
|
|
grep -qF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
sudon_at="$(grep -nF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
pert_at="$(grep -nF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
dropin_at="$(grep -nF 'install -m 0644 "$TMP" "$DROPIN"' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "users close-root: sudo -n proof precedes the drop-in install" \
|
|
|
|
|
0 "" test "${sudon_at:-999999}" -lt "${dropin_at:-0}"
|
|
|
|
|
check "users close-root: per-user sshd resolve precedes the drop-in install" \
|
|
|
|
|
0 "" test "${pert_at:-999999}" -lt "${dropin_at:-0}"
|
|
|
|
|
# runuser may be absent off-Debian; the gate must skip that one proof loudly,
|
|
|
|
|
# never die on a missing prover. Grep the graceful branch.
|
|
|
|
|
check "users close-root: a missing runuser skips the sudo proof, loudly" 0 "" \
|
|
|
|
|
grep -q "runuser not found" "$ROOT/commands/users-close-root.sh"
|
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
|
|
|
# DenyUsers judged fail-closed through the lib's pure deny_verdict — sshd
|
|
|
|
|
# accepts patterns and USER@HOST forms, and 'DenyUsers dan*' REALLY denies
|
|
|
|
|
# admin 'dan', so a token the check cannot prove irrelevant must flag, never
|
|
|
|
|
# pass (the review's regression: a wildcard denial). Empty output is the only
|
|
|
|
|
# pass; every hit names its reason.
|
|
|
|
|
deny_v() { # deny_v <user> <token...>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"; shift
|
|
|
|
|
deny_verdict "$@"' _ "$ROOT" "$@"
|
|
|
|
|
}
|
|
|
|
|
check "users close-root: deny_verdict flags a literal hit" \
|
|
|
|
|
0 "names this user" deny_v admin root admin
|
|
|
|
|
check "users close-root: deny_verdict fails closed on a wildcard (dan* vs dan)" \
|
|
|
|
|
0 "pattern entry 'dan*'" deny_v dan "dan*"
|
|
|
|
|
check "users close-root: deny_verdict fails closed on '?' patterns" \
|
|
|
|
|
0 "pattern entry" deny_v admin "admi?"
|
|
|
|
|
check "users close-root: deny_verdict fails closed on USER@HOST forms" \
|
|
|
|
|
0 "host-qualified" deny_v admin "admin@10.0.0.1"
|
|
|
|
|
deny_pass() { [ -z "$(deny_v "$@")" ]; } # empty verdict IS the pass
|
|
|
|
|
check "users close-root: deny_verdict passes provably-irrelevant literals" \
|
|
|
|
|
0 "" deny_pass admin root git backup
|
2026-07-18 17:17:39 +00:00
|
|
|
# The group directives, same discipline, judged against the candidate's ACTUAL
|
|
|
|
|
# membership (the review's regressions: an unmet AllowGroups, a DenyGroups
|
|
|
|
|
# naming a group they hold). First arg is the id -Gn word list.
|
|
|
|
|
groups_v() { # groups_v <fn> <groups> <token...>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"; shift
|
|
|
|
|
"$@"' _ "$ROOT" "$@"
|
|
|
|
|
}
|
|
|
|
|
groups_pass() { [ -z "$(groups_v "$@")" ]; }
|
|
|
|
|
check "users close-root: DenyGroups naming a held group flags" \
|
|
|
|
|
0 "a group this user is in" groups_v group_deny_verdict "dan sudo rig-admin" backup sudo
|
|
|
|
|
check "users close-root: DenyGroups fails closed on patterns" \
|
|
|
|
|
0 "pattern entry 'rig-*'" groups_v group_deny_verdict "dan rig-admin" "rig-*"
|
|
|
|
|
check "users close-root: DenyGroups passes provably-irrelevant literals" \
|
|
|
|
|
0 "" groups_pass group_deny_verdict "dan rig-admin" docker backup
|
|
|
|
|
check "users close-root: an unmet AllowGroups flags (fail closed)" \
|
|
|
|
|
0 "no entry literally names a group this user is in" groups_v group_allow_verdict "dan rig-admin" sudo
|
|
|
|
|
check "users close-root: AllowGroups pattern is no proof (fail closed)" \
|
|
|
|
|
0 "no entry literally names" groups_v group_allow_verdict "dan rig-admin" "rig-*"
|
|
|
|
|
check "users close-root: a literally-named held group passes AllowGroups" \
|
|
|
|
|
0 "" groups_pass group_allow_verdict "dan rig-admin" sudo rig-admin
|
|
|
|
|
# ...and the shipped gate consults both, against real membership.
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "users close-root: the gate consults the group verdicts" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*denyg_reason="\$\(group_deny_verdict ' "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "users close-root: the gate resolves real membership (id -Gn)" 0 "" \
|
|
|
|
|
grep -qF -- 'id -Gn -- "$a"' "$ROOT/commands/users-close-root.sh"
|
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
|
|
|
# ...and the shipped gate must actually consult it (call, not comment).
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "users close-root: the gate consults deny_verdict" 0 "" \
|
|
|
|
|
grep -qE '^[[:space:]]*deny_reason="\$\(deny_verdict ' "$ROOT/commands/users-close-root.sh"
|
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
|
|
|
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
|
|
|
|
|
# path sits behind the root check, so the gate is a pure lib function on
|
|
|
|
|
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
|
|
|
|
|
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
|
|
|
|
|
# stays pointable at fixtures.
|
|
|
|
|
marker_gate() { # marker_gate <marker_path>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"
|
|
|
|
|
assert_marker_human "$2"' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
MARKER_DIR="$(mktemp -d)"
|
|
|
|
|
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
|
|
|
|
|
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
|
|
|
|
|
check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
|
|
|
|
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
|
|
|
|
check "users close-root: class=server refuses, names the control plane" \
|
|
|
|
|
1 "control plane" marker_gate "$MARKER_DIR/server"
|
2026-07-18 14:54:03 +00:00
|
|
|
# #17's original table let the runner ROLE close root; the class model
|
|
|
|
|
# supersedes it — runner is class=server, an automation identity, and the
|
|
|
|
|
# refusal must SAY so or the divergence reads as a bug to anyone holding the
|
|
|
|
|
# old table.
|
|
|
|
|
check "users close-root: the server refusal owns the runner row (#17)" \
|
|
|
|
|
1 "runner included" marker_gate "$MARKER_DIR/server"
|
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
|
|
|
check "users close-root: class=human passes the gate" \
|
|
|
|
|
0 "" marker_gate "$MARKER_DIR/human"
|
|
|
|
|
rm -rf "$MARKER_DIR"
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: users close-root non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
# Bootstrap must read the closed door as hardened, not broken: `no` is the
|
|
|
|
|
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
|
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
|
|
|
# the widened assertion so a revert cannot ship green. The hardening block
|
|
|
|
|
# lives in lib/sshd.sh since #31 — ONE converger shared by the machine roles
|
|
|
|
|
# and the staging tenant — so the greps pin the lib, and a call-site grep pins
|
|
|
|
|
# that bootstrap actually runs it (a function nobody calls is not hardening).
|
|
|
|
|
check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \
|
|
|
|
|
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.sh"
|
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
|
|
|
# ...but only for class=human. On class=server a closed root door is a BROKEN
|
|
|
|
|
# box — root SSH is the control plane's automation door — and the usual cause
|
|
|
|
|
# is a 00-rig-users.conf left over from a former class=human life. The refusal
|
|
|
|
|
# must name that drop-in or the operator greps sshd configs blind; the path
|
|
|
|
|
# needs root + a doctored sshd, so grep the die message (repo precedent above).
|
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
|
|
|
check "sshd lib: class=server refusal names the stale close-root drop-in" 0 "" \
|
|
|
|
|
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/lib/sshd.sh"
|
|
|
|
|
# Validate-then-apply survived the extraction: sshd -t on the merged config
|
|
|
|
|
# must still precede the restart (same idiom as the close-root ordering check).
|
|
|
|
|
libt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
librestart_at="$(grep -nE '^[[:space:]]*systemctl restart ssh$' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "sshd lib: sshd -t precedes the ssh restart" \
|
|
|
|
|
0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}"
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
check "bootstrap: hardening runs through the shared lib" 0 "" \
|
|
|
|
|
grep -qE '^harden_sshd "\$CLASS"$' "$ROOT/commands/bootstrap.sh"
|
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
|
|
|
|
fix(coolify): validate the dump bindings, and stop printing $EDITOR
Both found by the first run on a real control-plane box — neither was
reachable by the argument-parsing tests.
$EDITOR is unset on a freshly-bootstrapped server, which is precisely rig's
target environment. The printed next-step `$EDITOR /etc/coolify-dump.env`
expanded to nothing, so bash tried to EXECUTE the 0600 bindings file and said
"Permission denied" — an error that reads like a filesystem problem and is not
one. Print `nano`.
A bare bucket name in S3_BUCKET reads to `aws` as a LOCAL path, so the upload
died with "Invalid argument type" and a usage dump — after pg_dump had run and
age had encrypted 14MB, with nothing in the error pointing at the actual
mistake. The script now validates the bindings up front: S3_BUCKET must be an
s3:// URI, S3_ENDPOINT must carry a scheme. Both fail with the value quoted and
the reason stated, before a database is read.
Note what still cannot be validated, and now says so in the script: age's X25519
header does not reveal its recipient, so a valid-but-WRONG key (staging's
instead of prod's) yields a flawless backup nobody can open. Only decrypting an
artifact proves the recipient. The printed next-steps now walk through that
read-back explicitly, from a machine holding the private key — never the box.
The dump script ships as an embedded heredoc, so a typo in it would first
surface at 04:00 on a live control plane. test/cli.sh now extracts it and
asserts it is valid bash and that both new guards fire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:55:39 +00:00
|
|
|
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
|
|
|
|
# error in it would be invisible here and would first surface at 04:00 on a live
|
|
|
|
|
# control plane. Extract it and syntax-check what actually gets written.
|
|
|
|
|
DUMP_TMP="$(mktemp)"
|
|
|
|
|
sed -n "/<<'DUMP_SCRIPT'/,/^DUMP_SCRIPT\$/p" "$ROOT/commands/coolify-backup-install.sh" \
|
|
|
|
|
| sed '1d;$d' > "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script extracted (guards the sed above)" 0 "" grep -q "pg_dump" "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script is valid bash" 0 "" bash -n "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script rejects a bare bucket name" 1 "must be an s3:// URI" \
|
|
|
|
|
env AGE_RECIPIENT=age1x S3_BUCKET=my-bucket S3_ENDPOINT=https://s3.example.com bash "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script rejects a schemeless endpoint" 1 "needs a scheme" \
|
|
|
|
|
env AGE_RECIPIENT=age1x S3_BUCKET=s3://b/k S3_ENDPOINT=s3.example.com bash "$DUMP_TMP"
|
|
|
|
|
rm -f "$DUMP_TMP"
|
|
|
|
|
|
2026-07-11 19:37:48 +00:00
|
|
|
# Regression: /etc/os-release defines VERSION (e.g. "13 (trixie)" on Debian);
|
|
|
|
|
# sourcing it in the main shell clobbers a script's $VERSION and splices the
|
|
|
|
|
# OS string into download URLs. It must only ever be sourced in a subshell.
|
|
|
|
|
check "no main-shell os-release sourcing" 1 "" \
|
|
|
|
|
grep -rnE '^[[:space:]]*\.[[:space:]]+/etc/os-release' "$ROOT/commands"
|
|
|
|
|
|
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
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# The versioned install (box#79's layout, ported — #35). RIG_INSTALL_SOURCE
|
|
|
|
|
# bypasses the network, so these are REAL runs of install.sh against throwaway
|
|
|
|
|
# RIG_HOME/RIG_BIN roots — layout, symlink chain, flat-tree migration, symlink
|
|
|
|
|
# healing, use and uninstall are all DRIVEN, not grepped. The bootstrapped-host
|
|
|
|
|
# flip gate (rig's analog of box's #66 refusal: WARN, never refuse) is driven
|
|
|
|
|
# too, through RIG_ROLE_MARKER fixtures — no root, no network, no real marker.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh"
|
|
|
|
|
VER="$(cat "$ROOT/VERSION")"
|
|
|
|
|
check "--version answers the tree's own VERSION" 0 "rig $VER" "$ROOT/bin/rig" --version
|
|
|
|
|
check "-V is --version" 0 "rig $VER" "$ROOT/bin/rig" -V
|
|
|
|
|
check "help lists the versioned verbs" 0 "uninstall" "$ROOT/bin/rig" --help
|
|
|
|
|
|
|
|
|
|
WORK="$(mktemp -d)"
|
|
|
|
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
|
|
|
|
|
|
|
|
|
# A fabricated "newer release": the same CLI, a different VERSION — what an
|
|
|
|
|
# upgrade actually is, from the installer's point of view.
|
|
|
|
|
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin"
|
|
|
|
|
cp "$ROOT/bin/rig" "$SRC9/bin/rig"; chmod +x "$SRC9/bin/rig"
|
|
|
|
|
echo "9.9.9-drill" > "$SRC9/VERSION"
|
|
|
|
|
SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin"
|
|
|
|
|
cp "$ROOT/bin/rig" "$SRC8/bin/rig"; chmod +x "$SRC8/bin/rig"
|
|
|
|
|
echo "8.8.8-drill" > "$SRC8/VERSION"
|
|
|
|
|
|
|
|
|
|
inst() { # inst <rig_home> <rig_bin> [VAR=val ...] — run install.sh for real
|
|
|
|
|
local h="$1" b="$2"; shift 2
|
|
|
|
|
env HOME="$FAKEHOME" RIG_ROLE_MARKER="$WORK/no-marker" \
|
|
|
|
|
RIG_HOME="$h" RIG_BIN="$b" \
|
|
|
|
|
RIG_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh"
|
|
|
|
|
}
|
|
|
|
|
irig() { # irig [VAR=val ...] <cmd...> — run an installed rig, marker-free
|
|
|
|
|
env HOME="$FAKEHOME" RIG_ROLE_MARKER="$WORK/no-marker" "$@"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --- fresh install: the layout and the chain --------------------------------
|
|
|
|
|
H1="$WORK/h1"; B1="$WORK/b1"
|
|
|
|
|
check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1"
|
|
|
|
|
check "install: the tree lands in versions/<v>" 0 "" test -x "$H1/versions/$VER/bin/rig"
|
|
|
|
|
check "install: 'current' points at versions/<v>" 0 "versions/$VER" readlink "$H1/current"
|
|
|
|
|
check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/rig" readlink "$B1/rig"
|
|
|
|
|
check "install: rig --version answers through the whole chain" 0 "rig $VER" irig "$B1/rig" --version
|
|
|
|
|
check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM"
|
|
|
|
|
|
2026-07-18 22:36:05 +00:00
|
|
|
# --- rig#39: no $HOME in the environment (cloud-init's runcmd) ---------------
|
|
|
|
|
# The box#88 seed runs install.sh from runcmd, which carries NO $HOME; under
|
|
|
|
|
# set -u the first $HOME expansion was a death instead of an install. The
|
|
|
|
|
# installer now derives a home from getent — driven here with a shim getent
|
|
|
|
|
# so the derived home is a throwaway root, and proven fatal-BY-NAME when
|
|
|
|
|
# getent has no answer either (never a bare unbound-variable stack).
|
|
|
|
|
GESHIM="$WORK/geshim"; GEHOME="$WORK/gehome"; mkdir -p "$GESHIM" "$GEHOME"
|
|
|
|
|
printf '#!/bin/sh\necho "u:x:0:0::%s:/bin/sh"\n' "$GEHOME" > "$GESHIM/getent"
|
|
|
|
|
chmod +x "$GESHIM/getent"
|
|
|
|
|
check "install: no \$HOME derives one from getent (rig#39)" 0 "done" \
|
|
|
|
|
env -u HOME PATH="$GESHIM:$PATH" RIG_ROLE_MARKER="$WORK/no-marker" \
|
|
|
|
|
RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"
|
|
|
|
|
check "install: ...and the tree landed under the derived home" 0 "" \
|
|
|
|
|
test -x "$GEHOME/.local/share/rig/versions/$VER/bin/rig"
|
|
|
|
|
printf '#!/bin/sh\nexit 2\n' > "$GESHIM/getent"
|
|
|
|
|
check "install: no \$HOME and no getent answer refuses by name" 1 "set HOME and re-run" \
|
|
|
|
|
env -u HOME PATH="$GESHIM:$PATH" RIG_ROLE_MARKER="$WORK/no-marker" \
|
|
|
|
|
RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"
|
|
|
|
|
|
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
|
|
|
# --- converge, don't clobber ------------------------------------------------
|
|
|
|
|
touch "$H1/versions/$VER/CANARY"
|
|
|
|
|
check "install: a same-version re-run is a no-op that says so" 0 "already installed" inst "$H1" "$B1"
|
|
|
|
|
check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY"
|
|
|
|
|
check "install: RIG_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" RIG_REINSTALL=1
|
|
|
|
|
check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY"
|
|
|
|
|
|
|
|
|
|
# --- a second version: side-by-side, and the flip ---------------------------
|
|
|
|
|
check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" RIG_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/rig"
|
|
|
|
|
check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER"
|
|
|
|
|
check "install: the default flips to the new version" 0 "rig 9.9.9-drill" irig "$B1/rig" --version
|
|
|
|
|
|
|
|
|
|
# --- rig versions -----------------------------------------------------------
|
|
|
|
|
check "versions: lists the installed versions" 0 "$VER" irig "$B1/rig" versions
|
|
|
|
|
check "versions: marks the current default" 0 "(current)" irig "$B1/rig" versions
|
|
|
|
|
check "versions: marks the running one" 0 "(running)" irig "$B1/rig" versions
|
|
|
|
|
|
|
|
|
|
# --- rig use ----------------------------------------------------------------
|
|
|
|
|
check "use: no argument is a usage error" 2 "use needs a version" irig "$B1/rig" use
|
|
|
|
|
check "use: an unknown version is refused by name" 1 "no such version" irig "$B1/rig" use 1.2.3
|
|
|
|
|
# A version is a directory NAME — a crafted one must die at the gate, never
|
|
|
|
|
# reach the ln (current pointing outside the root) or an rm -rf.
|
|
|
|
|
check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \
|
|
|
|
|
irig "$B1/rig" use '../../tmp/evil'
|
|
|
|
|
check "use: flips the default" 0 "switched to $VER" irig "$B1/rig" use "$VER"
|
|
|
|
|
check "use: the flip is effective through the PATH chain" 0 "rig $VER" irig "$B1/rig" --version
|
|
|
|
|
check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \
|
|
|
|
|
inst "$H1" "$B1" RIG_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" --version
|
|
|
|
|
|
|
|
|
|
# --- the flip gate: a bootstrapped host WARNS, never refuses (#35) ----------
|
|
|
|
|
# box refuses version flips under existing boxes; rig's stake is the converged
|
|
|
|
|
# host itself — /etc/rig/role. The deliberate decision: warn and proceed.
|
|
|
|
|
# Driven against a fixture marker; counting fires proves silence too.
|
|
|
|
|
MARK="$WORK/role-marker"
|
|
|
|
|
printf 'role=workload class=server host=no join=authkey\n' > "$MARK"
|
|
|
|
|
H2="$WORK/h2"; B2="$WORK/b2"
|
|
|
|
|
check "flip gate: baseline install" 0 "done" inst "$H2" "$B2"
|
|
|
|
|
check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \
|
|
|
|
|
inst "$H2" "$B2" RIG_INSTALL_SOURCE="$SRC9" RIG_ROLE_MARKER="$MARK"
|
|
|
|
|
check "flip gate: ...and still flips (warn, not refuse)" 0 "rig 9.9.9-drill" irig "$B2/rig" --version
|
|
|
|
|
check "flip gate: 'rig use' on a bootstrapped host WARNS" 0 "this host is bootstrapped" \
|
|
|
|
|
irig RIG_ROLE_MARKER="$MARK" "$B2/rig" use "$VER"
|
|
|
|
|
check "flip gate: ...and still flips" 0 "rig $VER" irig "$B2/rig" --version
|
|
|
|
|
# Silence when no marker: warning every un-bootstrapped host would train
|
|
|
|
|
# operators to ignore it.
|
|
|
|
|
flip_warns() { # flip_warns <cmd...> — how many bootstrapped warnings fired
|
|
|
|
|
"$@" 2>&1 | grep -c "this host is bootstrapped" || true
|
|
|
|
|
}
|
|
|
|
|
check "flip gate: no marker, no warning (installer)" 0 "0" \
|
|
|
|
|
flip_warns inst "$H2" "$B2" RIG_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
check "flip gate: no marker, no warning (rig use)" 0 "0" \
|
|
|
|
|
flip_warns irig "$B2/rig" use "$VER"
|
|
|
|
|
check "flip gate: a fresh install never warns (nothing changes under the host)" 0 "0" \
|
|
|
|
|
flip_warns inst "$WORK/h2f" "$WORK/b2f" RIG_ROLE_MARKER="$MARK"
|
|
|
|
|
|
|
|
|
|
# --- migration: a flat pre-versioning tree becomes a versioned one ----------
|
|
|
|
|
H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3"
|
|
|
|
|
cp "$ROOT/bin/rig" "$H3/bin/rig"; chmod +x "$H3/bin/rig"
|
|
|
|
|
cp "$ROOT/VERSION" "$H3/VERSION"
|
|
|
|
|
echo "test@flat" > "$H3/INSTALLED_FROM"
|
|
|
|
|
ln -s "$H3/bin/rig" "$B3/rig"
|
|
|
|
|
check "migrate: a flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3"
|
|
|
|
|
check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \
|
|
|
|
|
cat "$H3/versions/$VER/INSTALLED_FROM"
|
|
|
|
|
check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin"
|
|
|
|
|
check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current"
|
|
|
|
|
check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/rig" readlink "$B3/rig"
|
|
|
|
|
check "migrate: the migrated install answers --version" 0 "rig $VER" irig "$B3/rig" --version
|
|
|
|
|
|
|
|
|
|
# ...and the seamless upgrade every REAL flat rig install takes: no VERSION
|
|
|
|
|
# file at all (pre-rig#32), so it migrates as 0.0.0-unknown and the new
|
|
|
|
|
# version lands beside it and becomes the default.
|
|
|
|
|
H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4"
|
|
|
|
|
cp "$ROOT/bin/rig" "$H4/bin/rig"; chmod +x "$H4/bin/rig"
|
|
|
|
|
ln -s "$H4/bin/rig" "$B4/rig"
|
|
|
|
|
check "migrate: a VERSION-less flat tree migrates as 0.0.0-unknown" 0 "0.0.0-unknown" \
|
|
|
|
|
inst "$H4" "$B4" RIG_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
check "migrate+upgrade: both versions present" 0 "" \
|
|
|
|
|
bash -c "[ -d '$H4/versions/0.0.0-unknown' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
|
|
|
|
check "migrate+upgrade: the new version is the default" 0 "rig 9.9.9-drill" \
|
|
|
|
|
irig "$B4/rig" --version
|
|
|
|
|
|
|
|
|
|
# A broken current must halt the single-version uninstall BEFORE any decision:
|
|
|
|
|
# the CURRENT guard keys off what current resolves to, and a dangling link
|
|
|
|
|
# makes that answer a lie. Drive the version tree's own binary — the current
|
|
|
|
|
# chain is exactly what is broken. Heal current afterwards.
|
|
|
|
|
ln -sfn "versions/gone" "$H4/current"
|
|
|
|
|
check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \
|
|
|
|
|
irig "$H4/versions/9.9.9-drill/bin/rig" uninstall 0.0.0-unknown --force
|
|
|
|
|
check "uninstall: ...and both version trees survived the refusal" 0 "" \
|
|
|
|
|
bash -c "[ -d '$H4/versions/0.0.0-unknown' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
|
|
|
|
ln -sfn "versions/9.9.9-drill" "$H4/current"
|
|
|
|
|
|
|
|
|
|
# The migration reads VERSION off the old tree — disk data, not installer
|
|
|
|
|
# data. A hostile value must refuse BEFORE the tree moves anywhere.
|
|
|
|
|
H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9"
|
|
|
|
|
cp "$ROOT/bin/rig" "$H9/bin/rig"; chmod +x "$H9/bin/rig"
|
|
|
|
|
printf '%s\n' '../pwn' > "$H9/VERSION"
|
|
|
|
|
check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \
|
|
|
|
|
inst "$H9" "$B9"
|
|
|
|
|
check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/rig"
|
|
|
|
|
|
|
|
|
|
# --- healing: a wedged $BINDIR/rig must never block an install --------------
|
|
|
|
|
H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5"
|
|
|
|
|
ln -s "$WORK/nowhere/rig" "$B5/rig" # dangling
|
|
|
|
|
check "heal: a DANGLING \$BINDIR/rig does not wedge the install" 0 "done" inst "$H5" "$B5"
|
|
|
|
|
check "heal: ...and got repointed" 0 "rig $VER" irig "$B5/rig" --version
|
|
|
|
|
H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6"
|
|
|
|
|
ln -s /bin/true "$B6/rig" # stale, but resolvable
|
|
|
|
|
check "heal: a STALE \$BINDIR/rig with no tree does not fake 'installed'" 0 "installing $VER" \
|
|
|
|
|
inst "$H6" "$B6"
|
|
|
|
|
check "heal: ...the install is real and answers" 0 "rig $VER" irig "$B6/rig" --version
|
|
|
|
|
|
|
|
|
|
# --- rig uninstall: one version ---------------------------------------------
|
|
|
|
|
check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \
|
|
|
|
|
irig "$B1/rig" uninstall "$VER" --force
|
|
|
|
|
check "uninstall: an unknown version is refused by name" 1 "no such version" \
|
|
|
|
|
irig "$B1/rig" uninstall 5.5.5 --force
|
|
|
|
|
check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \
|
|
|
|
|
irig "$B1/rig" uninstall '../../../../etc' --force
|
|
|
|
|
check "uninstall: a version plus --all is ambiguous (usage error)" 2 "ambiguous" \
|
|
|
|
|
irig "$B1/rig" uninstall 9.9.9-drill --all --force
|
|
|
|
|
check "uninstall: an unknown flag is refused" 2 "unknown option" \
|
|
|
|
|
irig "$B1/rig" uninstall --nope
|
|
|
|
|
check "uninstall: removes a non-current version" 0 "removed version" \
|
|
|
|
|
irig "$B1/rig" uninstall 9.9.9-drill --force
|
|
|
|
|
check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill"
|
|
|
|
|
check "uninstall: the current version still answers" 0 "rig $VER" irig "$B1/rig" --version
|
|
|
|
|
|
|
|
|
|
# --- rig uninstall: everything ----------------------------------------------
|
|
|
|
|
check "uninstall: refuses without --force when no terminal" 2 "refusing" \
|
|
|
|
|
irig bash -c "'$B1/rig' uninstall --all </dev/null"
|
|
|
|
|
check "uninstall --all: warns on a bootstrapped host (never refuses)" 0 "this host is bootstrapped" \
|
|
|
|
|
irig RIG_ROLE_MARKER="$MARK" "$B1/rig" uninstall --all --force
|
|
|
|
|
check "uninstall --all: removed the whole install" 0 "" bash -c "
|
|
|
|
|
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
|
|
|
|
|
[ ! -e '$B1/rig' ] && [ ! -L '$B1/rig' ]"
|
|
|
|
|
# ...and RIG_YES=1 is the installer-family consent (no --force, no tty).
|
|
|
|
|
check "uninstall --all: RIG_YES=1 is consent without a terminal" 0 "uninstalled" \
|
|
|
|
|
irig bash -c "RIG_YES=1 '$B2/rig' uninstall --all </dev/null"
|
|
|
|
|
check "uninstall --all: ZERO residue — root and symlinks" 0 "" bash -c "
|
|
|
|
|
[ ! -e '$H2' ] && [ ! -L '$H2' ] &&
|
|
|
|
|
[ ! -e '$B2/rig' ] && [ ! -L '$B2/rig' ]"
|
|
|
|
|
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
|
|
|
|
|
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
|
|
|
|
|
# meaningful — and runnable — for a non-root runner only.)
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
H7="$WORK/h7"; B7="$WORK/b7"
|
|
|
|
|
inst "$H7" "$B7" >/dev/null 2>&1
|
|
|
|
|
mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin"
|
|
|
|
|
chmod 555 "$H7/versions/$VER/stuck"
|
|
|
|
|
check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \
|
|
|
|
|
irig "$B7/rig" uninstall --all --force
|
|
|
|
|
chmod -R u+w "$H7" 2>/dev/null
|
|
|
|
|
else
|
|
|
|
|
echo "skip: uninstall INCOMPLETE drill (root ignores file modes)"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# --- the versioned verbs from a working tree: refuse, don't guess -----------
|
|
|
|
|
check "uninstall: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" uninstall --all --force
|
|
|
|
|
check "versions: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" versions
|
|
|
|
|
check "use: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" use 1.0.0
|
|
|
|
|
|
|
|
|
|
# The version-name gate must be ONE decision: install.sh and bin/rig carry
|
|
|
|
|
# byte-identical copies (the installer runs before any tree exists), and a
|
|
|
|
|
# drifted copy is two gates pretending to be one — a version install.sh would
|
|
|
|
|
# refuse must not be one 'rig use' accepts.
|
|
|
|
|
VVBIN="$(mktemp)"; VVINST="$(mktemp)"
|
|
|
|
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/rig" > "$VVBIN"
|
|
|
|
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST"
|
|
|
|
|
check "valid_version: extracted from bin/rig (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN"
|
|
|
|
|
check "valid_version: bin/rig and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST"
|
|
|
|
|
rm -f "$VVBIN" "$VVINST"
|
|
|
|
|
# Same discipline for the flip gate: one bootstrapped-host stance, two copies.
|
|
|
|
|
WBBIN="$(mktemp)"; WBINST="$(mktemp)"
|
|
|
|
|
awk '/^warn_bootstrapped\(\) \{/,/^\}/' "$ROOT/bin/rig" > "$WBBIN"
|
|
|
|
|
awk '/^warn_bootstrapped\(\) \{/,/^\}/' "$ROOT/install.sh" > "$WBINST"
|
|
|
|
|
check "warn_bootstrapped: extracted from bin/rig (guards the awk)" 0 "RIG_ROLE_MARKER" cat "$WBBIN"
|
|
|
|
|
check "warn_bootstrapped: bin/rig and install.sh copies are byte-identical" 0 "" diff "$WBBIN" "$WBINST"
|
|
|
|
|
rm -f "$WBBIN" "$WBINST"
|
|
|
|
|
|
|
|
|
|
rm -rf "$WORK"
|
|
|
|
|
|
2026-07-10 20:40:07 +00:00
|
|
|
echo "---"
|
|
|
|
|
echo "$PASS passed, $FAIL failed"
|
|
|
|
|
[ "$FAIL" -eq 0 ]
|