feat(infra): provision the poller identity
All checks were successful
deploy / deploy (push) Successful in 5m35s

Stage 2 writes the first label, and nothing about the label protocol can be
dogfooded until an identity exists that is allowed to. Provisioned now rather
than deferred.

  account   tireless-poller (tireless-poller@lair.cafe), no ssh key — it never
            clones anything
  team      lair/tireless-poller: Issues=write, PullRequests=read, Code=none,
            everything else none, scoped to named repos
  token     write:issue, read:repository, read:user

This is the one place an org team earns its keep. Collaborator permissions in
Gitea are repo-wide, so granting issue write that way would hand this account
code push too — the unit-level team grants labelling without it.

The units now load different environment files, and that is not tidiness. The
runner spawns coding agents as subprocesses, and subprocesses inherit the
environment, so a label-capable token in the runner's environment is a token
every agent run can read. poller.env is loaded only by tireless-poller.service,
which spawns nothing.

Be precise about what that buys: the separation is between processes, not users.
Both units run as `tireless`, so the runner's uid can still read poller.env even
though its process never loads it. An agent would have to go looking rather than
find it handed over in `env` — a real difference, not a boundary. Closing it
means separate service accounts or LoadCredential=; recorded as a stage 8 item so
it is a known gap rather than an assumed guarantee.

Verified each identity can do its own job and not the other's:

              tireless   tireless-poller
  add label      403          200
  fork repo      works        403
  read issues    200          200

One measurement caveat recorded in §6.4: the poller can still read repository
contents despite Code=none, because lair/tireless is public. The unit permission
bites on private repos — do not read that as the grant being wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
rob thijssen
2026-08-07 18:31:59 +03:00
parent 8586da2cb9
commit 9deeaaa9c2
3 changed files with 80 additions and 21 deletions

View File

@@ -9,7 +9,16 @@ Type=exec
User=tireless
Group=tireless
Environment=HOME=/var/lib/tireless
EnvironmentFile=-/etc/tireless/tireless.env
# Its OWN environment file, not the runner's. This carries the one credential in
# the system that can write a label, and it must not reach the runner: the runner
# spawns coding agents as subprocesses, which inherit its environment, so a token
# there is a token every agent run can read.
#
# The separation is between processes, not between users — both units run as
# `tireless`, so this file is readable by the runner's uid even though its
# process never loads it. Closing that gap properly means either separate service
# accounts or systemd LoadCredential=; see doc/plan/design.md §6.4.
EnvironmentFile=-/etc/tireless/poller.env
ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml poll
Restart=on-failure
RestartSec=30s

View File

