feat(infra): provision the bot account, and move to a fork-based PR model
All checks were successful
deploy / deploy (push) Successful in 5m36s

The design had tireless pushing `tireless/*` branches into the repo it was
working on, with branch protection denying it `main`. It now works the way an
outside contributor does: pushes only to its own fork, reaches the real
repository through a pull request, and has no write access to that repository at
all.

That is a stronger guarantee than the one it replaces. Branch protection is a
rule that can be edited, applies per repo, and is easy to forget on the next repo
added; a bot with no push permission cannot write to `main` whatever anyone
forgets.

The wrinkle is the label protocol. Labels are a write on the issues unit, and
Gitea collaborator permissions are repo-wide, so granting issue write as a
collaborator would hand back the code push the fork exists to avoid. Access is
therefore an org team with unit-level permissions -- lair/tireless-agent, with
Issues=write, PullRequests=write, Code=read, everything else none, scoped to
named repositories rather than the whole org.

Provisioned and verified end to end rather than assumed:

  push to the fork            succeeds
  push to lair/tireless       "User permission denied for writing."
  label an issue              200 / 204
  cross-repo PR from fork     opened as `tireless`
  delete the repository       403

Two credentials with different blast radii: an ssh key on the account for git
transport, and an API token scoped write:issue + write:repository + read:user for
issues and PRs. known_hosts is pre-seeded, because an unattended git must not
prompt and accept-new would trust whatever answered first.

Stage 4 gains the consequence: two remotes, and a fork that has to be checked for
staleness before branching.

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:18:31 +03:00
parent ae285f5597
commit b1e01777ff
2 changed files with 100 additions and 23 deletions

View File

@@ -564,19 +564,63 @@ not be able to reach each other's state.
Cloning from a local path hardlinks objects rather than copying them, so a job Cloning from a local path hardlinks objects rather than copying them, so a job
clone is fast and near-free on disk regardless of repo size — git never mutates clone is fast and near-free on disk regardless of repo size — git never mutates
an existing object, so the hardlinks are safe. `origin` is then repointed at the an existing object, so the hardlinks are safe.
real remote, because the mirror is a cache, not the truth.
Branches are namespaced `tireless/<issue>-<slug>` so forge branch protection can **Work happens in a fork, and the two remotes are not interchangeable.** After
permit the bot there and nowhere else. cloning from the mirror, a job carries:
### 6.4 Identity | Remote | Points at | Used for |
| --- | --- | --- |
| `upstream` | `<owner>/<repo>` | fetching the base branch. **Never pushed to.** |
| `origin` | `tireless/<repo>` | pushing the work branch |
A dedicated `tireless` Gitea account, not the operator's. Its token is scoped to Branch from `upstream/<default>`, push to `origin`, open the pull request across
issue and PR write; branch protection on each repo's default branch denies it repositories with a head of `tireless:<branch>`. Branches stay namespaced
push. Three benefits: the audit trail distinguishes agent work from human work; `tireless/<issue>-<slug>`, now for legibility in a PR list rather than to give
you can meaningfully review a PR you did not author; and revoking the agent does branch protection something to match.
not touch your own credentials.
### 6.4 Identity and access
A dedicated `tireless` Gitea account, not the operator's — so the audit trail
distinguishes agent work from human work, you can meaningfully review a PR you
did not author, and revoking the agent does not touch your own credentials.
**The bot has no write access to any repository it works on.** That is the point
of the fork: it pushes only to `tireless/<repo>`, which is its own, and reaches
the real repository through a pull request like any outside contributor.
What it does need is the ability to run the label protocol (§2.2), and labels are
a *write* on the issues unit. Granting that as blanket collaborator write would
hand it code push as well, which the fork exists to avoid. So access is a Gitea
**org team with unit-level permissions**, `lair/tireless-agent`:
| Unit | Grant | Why |
| --- | --- | --- |
| Issues | **write** | labels, comments, and creating plan children — the protocol does not work without it |
| Pull requests | write | opening the PR from the fork |
| Code | read | fetching the base branch |
| everything else | none | wiki, releases, packages, projects, actions |
The team is scoped to named repositories rather than the whole org, so adding a
repo to tireless's remit is an explicit act.
This is stronger than the branch-protection scheme this section described
before. Branch protection is a rule that can be edited, per repo, and is easy to
forget on the next repo added; a bot that simply has no push permission cannot
write to `main` whatever anyone forgets. Verified as built: pushing to the fork
succeeds, pushing to `lair/tireless` returns `User permission denied for
writing.`, labelling an issue succeeds, and deleting the repository is refused.
Two 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), for issues and PRs.
`known_hosts` is pre-seeded for the forge, because an unattended git must not
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered
first.
### 6.5 systemd hardening ### 6.5 systemd hardening
@@ -695,9 +739,19 @@ thread; a bad implementation is a branch. Start where mistakes are cheapest.
Mirror cache, per-job clone, branch, commit, push, open PR. Wire the Claude Code Mirror cache, per-job clone, branch, commit, push, open PR. Wire the Claude Code
implementation path. Idempotent re-runs against existing branches and PRs. implementation path. Idempotent re-runs against existing branches and PRs.
*Done when:* an issue labelled `tireless/implement` yields a reviewable PR from a Two remotes, not one (§6.3): fetch the base from `upstream`, push the work branch
protected-branch-respecting bot account, and re-running the job updates rather to `origin` — the bot's fork — and open the pull request across repositories with
than duplicates. a head of `tireless:<branch>`. Ensure the fork exists and is not stale before
branching; a fork whose default branch has drifted produces a PR full of
unrelated commits.
*Done when:* an issue labelled `tireless/implement` yields a reviewable PR opened
by the bot account from its own fork, and re-running the job updates rather than
duplicates.
The credentials and access model this depends on are already provisioned and
verified (§6.4) — the bot cannot push to a `lair` repository at all, so this
stage cannot accidentally write to one.
### Stage 5 — OpenCode executor ### Stage 5 — OpenCode executor

