Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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