# rig `runner install` Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `rig runner install` command that turns a bootstrapped workload box into a self-hosted GitHub Actions runner — the official `actions/runner` agent as a **systemd service under an unprivileged user**, with **no Docker** and **zero inbound ports**. **Why this shape:** the runner is an *agent, not a server* — it opens an HTTPS long-poll out to GitHub and receives jobs down that already-established connection, so a box behind a deny-all firewall (or on a tailnet) can run CI jobs that reach hosts GitHub never could. Docker is deliberately refused: `docker` group membership is root-equivalent (the socket is a root API), and on a box whose design goal is a narrow blast radius for repo-controlled code that is a gratuitous path to root. Docker arrives only if/when a job genuinely needs it, and the isolation model gets revisited then (`--ephemeral`), not assumed. **Architecture:** one new command script `commands/runner-install.sh` dispatched by `bin/rig` (mirroring `coolify install`), test cases appended to `test/cli.sh`, README section. No new dependencies — bash + the tools a `rig bootstrap`-ed Debian box already has (`curl`, `tar`, `useradd`, `runuser`, systemd). CI (shellcheck + cli tests) already globs `commands/*.sh`, so it covers the new script with no workflow change. **Tech Stack:** bash only, shellcheck, GitHub Actions (existing `ci.yml`). ## Global Constraints - Every script starts `#!/usr/bin/env bash` + `set -euo pipefail`. - shellcheck-clean at default severity: `shellcheck install.sh bin/rig commands/*.sh test/cli.sh` exits 0. - Exit codes: `2` = usage/argument error, `1` = runtime refusal (e.g. not root). **All argument validation runs BEFORE the root check** so error paths are testable as non-root. - **No credential ever touches disk via rig.** The registration token is read from the `RUNNER_TOKEN` env var or an interactive `read -rsp` prompt, handed to `config.sh` in memory, and never logged. (The runner itself persists its own derived credential under its install dir, owned by the runner user — that is the runner's design, not rig's doing.) - **The runner user is never root and gets no supplementary groups** — no `docker`, no `sudo`. rig must not install Docker here, ever. - Nothing org-specific in this repo — README/usage examples use generic names (`acme/widgets`); no real hostnames, tags beyond generic examples, or fleet details. - Convergent: a second run of the same command changes nothing and exits 0. - Conventional Commits (`type: subject`; no scope enum in this repo). - Version pin is **required** (`--version `, no default), matching `coolify install`. Divergence from Coolify's `AUTOUPDATE=false` posture: the installed runner **self-updates** (GitHub refuses jobs from stale runners — a runner that never updates silently stops working). The pin states what you install today; GitHub owns the treadmill after that. This difference is deliberate and gets a README sentence. --- ### Task 1: `runner install` command + dispatcher wiring + tests **Files:** - Create: `commands/runner-install.sh` - Modify: `bin/rig` (dispatch `runner install`, extend usage text) - Modify: `test/cli.sh` (append cases before the `echo "---"` line) **Interfaces:** - Consumes: dispatcher passes post-`runner install` args verbatim; harness `check` function. - Produces: `commands/runner-install.sh` taking `--repo --version [--name ] [--labels ] [--user ]`. **Command contract (`commands/runner-install.sh`):** Usage text (heredoc, shown on `--help` and usage errors): ``` usage: rig runner install --repo --version [options] --repo GitHub repository the runner registers to (required) --version actions/runner release to install, e.g. 2.335.1 (required; no default — you state what you install) --name runner name (default: this host's hostname) --labels extra runner labels (default: ci-runner) --user unprivileged service user (default: github-runner; created if absent; never root) Installs GitHub's official actions/runner as a systemd service under an unprivileged user. The runner is an agent, not a server: it long-polls GitHub outbound and needs ZERO inbound ports. No Docker is installed and the runner user gets no supplementary groups. Provide the short-lived registration token via the RUNNER_TOKEN env var or the interactive prompt (get one from the repo's Settings > Actions > Runners > "New self-hosted runner", or: gh api -X POST repos//actions/runners/registration-token). It is consumed at registration and never written to disk by rig. ``` Behavior, in order: 1. **Arg parsing** (before the root check). `-h|--help` → usage, exit 0. Unknown flag → `die "unknown flag: $1" 2`. Flags needing values use the existing `[ $# -ge 2 ] || die "--x needs a value" 2` pattern. 2. **Validation** (still before the root check): - `--repo` required (`die "--repo is required" 2`); must match `^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$` else `die "--repo must be owner/repo" 2`. - `--version` required (`die "--version is required" 2`); normalize a leading `v` away (`VERSION="${VERSION#v}"`). - `--user` must not be `root`: `die "runner user must not be root" 2`. 3. **Guards:** root check (`must run as root`, exit 1) and the same Debian-family warn block `bootstrap.sh` uses. Also `command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"`. 4. **Registration token — only when registration is actually pending.** Registration is pending unless the runner user already exists AND `$RUNNER_DIR/.runner` exists (user absent ⇒ nothing can be registered ⇒ pending): if the user exists, derive `USER_HOME`/`RUNNER_DIR` as in step 5 and test `-e "$RUNNER_DIR/.runner"`. When NOT pending, skip token acquisition entirely — a converged second run must never demand a fresh short-lived token the operator no longer holds, and must exit 0. When pending: `RUNNER_TOKEN` from env, else `read -rsp "runner registration token (short-lived): " RUNNER_TOKEN; echo`. Empty → `die "empty registration token"`. 5. **User:** if `id -u "$RUNNER_USER"` fails, `useradd --create-home --shell /bin/bash "$RUNNER_USER"` and log; else log "user exists". Derive `USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"`; `RUNNER_DIR="$USER_HOME/actions-runner"`. 6. **Download + unpack** (skip whole step, with a log line, when `$RUNNER_DIR/bin/Runner.Listener` already exists — upgrades are the runner's own self-update, not a rig re-run): - Arch map: `x86_64` → `x64`, `aarch64` → `arm64`, else `die "unsupported arch: $(uname -m)"`. - `URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz"`. - Download to a `mktemp -d` workspace (with `trap cleanup EXIT`), extract into `$RUNNER_DIR` (`mkdir -p` first), `chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR"`. - No checksum flag: the transport is TLS to github.com and the runner self-updates from the same origin forever after — a one-time hash adds ritual, not trust. (Stated here so it reads as a decision, not an omission.) - Run `"$RUNNER_DIR"/bin/installdependencies.sh` as root (idempotent apt installs of the runner's native deps). 7. **Configure** (skip, with a log line, when `$RUNNER_DIR/.runner` exists — already registered): ```bash (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ ./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \ --name "$RUNNER_NAME" --labels "$LABELS" --unattended --replace) ``` (`config.sh` refuses to run as root, hence `runuser`; `--replace` makes re-registration after a box rebuild convergent; `--unattended` because rig already collected everything interactive.) 8. **Service** via the runner's own `svc.sh` (must run as root, and svc.sh resolves paths relative to cwd — keep the literal `cd` wrapper): ```bash if [ ! -e "$RUNNER_DIR/.service" ]; then (cd "$RUNNER_DIR" && ./svc.sh install "$RUNNER_USER") fi (cd "$RUNNER_DIR" && ./svc.sh start) # idempotent ``` 9. **Final logs:** runner name + labels; "verify it shows Idle under the repo's Settings > Actions > Runners"; remind that the deny-all provider firewall stays the operator's job outside rig (existing README stance) and that the box needs no inbound ports for this. Log prefix: `rig-runner:` (matching `rig-bootstrap:` / `rig-coolify:`), same `log`/`warn`/`die` helpers as `bootstrap.sh`. **Dispatcher (`bin/rig`):** add a `runner)` case exactly mirroring `coolify)` (sub must be `install`, else usage + exit 2), and add to the usage heredoc: ``` runner install --repo --version [options] GitHub Actions runner as a systemd service under an unprivileged user — outbound-only, no Docker. Prompts for the short-lived registration token (RUNNER_TOKEN env overrides). Run as root. ``` - [ ] **Step 1: Append failing tests** In `test/cli.sh`, insert before the `echo "---"` line: ```bash 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 check "runner: version required, exit 2" 2 "--version" "$ROOT/commands/runner-install.sh" --repo acme/widgets 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 ``` - [ ] **Step 2: Run tests to verify the new ones fail** Run: `bash test/cli.sh` Expected: the 16 existing checks pass; the `bare runner` check is already green pre-implementation (the dispatcher's unknown-command branch prints the usage text and exits 2, which happens to satisfy it); every `runner:` check FAILS (file not found → exit 127); harness exits 1. - [ ] **Step 3: Implement `commands/runner-install.sh` and wire `bin/rig`** Per the command contract above. `chmod +x commands/runner-install.sh`. - [ ] **Step 4: Run tests to verify they pass** Run: `bash test/cli.sh` Expected: `25 passed, 0 failed` (or 22 + 3 skip lines as root), exit 0. - [ ] **Step 5: shellcheck** Run: `shellcheck install.sh bin/rig commands/*.sh test/cli.sh` Expected: exit 0, no output. - [ ] **Step 6: Commit** ```bash git add commands/runner-install.sh bin/rig test/cli.sh git commit -m "feat: runner install command — GitHub Actions runner as an unprivileged systemd service" ``` --- ### Task 2: README section **Files:** - Modify: `README.md` (new `### rig runner install` subsection under Commands, after `coolify install`) **Interfaces:** - Consumes: the Task 1 command surface, verbatim. - Produces: operator-facing doc matching the README's existing voice. - [ ] **Step 1: Write the section** Content requirements (write in the README's existing voice, don't paste this list): - Heading: `### rig runner install --repo --version `. - Workload-box command; run after `rig bootstrap workload`. Example block: ```sh rig bootstrap workload --hostname my-ci-box --ts-tag tag:ci rig runner install --repo acme/widgets --version 2.335.1 ``` - The agent-not-server point: the runner long-polls GitHub outbound and receives jobs down that connection — **zero inbound ports**, so it works behind a deny-all firewall and can trigger deploys on hosts only it can reach (e.g. a tailnet-only control plane). - The posture: official `actions/runner` as a systemd service under an unprivileged user (default `github-runner`, created if absent, never root, no supplementary groups); **no Docker, deliberately** — the docker socket is a root API and `docker` group membership is root-equivalent; add Docker only when a job truly needs it and rethink isolation then. - Flags: `--name` (default: hostname), `--labels` (default: `ci-runner`; GitHub adds `self-hosted` itself), `--user`. - Token handling: `RUNNER_TOKEN` env or interactive prompt; short-lived; never written to disk by rig. - The version-pin note: pin is required like `coolify install`, but unlike Coolify the runner then **self-updates** — GitHub refuses stale runners, so freezing it means it silently stops taking jobs. Deliberate divergence. - Convergent: safe to re-run; an already-registered runner is left alone. - [ ] **Step 2: Full local gate** Run: `shellcheck install.sh bin/rig commands/*.sh test/cli.sh && bash test/cli.sh` Expected: shellcheck silent; `25 passed, 0 failed`, exit 0. - [ ] **Step 3: Commit** ```bash git add README.md git commit -m "docs: README section for runner install" ``` --- ## Addendum (2026-07-11, operator-requested, post final review) A third bootstrap role, `runner`, joins `control-plane|workload` — requested for CLI consistency (each follow-up command applies to exactly one role) and because it closes a real footgun mechanically: the role defaults `--ts-tag` to `tag:ci` and **refuses `tag:server`** (exit 2, validated before the root check). Forgetting the tag flag previously joined the CI box with the default server tag — the exact misconfiguration the runner posture exists to prevent. Everything else about bootstrap is unchanged; `runner install`'s contract is untouched. Tests: +2 (`runner refuses tag:server`, `runner role parses / refuses non-root`) → 27 non-root. Second amendment (same day): `runner install --version` becomes **optional** — omitted, rig resolves the latest release at install time by following the `releases/latest` redirect (no API, no rate limit, no JSON parsing; validated against a digits-and-dots pattern before use). Safe here, and only here, because the runner self-updates regardless of what you install; `coolify install` keeps its mandatory pin — Coolify never self-updates, so its version is a verified contract, not a starting point. The `version required` test is replaced by a `--version needs a value` test → still 27 non-root. ## Integration (orchestrator, after final review — not an SDD task) 1. Push the branch to the fork and open the PR **against upstream**: `git push -u fork feat/runner-install`, then `gh pr create --repo heavy-duty/rig --head claude-hdb:feat/runner-install ...` with a body summarizing the posture (outbound-only agent, unprivileged user, no Docker, token never on disk) and noting that the end-to-end functional test is the box rehearsal (README Testing section) — the CLI tests cover argument/refusal paths only. 2. Run the `record` skill in the consuming project's brain.