feat(infra): provision the poller identity
All checks were successful
deploy / deploy (push) Successful in 5m35s
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:
@@ -9,7 +9,16 @@ Type=exec
|
|||||||
User=tireless
|
User=tireless
|
||||||
Group=tireless
|
Group=tireless
|
||||||
Environment=HOME=/var/lib/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
|
ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml poll
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=30s
|
RestartSec=30s
|
||||||
|
|||||||
@@ -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
|
authority, and a separate identity reconciles labels onto the forge. That maps
|
||||||
onto the split the two units already have (§2.1):
|
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 |
|
| apply / remove a label | **403** | 200 / 204 |
|
||||||
| `tireless-poller` | separate | issue write, and no code access at all |
|
| 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 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
|
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.
|
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
|
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,
|
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
|
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
|
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.
|
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:
|
Credentials, with different jobs and different blast radii:
|
||||||
|
|
||||||
- an **ssh key** (`/var/lib/tireless/.ssh/id_ed25519`, 0600, on the account) for
|
| Credential | Held by | Path |
|
||||||
git transport;
|
| --- | --- | --- |
|
||||||
- an **API token** scoped `write:issue`, `write:repository`, `read:user`, in
|
| ssh key, on the `tireless` account | runner | `/var/lib/tireless/.ssh/id_ed25519`, 0600 |
|
||||||
`/etc/tireless/tireless.env` (0640 root:tireless). The `write:issue` scope is a
|
| API token, `write:repository` + `read:user` | runner | `/etc/tireless/tireless.env`, 0640 root:tireless |
|
||||||
ceiling on what the token may attempt, not a grant — the account still has no
|
| API token, `write:issue` + `read:repository` + `read:user` | poller | `/etc/tireless/poller.env`, 0640 root:tireless |
|
||||||
issue write, so labelling stays denied. Scope narrows; it never widens.
|
| 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
|
`known_hosts` is pre-seeded for the forge, because an unattended git must not
|
||||||
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered
|
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered
|
||||||
|
|||||||
@@ -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
|
protection for your own workflow if you want it; it is no longer what stands
|
||||||
between an unattended agent and `main`.
|
between an unattended agent and `main`.
|
||||||
|
|
||||||
LABELLING IS A SEPARATE IDENTITY, and does not exist yet -- provision it with
|
LABELLING IS A SEPARATE IDENTITY: `tireless-poller`. (Done 2026-08-07.)
|
||||||
stage 2, which is when the first label is written. Applying a label is the one
|
Applying a label is the one §2.2 capability an unprivileged account cannot do,
|
||||||
§2.2 capability an unprivileged account cannot do, and it must not be bought
|
and it must not be bought by granting the runner code write.
|
||||||
by granting the runner code write. The poller reconciles labels from Postgres
|
|
||||||
under its own account; the runner never writes one.
|
|
||||||
|
|
||||||
Beware when you build it: creating an issue WITH labels as an unprivileged
|
- account `tireless-poller` (tireless-poller@lair.cafe), no ssh key -- it
|
||||||
user returns 201 and silently drops the labels. No error, no warning, an issue
|
never clones anything;
|
||||||
that never gets picked up.
|
- 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.
|
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
|
The vhost, its cert and the split-horizon DNS were installed above, but
|
||||||
|
|||||||
Reference in New Issue
Block a user