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

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:
the API key silently wins, and the subscription goes unused.
2. Gitea bot account.
Create a dedicated `tireless` user on git.lair.cafe (not your own account),
generate a token scoped to issue + PR write, and put it in
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless).
2. Gitea bot account. (Done for git.lair.cafe on 2026-08-07.)
A dedicated `tireless` user, not your own account, holding two credentials:
Then, for each repo tireless should work on:
- add `tireless` as a collaborator with write access;
- enable branch protection on the default branch, denying `tireless` push;
- confirm it can still push refs matching `tireless/*`.
- ssh key at /var/lib/tireless/.ssh/id_ed25519 (0600 tireless:tireless),
registered on the account, for git transport. The private half is kept at
~/.ssh/id_tireless on the operator workstation.
- 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.
Verify it rather than assuming it.
Seed known_hosts for the forge at the same time. An unattended git must not
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.
The vhost, its cert and the split-horizon DNS were installed above, but