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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user