View File

@@ -321,18 +321,41 @@ Remaining one-time steps (operator, on the target host):
ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both: ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both:
the API key silently wins, and the subscription goes unused. the API key silently wins, and the subscription goes unused.
2. Gitea bot account. 2. Gitea bot account. (Done for git.lair.cafe on 2026-08-07.)
Create a dedicated `tireless` user on git.lair.cafe (not your own account), A dedicated `tireless` user, not your own account, holding two credentials:
generate a token scoped to issue + PR write, and put it in
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless).
Then, for each repo tireless should work on: - ssh key at /var/lib/tireless/.ssh/id_ed25519 (0600 tireless:tireless),
- add `tireless` as a collaborator with write access; registered on the account, for git transport. The private half is kept at
- enable branch protection on the default branch, denying `tireless` push; ~/.ssh/id_tireless on the operator workstation.
- confirm it can still push refs matching `tireless/*`. - API token scoped `write:issue`, `write:repository`, `read:user`, in
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless), for
issues and pull requests.
The protection rule is what keeps an unattended agent from writing to main. Seed known_hosts for the forge at the same time. An unattended git must not
Verify it rather than assuming it. prompt, and StrictHostKeyChecking=accept-new would trust whatever answered.
Access is a Gitea ORG TEAM with unit-level permissions -- `lair/tireless-agent`:
Issues=write, PullRequests=write, Code=read, everything else none, scoped to
named repositories rather than the whole org.
Do NOT add `tireless` as a collaborator with write access. Collaborator
permissions in Gitea are repo-wide, so "write" to run the label protocol also
grants code push, which is exactly what the fork model exists to avoid. The
unit-level team grants issue write WITHOUT code write.
For each repo tireless should work on:
- add the repo to the `tireless-agent` team;
- fork it to the `tireless` user (the runner pushes only to its fork);
- confirm, rather than assume:
push to the fork succeeds
push to the upstream repo "User permission denied for writing."
labelling an issue succeeds
deleting the repo refused
Note what is NOT needed: branch protection for the bot. It has no push
permission at all, so there is no rule to forget on the next repo you add.
Keep branch protection for your own workflow if you want it; it is no longer
what stands between an unattended agent and `main`.
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