@@ -611,15 +611,44 @@ writes a label. It records state in Postgres, which §2.2 already makes the
authority, and a separate identity reconciles labels onto the forge. That maps
onto the split the two units already have (§2.1):
| Unit | Identity | Forge access |
| Unit | Identity | Forge access | Holds |
| --- | --- | --- | --- |
| `tireless-runner` | `tireless` | no grant at all; pushes only to its own fork | agent login, ssh key |
| `tireless-poller` | `tireless-poller` | issue write via `lair/tireless-poller` team, code `none` | nothing else |
Verified as built, each identity able to do its own job and not the other's:
| | `tireless` | `tireless-poller` |
| --- | --- | --- |
| `tireless-runner` | `tireless` | none beyond public read; pushes only to its own fork |
| `tireless-poller` | separate | issue write, and no code access at all |
| apply / remove a label | **403** | 200 / 204 |
| fork a repository | works | **403** |
| read issues | 200 | 200 |
The token that can touch issues is held by the process that never runs an agent;
the token held by the process running unattended agents can only push to a
repository nobody depends on. Neither half can do the other's damage.
**The two units load different environment files**`/etc/tireless/poller.env`
and `/etc/tireless/tireless.env` — and that is not tidiness. The runner spawns
coding agents as subprocesses, and subprocesses inherit the environment, so a
label-capable token in the runner's environment is a token every agent run can
read. Keeping it out of that environment is the point.
Being precise about what that does and does not achieve: the separation is
between *processes*, not between *users*. Both units run as `tireless`, so the
runner's uid can read `poller.env` even though its process never loads it. An
agent would have to go looking for it rather than find it handed over in `env`,
which is a meaningful difference but not a boundary. Closing it properly means
either separate service accounts or systemd `LoadCredential=`, which puts the
credential in a per-unit tmpfs no other unit can read. Deferred to stage 8; noted
here so it is a known gap rather than an assumed guarantee.
One measurement caveat worth recording, since it will mislead whoever checks:
the poller's team grants code `none`, yet it can still read repository contents.
That is because `lair/tireless` is public — the unit permission bites on private
repositories. Do not read a successful content fetch as evidence the grant is
wrong.
It also removes a latent silent failure. Creating an issue *with* labels as an
unprivileged user returns **`201` and drops the labels** — no error, no warning,
an issue that simply never gets picked up. A Plan job stamping inherited
@@ -629,17 +658,21 @@ labels recorded in Postgres, and the poller applies them on the next reconcile.
The cost is that a child is not admitted until the next poll, which is a
scheduling delay of minutes and visible in the dashboard.
The second identity is provisioned when stage 2 first writes a label; nothing
before then needs it.
Credentials, with different jobs and different blast radii:
- an **ssh key** (`/var/lib/tireless/.ssh/id_ed25519`, 0600, on the account) for
git transport;
- an **API token** scoped `write:issue`, `write:repository`, `read:user`, in
`/etc/tireless/tireless.env` (0640 root:tireless). The `write:issue` scope is a
ceiling on what the token may attempt, not a grant — the account still has no
issue write, so labelling stays denied. Scope narrows; it never widens.
| Credential | Held by | Path |
| --- | --- | --- |
| ssh key, on the `tireless` account | runner | `/var/lib/tireless/.ssh/id_ed25519`, 0600 |
| API token, `write:repository` + `read:user` | runner | `/etc/tireless/tireless.env`, 0640 root:tireless |
| API token, `write:issue` + `read:repository` + `read:user` | poller | `/etc/tireless/poller.env`, 0640 root:tireless |
| Claude Code subscription login | runner | `/var/lib/tireless/.claude.json`, 0600 — stat'd, never read |
The poller has no ssh key and no git access of any kind; it talks only to the
forge API and to Postgres.
Token scope is a ceiling on what a token may attempt, never a grant. The runner's
token could carry `write:issue` and labelling would still be denied, because the
account behind it has no issue write. Scope narrows; permission is what widens.
`known_hosts` is pre-seeded for the forge, because an unattended git must not
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered

View File

@@ -353,15 +353,32 @@ Remaining one-time steps (operator, on the target host):
protection for your own workflow if you want it; it is no longer what stands
between an unattended agent and `main`.
LABELLING IS A SEPARATE IDENTITY, and does not exist yet -- provision it with
stage 2, which is when the first label is written. Applying a label is the one
§2.2 capability an unprivileged account cannot do, and it must not be bought
by granting the runner code write. The poller reconciles labels from Postgres
under its own account; the runner never writes one.
LABELLING IS A SEPARATE IDENTITY: `tireless-poller`. (Done 2026-08-07.)
Applying a label is the one §2.2 capability an unprivileged account cannot do,
and it must not be bought by granting the runner code write.
Beware when you build it: creating an issue WITH labels as an unprivileged
user returns 201 and silently drops the labels. No error, no warning, an issue
that never gets picked up.
- account `tireless-poller` (tireless-poller@lair.cafe), no ssh key -- it
never clones anything;
- org team `lair/tireless-poller`: Issues=write, PullRequests=read,
Code=none, everything else none, scoped to named repos;
- API token scoped `write:issue`, `read:repository`, `read:user`, in
/etc/tireless/poller.env (0640 root:tireless).
The env file is SEPARATE from the runner's on purpose. The runner spawns
coding agents as subprocesses, and subprocesses inherit the environment, so a
label-capable token in the runner's environment is one every agent run can
read. tireless-poller.service loads poller.env; tireless-runner.service loads
tireless.env. Do not merge them.
Confirm the split, rather than assume it:
runner token, add label 403
poller token, add label 200
poller token, fork a repo 403
Beware when you build stage 2: creating an issue WITH labels as an
unprivileged user returns 201 and silently drops the labels. No error, no
warning, an issue that never gets picked up. The runner creates children bare
and records their intended labels in Postgres; the poller applies them.
3. Confirm the proxy is serving the cert that is on disk.
The vhost, its cert and the split-horizon DNS were installed above, but