feat(infra): provision the bot account, and move to a fork-based PR model
All checks were successful
deploy / deploy (push) Successful in 5m36s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user