fix(runner): install refuses a box registered to another repo #14

Merged
dan-claude-bot merged 3 commits from fix/runner-install-repo-guard into main 2026-07-13 20:00:46 +00:00
dan-claude-bot commented 2026-07-13 14:57:55 +00:00 (Migrated from github.com)

Closes #13.

rig runner install --repo <B> on a box already registered to repo A treated the mere existence of .runner as "already registered": it skipped configure, started 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 a runner that will never come.

The fix

Gate install on the repo .runner actually names, before anything is prompted for, downloaded, or started:

$ rig runner install --repo acme/beta
rig-runner: ERROR: this box's runner is already registered to https://github.com/acme/alpha, not https://github.com/acme/beta.
install will not move a runner between repositories: it would leave the service
running against the OLD repo and report success. To move it in one act:
  rig runner repoint --repo acme/beta
or take it off the old repo first, then install:
  rig runner remove             (deregisters from https://github.com/acme/alpha; needs a removal token)
  rig runner remove --local     (when you cannot mint one)

Convergence — the property worth keeping — is untouched. Re-running install against the repo the box is already on still re-uses the binary, skips registration, never asks for a token, and exits 0. Skipping when the repo differs was never convergence, only a silently ignored argument.

An .runner rig cannot read is refused as well: it is no licence to assume a match.

Shape of the change

  • commands/lib/runner-config.sh (new) — the .runner reader that status and repoint each carried a copy of, now in one place, plus the assert_runner_repo guard. Sourced, never executed (mode 644); install.sh already ships the whole tree, so it lands on boxes as-is.
  • runner-install.sh — calls the guard between the root check and the token prompt. The REG_PENDING logic is otherwise unchanged.
  • runner-status.sh / runner-repoint.sh — use the shared reader; no behavior change.
  • READMEinstall is documented as convergent toward --repo, and the repoint section no longer describes install as convergent-by-skipping.

One incidental fix that falls out of sharing the reader: 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 had always assumed but never got.

repoint is unaffected: it deregisters (removing .runner) before calling install, so the guard sees an unregistered box and passes.

Tests

test/cli.sh gains the guard, 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 assertion that the guard precedes svc.sh start, since ordering is the whole fix. Reaching it through the CLI would need root and a really-registered runner, which the dependency-free harness cannot fabricate; this is the closest honest thing to the end-to-end case the issue sketched, and it is why the ordering is asserted rather than trusted.

Verified by mutation — all three go red, and the suite is green (66/66) unmutated:

mutant result
guard call deleted (the original bug) caught
guard present but compares nothing caught (3 checks)
guard moved below svc.sh start caught

shellcheck -x is clean across the tree.

Left alone, deliberately

The issue's "skim the other commands for the same shape" turned up two lesser instances in install, both out of scope here — neither is a trust boundary, and both are currently documented as intended:

  • --version is ignored when a runner binary is already present (deliberate: the runner self-updates regardless, so the pin is a starting point — the README says so).
  • --name / --labels are ignored on an already-registered box, since configure is skipped. This one is a real, if quieter, silently-ignored argument — a --labels that does not take is a workflow that stops matching its runs-on. Happy to file it as a follow-up.

🤖 Generated with Claude Code

Closes #13. `rig runner install --repo <B>` on a box already registered to repo **A** treated the mere existence of `.runner` as "already registered": it skipped `configure`, started 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 a runner that will never come. ## The fix Gate `install` on the repo `.runner` actually names, before anything is prompted for, downloaded, or started: ``` $ rig runner install --repo acme/beta rig-runner: ERROR: this box's runner is already registered to https://github.com/acme/alpha, not https://github.com/acme/beta. install will not move a runner between repositories: it would leave the service running against the OLD repo and report success. To move it in one act: rig runner repoint --repo acme/beta or take it off the old repo first, then install: rig runner remove (deregisters from https://github.com/acme/alpha; needs a removal token) rig runner remove --local (when you cannot mint one) ``` **Convergence — the property worth keeping — is untouched.** Re-running `install` against the repo the box is *already* on still re-uses the binary, skips registration, never asks for a token, and exits 0. Skipping when the repo *differs* was never convergence, only a silently ignored argument. An `.runner` rig cannot read is refused as well: it is no licence to assume a match. ## Shape of the change - **`commands/lib/runner-config.sh`** (new) — the `.runner` reader that `status` and `repoint` each carried a copy of, now in one place, plus the `assert_runner_repo` guard. Sourced, never executed (mode 644); `install.sh` already ships the whole tree, so it lands on boxes as-is. - **`runner-install.sh`** — calls the guard between the root check and the token prompt. The `REG_PENDING` logic is otherwise unchanged. - **`runner-status.sh` / `runner-repoint.sh`** — use the shared reader; no behavior change. - **README** — `install` is documented as convergent *toward `--repo`*, and the `repoint` section no longer describes `install` as convergent-by-skipping. One incidental fix that falls out of sharing the reader: `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 had always assumed but never got. `repoint` is unaffected: it deregisters (removing `.runner`) before calling `install`, so the guard sees an unregistered box and passes. ## Tests `test/cli.sh` gains the guard, 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 assertion that the guard *precedes* `svc.sh start`, since ordering is the whole fix. Reaching it through the CLI would need root **and** a really-registered runner, which the dependency-free harness cannot fabricate; this is the closest honest thing to the end-to-end case the issue sketched, and it is why the ordering is asserted rather than trusted. Verified by mutation — all three go red, and the suite is green (66/66) unmutated: | mutant | result | | --- | --- | | guard call deleted (the original bug) | ✅ caught | | guard present but compares nothing | ✅ caught (3 checks) | | guard moved below `svc.sh start` | ✅ caught | `shellcheck -x` is clean across the tree. ## Left alone, deliberately The issue's "skim the other commands for the same shape" turned up two lesser instances in `install`, both **out of scope** here — neither is a trust boundary, and both are currently documented as intended: - `--version` is ignored when a runner binary is already present (deliberate: the runner self-updates regardless, so the pin is a *starting* point — the README says so). - `--name` / `--labels` are ignored on an already-registered box, since `configure` is skipped. This one is a real, if quieter, silently-ignored argument — a `--labels` that does not take is a workflow that stops matching its `runs-on`. Happy to file it as a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#14
No description provided.