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
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
real remote, because the mirror is a cache, not the truth.
an existing object, so the hardlinks are safe.
Branches are namespaced `tireless/<issue>-<slug>` so forge branch protection can
permit the bot there and nowhere else.
**Work happens in a fork, and the two remotes are not interchangeable.** After
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
issue and PR write; branch protection on each repo's default branch denies it
push. Three benefits: 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.
Branch from `upstream/<default>`, push to `origin`, open the pull request across
repositories with a head of `tireless:<branch>`. Branches stay namespaced
`tireless/<issue>-<slug>`, now for legibility in a PR list rather than to give
branch protection something to match.
### 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
@@ -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
implementation path. Idempotent re-runs against existing branches and PRs.
*Done when:* an issue labelled `tireless/implement` yields a reviewable PR from a
protected-branch-respecting bot account, and re-running the job updates rather
than duplicates.
Two remotes, not one (§6.3): fetch the base from `upstream`, push the work branch
to `origin` — the bot's fork — and open the pull request across repositories with
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