vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: a runner can narrow what it takes, and cannot widen it
b238ac2448290fb706d0503c2e65798fc618f994 · Keenan Nemetz · 16d ago · parent 8d336083
Phase 4: a runner can narrow what it takes, and cannot widen it `vole runner --repos name` (repeatable) restricts a session to the repositories it names. The restriction is applied by the server as an intersection with what the account's runner allowlist grants, which is the whole design: the flag is a filter, not a grant, so the worst a client can do is run less than it was allowed to. Honouring the client's list directly would have made an SSH flag an access-control decision. An empty filter means "no narrowing" rather than "nothing", which keeps an unfiltered runner following allowlist edits instead of freezing its list at connect time. The filter has a failure mode with no symptom: a runner whose --repos matches nothing connects, waits, and never sees work, which looks exactly like a quiet instance. So "ping" now reports the account's grant and the runner refuses at startup, naming what it asked for and what it was allowed — the same class of check as the signing key, cheaper before a build than after one that never happened. An account with an empty grant and no filter is only a warning, because an allowlist nobody has written yet takes effect without a restart. runs.Store.Lease now takes a LeaseRequest struct. It gained a second list of names, and two adjacent []string arguments are exactly the shape that gets transposed in silence. Measured: TestARequestCannotLeaseBeyondTheAllowlist — a repository the account may *write* to but is not a runner for is refused even when asked for by name, and an admin can still lease it. TestARepoFilterTakesOnlyWhatItNames, TestNarrowCanOnlySubtract, TestRepoFilterThatCanNeverMatchIsRefused and TestARepoFilterOutsideTheGrantIsRefusedAtStartup cover the rest.
docs/roadmap.md +28−4
| @@ -534,12 +534,35 @@ work": both repositories served once, a third runner refused, a finish wakes the | |||
| 534 | 534 | parked lease and the freed slot's own repository is leasable; and | |
| 535 | 535 | `TestRunnersPageSaysWhenARepositoryIsAtItsCap`.) | |
| 536 | 536 | ||
| 537 | ✅ **Slice 11 — a runner can narrow what it takes.** `vole runner --repos name` | ||
| 538 | (repeatable) restricts a session to the repositories it names, and the restriction is | ||
| 539 | applied by the *server* as an intersection with what the account's runner allowlist | ||
| 540 | grants. That direction is the whole design: the flag is a filter, not a grant, so the | ||
| 541 | worst a client can do is run less than it was allowed to. An empty filter means "no | ||
| 542 | narrowing" rather than "nothing", which is what keeps an unfiltered runner following | ||
| 543 | allowlist edits instead of freezing its list at connect time. | ||
| 544 | |||
| 545 | The filter also has a failure mode with no symptom — a runner whose `--repos` matches | ||
| 546 | nothing connects, waits, and never sees work, which looks exactly like a quiet instance | ||
| 547 | — so `ping` now reports the account's grant and the runner refuses at startup, naming | ||
| 548 | the names it asked for and the ones it was allowed. That is the same class of check as | ||
| 549 | the signing key: cheaper to find before a build than after one that never happened. An | ||
| 550 | account whose grant is empty *without* a filter is only a warning, because an allowlist | ||
| 551 | somebody has not written yet takes effect without a restart. | ||
| 552 | |||
| 553 | `runs.Store.Lease` took a `LeaseRequest` struct as part of this. It gained a second list | ||
| 554 | of names, and two adjacent `[]string` arguments are precisely the shape that gets | ||
| 555 | transposed in silence. | ||
| 556 | (Measured: `TestARequestCannotLeaseBeyondTheAllowlist` — a repository the account may | ||
| 557 | *write* to but is not a runner for is refused even when asked for by name, and an admin | ||
| 558 | can still lease it; `TestARepoFilterTakesOnlyWhatItNames`, `TestNarrowCanOnlySubtract`, | ||
| 559 | `TestRepoFilterThatCanNeverMatchIsRefused`, | ||
| 560 | `TestARepoFilterOutsideTheGrantIsRefusedAtStartup`.) | ||
| 561 | |||
| 537 | 562 | Remaining: | |
| 538 | 563 | ||
| 539 | - A narrowing filter on the runner (`--repos`, labels) and job-level targeting | ||
| 540 | (`runs-on`): one instance-wide pool is right for identical machines and wrong for one | ||
| 541 | macOS runner in a Linux pool. The filter can only narrow what the allowlist grants, | ||
| 542 | never widen it | ||
| 564 | - Job-level targeting (`runs-on`) and runner labels: one instance-wide pool is right | ||
| 565 | for identical machines and wrong for one macOS runner in a Linux pool | ||
| 543 | 566 | ||
| 544 | 567 | **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in | |
| 545 | 568 | the job being requeued and completing on another runner ✅; a workflow that mutates its | |
| @@ -1041,6 +1064,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 1041 | 1064 | | 2026-10-23 | Runner presence is a queue row per channel, filtered by the clone rule | A runner was an SSH channel invisible to both the web and the queue, so "is my build waiting because nobody is listening" had no answer anywhere. Presence is derived rather than stored: written by connect, lease, heartbeat and finish, dropped when the channel closes, aged out after ten silent minutes. The web is anonymous, so a session appears only while the repository it is building is public — naming a private repository would confirm it exists | | |
| 1042 | 1065 | | 2026-10-23 | A record names the machine (OS, architecture), not the toolchain | The roadmap asked for the runner's Go version too, and it is the one field here that would mislead: `runtime.Version()` reports what compiled the *runner binary*, not what a job builds with, so in a signed record it would be a confident answer to a question nobody asked. OS and architecture describe the machine, which is the fact worth keeping — it is what turns "one macOS runner in a Linux pool" into something a finished run can answer. Both are the binary's build target rather than a live probe: a `uname` subprocess would add a failure path on Windows to sharpen a fact the binary already knows, and the only case it would correct is emulation. The keys are optional so records written before them still read, since a record is never rewritten | | |
| 1043 | 1066 | | 2026-10-23 | A repository may be bounded with `max_jobs_per_repo`, and finishing a job wakes the lease | Ordering and bounding answer different questions: ordering (least busy repository first) keeps one repository's burst from taking the whole fleet, and a cap is what bounds one repository's footprint on purpose. Enforcing it as a skip in the lease rather than a refusal keeps the other repositories an account may run serviceable, which makes `nil` mean two things — "nothing to do" and "work, but not for you yet" — and a cap-bound queue is exactly where the second meaning would hang forever, since a parked runner has nothing to wake it. So `Finish` notifies as well: a released slot is leasable work. The runners page renders `4 of 4 (at the cap)` for the same reason, because a depth that is full and not draining with no explanation is the question that page exists to answer | | |
| 1067 | | 2026-10-23 | A runner's `--repos` filter is applied by the server, as an intersection with the allowlist | A filter and a grant are different things: `--repos` says what one machine is *for*, and the runner allowlist says what its account *may do*. Applying the filter server-side, against the grant, means the client's list can only shrink the answer — if the server honoured the request list directly, an SSH flag would have become an access-control decision, and the first person to notice would be whoever read the flag's name and assumed otherwise. An empty filter means "no narrowing" rather than "nothing" so an unfiltered runner keeps following allowlist edits instead of freezing at connect time. The cost is a filter that matches nothing, which presents as a healthy idle runner, so `ping` reports the grant and the runner refuses at startup instead. `Lease` consequently takes a struct: it now holds two lists of names, and adjacent `[]string` arguments are the shape that transposes silently | | ||
| 1044 | 1068 | ||
| 1045 | 1069 | ## Open questions | |
| 1046 | 1070 | ||