Sovereign review runner¶
How to stand up, verify and remove the self-hosted runner that runs pr-review.yml's
review-sovereign job on [self-hosted, vibey-local-vibey] (sub-doctrine 8.a). Everything
on the host is rendered from the [runners] table in .vibey-gh.toml by vibey-gh runner
(sub-doctrine 12.c). Nothing here is hand-written, so this page is the whole procedure.
The runner is macOS-only: a launchd user agent keeps a bash supervisor alive, and the supervisor starts one ephemeral runner container per job.
What it needs¶
| Thing | Where it comes from |
|---|---|
| Docker Desktop, running | the host |
ollama serve on [pr_automation.fallback] base_url, with its model pulled |
the host |
| A fine-grained token for this repository only, Administration: Read and write | the operator, step 1 |
That token in a file-based gh login in ~/.config/gh-runner |
the operator, step 2 |
| The LaunchAgent, supervisor, Dockerfile and entrypoint | vibey-gh runner install, step 4 |
Why a dedicated credential¶
The supervisor mints a registration token per job, which needs a durable GitHub credential.
The operator's own gh login keeps its token in the macOS keyring, and a LaunchAgent cannot
read the keyring: gh auth status passes in every shell and fails under launchd, so the
runner stayed down with nothing visibly wrong. That login can also administer every
repository the operator owns, which is far more than a runner needs.
So the runner has a login of its own. It is set as GH_CONFIG_DIR in the LaunchAgent and
stored in a file by gh auth login --insecure-storage, and it holds a token that can do
nothing but manage this repository's runners. The supervisor refuses to start, and logs the
command that fixes it, if that directory is unset or missing, if it holds no login, if its
token went to the keyring, if its hosts.yml is readable by other users, or if GitHub
rejects the token. It also refuses a GH_CONFIG_DIR that resolves (through symlinks and
..) to gh's own default directory, $XDG_CONFIG_HOME/gh or ~/.config/gh; so does
vibey-gh when it loads the configuration. It clears GH_TOKEN and GITHUB_TOKEN, names
the configured host on every gh call, and never falls back to any other credential. It
never prints the token.
The token's permission¶
Exactly one repository permission: Administration: Read and write. GitHub's permissions for fine-grained personal access tokens list, under "Repository permissions for Administration":
| Endpoint | What the supervisor uses it for | Access |
|---|---|---|
POST /repos/{owner}/{repo}/actions/runners/registration-token |
mint a registration token per job | write |
GET /repos/{owner}/{repo}/actions/runners |
find offline runners to reap | read |
DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} |
reap them | write |
GitHub adds read-only Metadata to every fine-grained token. Add nothing else.
Stand it up¶
Run these from a checkout of this repository on develop, so vibey-gh reads its
.vibey-gh.toml.
- Create the token. On GitHub: Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
- Resource owner:
the-vibey-project. If the organization requires approval for fine-grained tokens, the token works only after an owner approves it. - Expiration: set one, and note the date. When it passes, the supervisor refuses with "not accepted by github.com" until step 2 is repeated with a new token.
- Repository access: Only select repositories →
the-vibey-project/vibey. -
Repository permissions: Administration → Read and write.
-
Give the runner its own login. Paste the token when gh waits, press Enter, then Ctrl-D.
bash
mkdir -m 700 -p ~/.config/gh-runner
env -u GH_TOKEN -u GITHUB_TOKEN GH_CONFIG_DIR=$HOME/.config/gh-runner \
gh auth login --hostname github.com --with-token --insecure-storage
chmod 600 ~/.config/gh-runner/hosts.yml
env -u keeps an exported GH_TOKEN from standing in for the token you paste. Your own
gh login in ~/.config/gh is not touched.
- Retire the agents of the absorbed repositories. They run the same
vibey-runner.shthe install replaces, so retire them first. Read the dry run: it lists every agent under[runners] unit_prefixthat the tree does not declare, with the repository each one serves.
bash
uv run vibey-gh runner cleanup
uv run vibey-gh runner cleanup --apply
--apply boots each one out of launchd and moves its plist to
~/.local/share/vibey-runner/retired-units/. Nothing is deleted, and an earlier retired
copy is never replaced: a second copy of the same agent is kept as <name>.1.plist, then
.2, and so on. To put one back:
mv ~/.local/share/vibey-runner/retired-units/<name>.plist ~/Library/LaunchAgents/, then
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/<name>.plist.
- Render and install the runner's files. This loads nothing. It prints the same commands as the steps below, with this machine's paths filled in.
bash
uv run vibey-gh runner install
- Build the runner image. The version comes from
[runners] runner_version.
bash
docker build --build-arg RUNNER_VERSION=2.337.0 -t vibey-runner:latest ~/.local/share/vibey-runner
- Load the runner's agent. This replaces the running
-vibeyagent in place.
bash
launchctl bootout gui/$(id -u)/com.adammatthewsteinberger.vibey-runner-vibey 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.adammatthewsteinberger.vibey-runner-vibey.plist
uv run vibey-gh runner install --load does steps 4 and 6 together, and exits non-zero
if launchd refuses to load the agent.
The two manual commands above race: bootout returns while launchd is still stopping the
old supervisor (it removes the egress gate and reaps its runners), and a bootstrap in that
window fails with exit 5, "Input/output error". --load waits for the label to disappear and
retries exit 5, for at most [runners] launchd_settle_seconds (default 60). If you run the
commands by hand, wait until launchctl print gui/$(id -u)/<label> stops answering before
the bootstrap. Do not run the installer under sudo: root cannot bootstrap into your
login domain (exit 125).
The egress gate¶
The job container has no route out. vibey-runner.sh creates a Docker network with
--internal ([runners] egress_network) and starts one small container on it and on the
default network, [runners] egress_name (vibey-egress). The job joins only the internal
network, so the gate is the only thing it can reach:
- HTTPS to
egress_allowonly, on port 443, through aCONNECTproxy (HTTPS_PROXY=http://vibey-egress:3128). The gate resolves the name itself and refuses any address that is not globally routable, so a name that points at this machine or its LAN is refused whatever it is called. An address literal is never an allowed host. - The model server's four endpoints, on
http://vibey-egress:11434:GET /api/version,GET /api/tags,GET /v1/models,POST /v1/chat/completions. A job cannot pull, create or delete a model on this machine, and cannot reach any other service on it.
Why: --add-host host.docker.internal:host-gateway makes every port on this machine
reachable from the container, and a job's shell has full network egress (allow_network=False
in the agent runner sets an environment variable; it enforces nothing). On 2026-10-09 the
host's Postgres (5432) and RabbitMQ (5672, 15672) accepted connections from inside a runner
container. The gate is what makes "unreachable" true by construction.
To verify it on this machine (a throwaway network and gate, nothing of the runner's):
docker network create --internal egress-test
docker run -d --name egress-test --read-only --cap-drop ALL \
--add-host host.docker.internal:host-gateway \
-v ~/.local/share/vibey-runner/egress:/egress:ro \
-e EGRESS_ALLOW=github.com -e EGRESS_MODEL_UPSTREAM=host.docker.internal:11434 \
--entrypoint python3 vibey-runner:latest /egress/egress_gate.py
docker network connect egress-test egress-test
docker run --rm --network egress-test --entrypoint bash vibey-runner:latest -c '
(</dev/tcp/host.docker.internal/5432) 2>&1 | tail -1 # must fail
curl -s -m 5 --noproxy "*" https://1.1.1.1 || echo blocked # must be blocked
curl -s -m 5 -x http://egress-test:3128 https://github.com -o /dev/null -w "%{http_code}\n"
curl -s -m 5 -X DELETE http://egress-test:11434/api/delete -w "%{http_code}\n" # 403'
docker rm -f egress-test; docker network rm egress-test
When a job step fails on a host nobody listed, the gate says so. Every decision is one line on its standard output:
docker logs --since 30m vibey-egress | grep DENY
# egress DENY example.com:443 -- not on the allowlist (add the host to [runners] egress_allow)
What the log does and does not hold. It records where a request was going (a host and port, or
a method and path) and why it was refused, never a header, a body, URL credentials or a query
string. The destination is logged as the job asked for it, cut at 120 characters, so a job
that tries to smuggle data out inside a hostname (<encoded data>.evil.example) leaves that
attempt in the log: that is the log doing its work, and it stays on this machine. It is capped
so a runaway job cannot fill the disk: at most 600 ALLOW and 600 DENY lines a minute (a line
says how many were dropped), and the container's log is rotated at 5 MB x 3 files.
A DENY ... not on the allowlist line names the host to add to [runners] egress_allow (an
exact name, or *. and a domain you trust; see docs/configuration.md for what is refused).
The other reasons are not allowlist problems: resolves to an address that is not public
means the name points at this machine or a private network, which is refused whatever the
list says; the host's model server did not answer means Ollama is down.
After changing egress_gate, egress_allow or the gate's template, re-run
uv run vibey-gh runner install --load (the supervisor, and with it the gate, is restarted).
Verify¶
uv run vibey-gh runner check
tail -n 20 ~/Library/Logs/com.adammatthewsteinberger.vibey-runner-vibey.log
env -u GH_TOKEN -u GITHUB_TOKEN GH_CONFIG_DIR=$HOME/.config/gh-runner \
gh api --hostname github.com repos/the-vibey-project/vibey/actions/runners \
--jq '.runners[] | {name, status, busy, labels: [.labels[].name]}'
runner checkprints... matches the tree and its credential is usableand exits 0. "Usable" means GitHub accepted the token: it runsgh auth status --hostname github.comunder the runner'sGH_CONFIG_DIRwithGH_TOKENandGITHUB_TOKENunset, and prints none of gh's output. Otherwise it names eachmissing:,drift:,not executable:orcredential:problem.- The log shows
supervisor startingand thenregistering an ephemeral runner. A line startingREFUSING TO START:names what is missing and the command that fixes it. The log moved: it was~/Library/Logs/vibey-runner-vibey.log, and is now named after the agent. - The API lists a runner labelled
vibey-local-vibeywith"status": "online". The runner is ephemeral, so after each job it deregisters and the next one registers under a new name.
The workflow schedules the sovereign job only while the heartbeat is fresh. The heartbeat is
published by a timer that runner install installs beside the runner (vibey-gh heartbeat,
ADR-0060): <unit_prefix>-heartbeat-vibey, a LaunchAgent here. Each beat publishes only while
GitHub lists a runner labelled vibey-local-vibey as online and Ollama answers with the model,
and it goes through the pre-push gate, which lets an empty parentless commit on a non-branch
ref through by its own rule. The timer must run a vibey-gh installed outside any checkout, so
install it as a tool first and run the install with it:
uv tool install --force --from . vibey-engine
~/.local/bin/vibey-gh heartbeat install --load
~/.local/bin/vibey-gh heartbeat status
uv run vibey-gh sovereign # the heartbeat's age, as the workflow reads it
heartbeat status prints the last beat's age and whether it was published or withheld, and
why. The hand-written com.adammatthewsteinberger.vibey-local-authority agent that used to
publish the heartbeat is retired: it pushed with --no-verify and said "up" whenever its
supervisor had a live process. If it is still loaded, boot it out
(launchctl bootout gui/$(id -u)/com.adammatthewsteinberger.vibey-local-authority) before
loading the timer.
Does it catch defects?¶
A verdict from this runner is only worth what its recall is. The review canary measures it:
41 diffs against this repository's own code — 27 with one planted defect each, in nine
classes, and 14 clean controls — reviewed through vibey-gh local-review with every
[pr_automation.fallback] setting the pull-request review uses
(docs/architecture/evidence/review-canary/corpus.toml says how each was built).
review-canary.yml runs it weekly on GitHub-hosted runners, not this one (operator,
2026-10-03: every CI workflow on hosted runners): it serves the same model with Ollama on
the runners' CPU, cut into [pr_automation.review_canary] shards jobs that
vibey-gh review-canary merge joins into one measurement, and lands it as a pull request.
Every setting that decides a verdict is the one this runner reviews with; the deadline
rates are the hosted runners' own, declared beside the shard count. The silicon differs, so
a weekly measurement is of this configuration on CPU, not of this machine. A defect counts as caught only when the verdict
blocks and a finding is on the planted lines and names the defect's class. A review that
gives no verdict is counted apart, never as caught or missed.
uv run vibey-gh review-canary check # the corpus builds; offline
uv run vibey-gh review-canary show --case race-worker-build-lock
uv run vibey-gh review-canary run --work ~/git/vibey-storm/review-canary.work.jsonl
uv run vibey-gh review-canary status # exit 0 meets the floor, 1 not, 3 none
The floor is [pr_automation.review_canary] in .vibey-gh.toml. status also refuses a
measurement that is older than max_age_days, ran a subset, or ran under other settings.
Latest measurement: finished 2026-10-14T19:31:31.271706+00:00 on runnervmidgih, runnervmy3dvn, at commit 0b2cfd3dc3e0, over all 41 cases of corpus a89849815b0b (27 defects in 9 classes, 14 controls). Model gpt-oss:20b, think default, window 65536, reserve 16384, scope full, source context on.
| Measure | Result | 95% Wilson interval |
|---|---|---|
| Recall: blocked, on the planted lines, naming the class | 21 of 26 (80.8%) | 62.1% – 91.5% |
| Recall: blocked, on the planted lines | 21 of 26 (80.8%) | 62.1% – 91.5% |
| False positives: controls blocked | 2 of 13 (15.4%) | 4.3% – 42.2% |
| No verdict | 2 of 41 (model_timeout 2) | not counted either way |
| Class | Caught | No verdict |
|---|---|---|
inverted_condition |
3 of 3 | 0 |
missing_await |
3 of 3 | 0 |
off_by_one |
3 of 3 | 0 |
removed_guard |
3 of 3 | 0 |
resource_leak |
2 of 2 | 1 |
shared_state_race |
1 of 3 | 0 |
sql_injection |
2 of 3 | 0 |
swallowed_exception |
2 of 3 | 0 |
wrong_error_return |
2 of 3 | 0 |
Small samples: each interval is what this many cases can say, and is wide on purpose. Against the floor declared when it ran -- recall's lower bound at least 0.5, the false-positive rate's upper bound at most 0.5 -- this measurement meets it. Whether it still stands (its age, and settings changed since) is vibey-gh review-canary status's to say.
What the block does not say on its own:
- The corpus is small diffs. Every case is one file changed in one request. The canary
says nothing about large diffs, which the review splits into parts and on which, up to
2026-10-02, it had reached no verdict under #1316's settings. Why, and what would, is a
preregistered study in progress, mechanism and screening only
(
research/large-diff-review/experiments/). Production settings are unchanged until that study confirms a method. - A pass can contradict its own summary. In the first measurement, five planted defects
passed with no findings, and in two of them the summary named the defect while
passwas true. The gate readspass. - The matching rule is strict. By hand, two of the first measurement's misses were real catches the lexical rule did not credit, so recall by hand was 20 of 25. The floor reads the strict figure.
- One run. The first measurement ran once, on one host, and
review-canary.ymlhad not yet run on its schedule on 2026-10-02.
Remove it¶
uv run vibey-gh runner uninstall
uv run vibey-gh runner uninstall --apply
rm -rf ~/.config/gh-runner
uninstall boots the agent out, moves its plist to retired-units/, and deletes only the
three files install wrote; it also unloads the heartbeat timer and moves its plist to
retired-units/. Logs and the last beat's record stay. Then revoke the token on GitHub:
Settings → Developer settings → Personal access tokens → Fine-grained tokens.
Changing it¶
Edit [runners] (or [pr_automation.fallback]) in a pull request, then after it merges
repeat steps 4 and 6. uv run vibey-gh runner check reports a host that has not caught up
as drift:. The supervisor, Dockerfile and entrypoint templates live in
src/vibey_tools/gh/vibey_gh/templates/runner/. Do not edit the installed copies.