GITEA-SETUP.md listed `propose-work` as unbuilt, and this PR builds it without touching that list, so merging would leave the doc describing a tool as missing that exists. Strike it the way `gitea-verify` already is: say what was built, then say what is still missing — here, an end-to-end run, which only the agent box can do because only it holds a token that can make the POSTs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
427 lines
20 KiB
Markdown
427 lines
20 KiB
Markdown
# Runbook: standing the Gitea working surface up
|
||
|
||
**For the human. Work top to bottom — later phases depend on earlier ones.**
|
||
[`WORKFLOW-gitea.md`](WORKFLOW-gitea.md) says *what* this is and why; this says
|
||
*how*, in order, with a check after each phase.
|
||
|
||
Steps are marked **👤 you** (a decision or a credential only you can make) or
|
||
**🤖 me** (I do it once you have unblocked it).
|
||
|
||
## Where things stand
|
||
|
||
**Updated 2026-09-04, against the live instance.** Phases 1–4 and 6 are done.
|
||
|
||
| phase | state |
|
||
|---|---|
|
||
| 1 · identities | ✅ `sylph-decoder`, `sylph-port`, both collaborators at **Write** |
|
||
| 2 · protection | ✅ applied and **verified behaviourally** — a real push to `main` was refused with `pre-receive hook declined`, as the repository owner |
|
||
| 3 · tokens | ✅ three, each functionally probed: right identity, `403` on `branch_protections` for both agents |
|
||
| 4 · labels | ✅ 11 labels, 4 milestones, idempotence confirmed by a second run creating nothing |
|
||
| 5 · MCP | ⏳ **written and merged; the images are NOT rebuilt.** This is the remaining blocker |
|
||
| 6 · items | ✅ 9 issues seeded with 3 dependency edges, read back. All `state/proposed` — **awaiting the human's approval of the shapes** |
|
||
| 7 · restart | ⏳ after the rebuild |
|
||
|
||
⚠️ **Do not start an agent before Phase 7.** Until the images are rebuilt, the
|
||
briefs tell it to read notifications and open issues with no tool that can.
|
||
|
||
📌 **This block goes stale first.** It was already wrong once — it read "nothing
|
||
exists on the instance" while nine issues were live. If it disagrees with
|
||
`gitea-protect --verify` or the issue list, believe those: they measure, this
|
||
remembers.
|
||
|
||
---
|
||
|
||
## Phase 1 · Identities 👤
|
||
|
||
The agents currently push as `fabi`, using your credential. That is the defect
|
||
this phase fixes, and it is not cosmetic: **Gitea does not let the author of a
|
||
pull request approve it.** While an agent *is* you, either you cannot approve its
|
||
PR or it can approve its own — and there is no third possibility. The review gate
|
||
does not exist until the agents are distinct people.
|
||
|
||
Two more reasons, once you are there anyway: 495 commits of decoder work are
|
||
currently attributed to **your** email, so blame is wrong; and separate
|
||
identities mean revoking one agent does not touch the other or you.
|
||
|
||
**1.1 — Create two users.** Site Administration → Identity & Access → User
|
||
Accounts → *Create User Account*.
|
||
|
||
| | |
|
||
|---|---|
|
||
| usernames | `sylph-decoder`, `sylph-port` |
|
||
| email | anything you control and can tell apart — `you+decoder@…`, `you+port@…` |
|
||
| "require password change on first login" | **off** — they never log in interactively |
|
||
|
||
**1.2 — Add both to `fabi/Sylpheed` as collaborators.** Repo → Settings →
|
||
Collaborators → add each → permission **Write**.
|
||
|
||
🔴 **Write, not Admin.** Admin can edit branch protection, which would let an
|
||
agent remove the rule that stops it merging.
|
||
|
||
> **Check:** the repo's Collaborators list shows both, each reading `Write`.
|
||
|
||
---
|
||
|
||
## Phase 2 · Branch protection 👤
|
||
|
||
**Do this before the agents hold tokens**, so there is no window in which they
|
||
can push to `main`.
|
||
|
||
**Apply it through the API, not the form** — `tools/gitea-protect`. Six settings
|
||
of which two are load-bearing, and both of those were missing from the first
|
||
draft of this phase: that is the shape of thing that gets mis-clicked. An API
|
||
call is reviewable in a diff and repeatable, and the same file re-checks it later.
|
||
|
||
```bash
|
||
tools/gitea-protect --dry-run # the exact rule, no credential read
|
||
tools/gitea-protect # create or update, then verify
|
||
tools/gitea-protect --verify # assert it still holds; exit 1 if not
|
||
```
|
||
|
||
📌 **Run it on the agent box, not the Pi.** Branch protection is a
|
||
repository-scope endpoint, so `~/.sylph-gitea-api-token` cannot do it — that
|
||
token is deliberately issue-only. The credential that can is the one already
|
||
sitting on that machine, `~/.sylph-git-credentials`, which the tool reads. Doing
|
||
it there means no new credential, and no second machine holding push rights just
|
||
to close a one-time setup step.
|
||
|
||
🔴 The tool sets `block_admin_merge_override: false`, deliberately. Turning it on
|
||
would lock **you** out of your own work — approvals are whitelisted to `fabi`,
|
||
Gitea will not let `fabi` approve a `fabi` PR, so a human-authored PR could never
|
||
reach one approval and could never merge. The admin override is what keeps that
|
||
door open, and it is not a hole in the agent gate for exactly one reason: the
|
||
agents are **Write, not Admin**. That is what Phase 1.2 is buying, and this is
|
||
where it gets spent.
|
||
|
||
Or by hand — Repo → Settings → Branches → *Protected Branches* → add rule for
|
||
`main`:
|
||
|
||
| setting | value | why |
|
||
|---|---|---|
|
||
| Enable Push | **off** | nothing reaches `main` except through a PR |
|
||
| Require approvals | **1** | the human gate, made native |
|
||
| Dismiss stale approvals | **on** | an approval must describe the code that merges |
|
||
| Block merge on rejected reviews | **on** | "changes requested" has to mean something |
|
||
| Enable Merge Whitelist | **on** → whitelist **`fabi` only** | approvals are not the last gate. *Merging* is |
|
||
| Enable Approvals Whitelist | **on** → whitelist **`fabi` only** | only a human's approval counts toward the 1 |
|
||
|
||
### 🔴 The hole that separate identities open, and why the last two rows close it
|
||
|
||
Phase 1 makes the agents distinct people so that a human *can* approve their
|
||
work. The same change makes something else possible for the first time: **Gitea
|
||
refuses to let an author approve their own pull request — it does not stop
|
||
`sylph-decoder` approving `sylph-port`'s.** With `required_approvals = 1` and
|
||
nothing else, the two agents satisfy the human gate between themselves, and the
|
||
author can then press Merge, because branch protection blocks *pushes* to `main`
|
||
and never blocked *merges*.
|
||
|
||
Neither whitelist is decoration, and neither replaces the other:
|
||
|
||
* **approvals whitelist** — an agent's approval stops counting toward the 1.
|
||
* **merge whitelist** — even a legitimately approved PR is merged by you.
|
||
|
||
Withholding the review tool from the agents (Phase 5) is defence in depth behind
|
||
these, not a substitute: an agent still has a browser-shaped API token.
|
||
|
||
### 🔴 What this rule does not gate, said plainly
|
||
|
||
It binds everyone who reaches Gitea through the API or the web. **It does not
|
||
bind anyone who can run `gitea admin` inside the container** — and that includes
|
||
the supervising agent on the Pi, the one that created the agent accounts and
|
||
minted their tokens. From that shell you can issue an admin token or edit this
|
||
rule, and nothing here would stop you.
|
||
|
||
That is not a hole to plug here; it is the boundary of what Phase 2 buys, and it
|
||
should be written down rather than discovered. **Phases 1 and 2 gate the two
|
||
containerised agents** — the ones that run unattended on a loop, whose whole
|
||
design assumption is that policy lives somewhere they cannot reach. A supervisor
|
||
with a shell on the host is not in that set, and the protection above should not
|
||
be read as universal.
|
||
|
||
The distinction is exactly the one Phase 1.2 draws with **Write, not Admin**: the
|
||
looping agents get a permission level that cannot edit the rule that binds them.
|
||
`tools/gitea-protect --verify` asserts that level on every run, which is the
|
||
check that keeps this true rather than merely stated.
|
||
|
||
> ### Check — and actually run it, do not assume it
|
||
>
|
||
> The whole point of putting this in protection rather than in a document is
|
||
> that it does not depend on anyone's good behaviour. So verify it the same way:
|
||
>
|
||
> 1. As `sylph-port`, push a throwaway branch and open a PR into `main`.
|
||
> 2. Confirm **no Merge button** is offered to that account.
|
||
> 3. Confirm **you** can approve it, and that *it* cannot approve itself.
|
||
> 4. Approve it yourself, then look at `sylph-port` again: **still no Merge
|
||
> button**, now that an approval exists. This is the step that tests the
|
||
> merge whitelist rather than the absence of an approval — without it, steps
|
||
> 2 and 3 pass on an instance where the agents can merge each other's work.
|
||
> 5. As **yourself**, try `git push origin main` with a throwaway commit. It
|
||
> should be **refused** — see below.
|
||
> 6. Close the PR, delete the branch, drop the commit.
|
||
>
|
||
> If step 2 or step 4 offers a Merge button, stop — the rest of this runbook
|
||
> assumes neither does.
|
||
|
||
### ⚠️ Your own pushes to `main` stop too
|
||
|
||
Not a side effect — the rule working. `enable_push: false` compiles to
|
||
`CanUserPush`, which in Gitea's `models/git/protected_branch.go` returns false
|
||
with **no bypass for repository admins or the owner**:
|
||
|
||
```go
|
||
if !protectBranch.CanPush {
|
||
return false
|
||
}
|
||
```
|
||
|
||
Three commits reached `main` by direct push on the day this was written, so the
|
||
first time you notice will be the first time you reach for it. From Phase 2 on,
|
||
**human changes go through pull requests like everything else** — and merging
|
||
them is what the admin override above is for. `--verify` asserts this state
|
||
rather than tolerating it: a verifier that excused your push would be excusing
|
||
the gate.
|
||
|
||
---
|
||
|
||
## Phase 3 · Tokens 👤
|
||
|
||
Three principals, three tokens. Settings → Applications → *Generate New Token*
|
||
while logged in **as that user**.
|
||
|
||
| whose | scopes | goes in | on which machine |
|
||
|---|---|---|---|
|
||
| **you** (`fabi`) | `write:issue`, `read:repository` | `~/.sylph-gitea-api-token` | **the Pi** |
|
||
| `sylph-decoder` | `write:repository`, `write:issue`, `write:notification`, `read:user` | `~/.sylph-gitea-token-decoder` | the agent box |
|
||
| `sylph-port` | same four | `~/.sylph-gitea-token-port` | the agent box |
|
||
|
||
📌 **Three machines, and the split is by tooling, not by capability.** Gitea runs
|
||
on the Pi, published through a VPS — so `git.mc02.dev` resolves to a hosted
|
||
address and a DNS lookup tells you nothing about the origin. The agent
|
||
containers run on the x86_64 desktop, which reaches the Gitea API perfectly well
|
||
(`GET /api/v1/version` → `200 {"version":"1.25.5"}`, run from there).
|
||
|
||
The `fabi` token lives on the Pi because that is where `tools/gitea-setup` runs,
|
||
and that is where the session driving Phases 4 and 6 sits. It is **not** a
|
||
reachability constraint, and an earlier draft that said so was wrong.
|
||
|
||
```bash
|
||
printf '%s\n' '<token>' > ~/.sylph-gitea-api-token && chmod 600 ~/.sylph-gitea-api-token
|
||
```
|
||
|
||
⚠️ **Never paste a token into chat.** The files are mounted read-only into the
|
||
containers, exactly like `~/.sylph-claude-token`.
|
||
|
||
📌 The existing `~/.sylph-git-credentials` is scoped `write:repository` and is
|
||
**refused by every issue endpoint** — verified, not assumed:
|
||
`required=[read:issue], token scope=write:repository`. It stays as it is; these
|
||
are additional.
|
||
|
||
🔴 **Do not add `write:repository` to the `fabi` token**, even though Phase 2's
|
||
API path might look as though it needs it. **A `write:repository` token *is* a
|
||
push credential** — that is the scope git checks for receive-pack — so adding it
|
||
would give the Pi push rights over `main`, in order to avoid giving the Pi push
|
||
rights. `gitea-protect` sidesteps it entirely by running on the agent box
|
||
against the credential already there. This warning exists because that advice
|
||
was given, in chat, by the same author as this file.
|
||
|
||
> **Check:** `tools/gitea-setup --dry-run` prints "would create …" rather than a
|
||
> scope error.
|
||
|
||
---
|
||
|
||
## Phase 4 · Labels and bundles 🤖
|
||
|
||
```bash
|
||
tools/gitea-setup --dry-run # read it first
|
||
tools/gitea-setup # idempotent; safe to re-run
|
||
```
|
||
|
||
Creates 11 labels — 5 `state/*`, 2 `agent/*`, 4 `kind/*` — and 4 milestones
|
||
(Menus, Title screen, Graphics pipeline, Infrastructure).
|
||
|
||
**No Kanban board yet, on purpose.** Gitea's board does not follow labels, so it
|
||
would be a second copy of the state to keep in sync by hand — which is the exact
|
||
failure that produced a 1,227-line `BLOCKED.md`. **Labels are the truth**; a
|
||
saved issue filter gives the same view for nothing. Add a board later if the
|
||
filter turns out to be insufficient.
|
||
|
||
> **Check:** the Issues page offers the `state/*` labels, and Milestones lists
|
||
> the four bundles.
|
||
|
||
---
|
||
|
||
## Phase 5 · The MCP server 🤖
|
||
|
||
**Done — in the tree, not yet in an image.** `gitea-mcp` **v1.7.0**, Linux
|
||
x86_64, sha256 `bbc9a7b4…d446b8d` from the release's own `checksums.txt`. The
|
||
flags are no longer taken on trust: the arm64 build of the same release was run
|
||
and its `--help` read, so `-t stdio`, `-H <url>`, `-O/--tools`, `-S/--scope`,
|
||
`-r/--read-only` and `GITEA_ACCESS_TOKEN_FILE` are confirmed, not assumed.
|
||
|
||
Three edits per image, made:
|
||
|
||
1. **`Dockerfile`** — fetch the release tarball, verify the checksum, unpack
|
||
`gitea-mcp` into `/usr/local/bin`, and run `--version` at build time so a bad
|
||
pin fails the build rather than the agent.
|
||
2. **`entrypoint.sh`** — register it at user scope for that agent's identity,
|
||
remove-then-add so a restart is idempotent:
|
||
```bash
|
||
claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
|
||
-- gitea-mcp -t stdio -H https://git.mc02.dev -O "$GITEA_MCP_TOOLS"
|
||
```
|
||
🔴 **By path, not by value.** The earlier draft of this line read
|
||
`GITEA_ACCESS_TOKEN=$(cat …)`, which writes the token in cleartext into
|
||
`~/.claude.json` — read by every session in the container and carried into any
|
||
copy of that file. `GITEA_ACCESS_TOKEN_FILE` is new in the version we pin and
|
||
leaves the secret in its read-only mount.
|
||
3. **`sylph-decoder` / `sylph-port`** — mount `~/.sylph-gitea-token-{decoder,port}`
|
||
read-only and pass its path. Inert until the file exists: without a token the
|
||
container still starts, says plainly that the agent has no issues and no pull
|
||
requests, and carries on.
|
||
|
||
**👤 Yours:** rebuild both images on the agent box, where the containers run.
|
||
⚠️ `CARGO_BUILD_JOBS=4` and a limited `-j`; a full-parallel build has OOM-crashed
|
||
that machine.
|
||
|
||
```bash
|
||
docker/decoder/sylph-decoder build
|
||
docker/port/sylph-port build
|
||
```
|
||
|
||
### The tool filter is a control now, not an experiment
|
||
|
||
The tool names were unknown when this was written; they are in the release's
|
||
README, and the set each agent gets is pinned in the entrypoint
|
||
(`SYLPH_GITEA_TOOLS` overrides it):
|
||
|
||
```
|
||
get_me, notification_read, notification_write, list_issues, issue_read,
|
||
issue_write, attachment_read, search_issues, label_read, milestone_read,
|
||
list_pull_requests, pull_request_read, pull_request_write
|
||
```
|
||
|
||
What is **absent** is the point:
|
||
|
||
* **`pull_request_review_write`** — the tool that approves, dismisses and
|
||
resolves reviews. Without it an agent cannot approve the *other* agent's pull
|
||
request through the MCP. Pair it with the approvals whitelist in Phase 2; the
|
||
whitelist is the control, this is the layer in front of it.
|
||
* **the file, branch, tag and repo write tools** — a change reaches `main` as a
|
||
reviewable commit through git, or it does not reach it.
|
||
* `label_write` / `milestone_write` — agents *apply* labels (that is
|
||
`issue_write`); they do not get to redefine the state machine.
|
||
|
||
`pull_request_write` bundles `merge` into one action-based tool and **cannot be
|
||
split**, which is exactly why merging is blocked by the merge whitelist instead.
|
||
|
||
> **Check:** in each container, `claude mcp list` shows `gitea` connected, and a
|
||
> read call returns this repo's labels. The entrypoint also says which of the two
|
||
> it did on every start, so a missing token is visible in `logs` rather than as
|
||
> an agent quietly improvising.
|
||
|
||
---
|
||
|
||
## Phase 6 · Seed the first items 🤖 + 👤
|
||
|
||
I migrate the live findings into issues — **not** the 1,227 historical lines,
|
||
only what is actually open:
|
||
|
||
| bundle | items |
|
||
|---|---|
|
||
| **Title screen** | F5 (does Ⓐ snap or accelerate?), F6 (`ptloop01/02` sweep onset), re-propose the F5/F6 work left off `main` |
|
||
| **Menus** | F1 (held-direction repeat rate — Decoder measures, Port implements), F2 (SFX mix too loud), F3 (missing title audio), re-propose the OPTIONS menu work |
|
||
|
||
Each gets a bundle, an owner label, a dependency edge where one waits on the
|
||
other, and — for anything already written on the port branch — a note that the
|
||
code exists and needs re-proposing as a reviewable PR, not rewriting.
|
||
|
||
**👤 Your part:** approve the *shape* of each (`state/proposed` →
|
||
`state/approved`). This is the cheap gate — before effort, not after.
|
||
|
||
---
|
||
|
||
## Phase 7 · Restart, and verify the loop 🤖 + 👤
|
||
|
||
```bash
|
||
docker/decoder/sylph-decoder
|
||
docker/port/sylph-port
|
||
```
|
||
|
||
> ### Check — the three things that must be true
|
||
>
|
||
> 1. Each agent's **first iteration reads its notifications.** If it does not,
|
||
> nothing addressed to it will ever arrive: **notifications are polled, and
|
||
> nothing pushes.**
|
||
> 2. Each opens a **pull request**, not a bare branch push, and labels its issue
|
||
> `state/needs-human` with a one-line "look at this".
|
||
> 3. Neither can merge. (Already proven in Phase 2; confirm it holds for a real
|
||
> PR.)
|
||
|
||
---
|
||
|
||
## Still to build 🤖
|
||
|
||
Not blockers for Phase 7, but the workflow is not finished without them:
|
||
|
||
* ~~**`propose-work`**~~ — done, as `docker/{decoder,port}/bin/propose-work`:
|
||
pushes through `push-work` (so its refusals stay in one place — no `main`, no
|
||
force-push), opens the PR with `Closes #N` taken from the branch name, and moves
|
||
the issue to `state/needs-human`. What is still missing is an *end-to-end* run:
|
||
everything before the network calls is exercised, the `POST`s are not, because
|
||
only the agent box holds a token that can make them. Its first real use is its test.
|
||
* **an attachment uploader** — the MCP exposes `attachment_read` only, so putting
|
||
a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`.
|
||
* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every
|
||
field of the rule *independently* of what the apply path sends, and that both
|
||
agents are still Write-not-Admin. What is still missing is only the *every
|
||
day* part — nothing runs it on a timer yet.
|
||
* **wiki landing page** — bundles in flight and what each agent is on. There is
|
||
currently no view of what is happening except container logs.
|
||
|
||
## What I have not verified
|
||
|
||
Said plainly, because a runbook that hides its soft spots is worse than one that
|
||
does not:
|
||
|
||
* **that Gitea hides Approve from a PR's own author.** Widely true; Phase 2's
|
||
check tests it directly rather than trusting me. What I no longer assume is
|
||
that it is *enough* — it says nothing about one agent approving the other,
|
||
which is what the approvals whitelist is for.
|
||
* **Gitea's Projects API**, which is why Phase 4 creates no board.
|
||
|
||
Settled since, rather than assumed:
|
||
|
||
* ~~the `--tools` filter names~~ — read out of the pinned release, and the
|
||
binary's `--help` run directly. Phase 5 lists the set.
|
||
* ~~the exact Gitea version~~ — **1.25.5**, confirmed independently from *both*
|
||
machines. `enable_merge_whitelist`, `enable_approvals_whitelist` and
|
||
`block_admin_merge_override` are all present in this instance's own API
|
||
schema, so the Phase 2 settings exist under those names on the Branches screen.
|
||
* ~~which machine can reach what~~ — the desktop reaches the Gitea API fine.
|
||
The token split in Phase 3 is about which session runs which script, and an
|
||
earlier draft that justified it as a network constraint was wrong.
|
||
|
||
### Wrong, not merely unverified
|
||
|
||
Kept separate, because "I had not checked" and "I asserted the opposite" are
|
||
different failures and only the second is worth a heading:
|
||
|
||
* **that requiring an approval closes the gate.** It does not. Merging ignores
|
||
the push whitelist entirely, and any Write collaborator is an official
|
||
reviewer — so the first version of Phase 2 would have let the two agents
|
||
approve each other and merge. Both whitelists exist because of it.
|
||
* **that the check could catch that.** It could not: with the approval
|
||
requirement unmet, Gitea offers *nobody* a merge button, so the original
|
||
steps 1–3 pass on a completely unprotected instance. Step 4 is the test.
|
||
* **that the `fabi` token should gain `write:repository`.** That scope is a push
|
||
credential.
|
||
* **that the desktop could not reach Gitea.** It can; `curl` was being refused
|
||
by a local permission prompt, which is not the same thing and was read as if
|
||
it were.
|
||
|
||
The first two were caught by the other agent. The pattern in all four is one
|
||
thing: **a property was inferred from something adjacent to it** — protection
|
||
from a settings page, reachability from a DNS record — instead of being tested
|
||
directly. That is the same failure the port's frozen-splash instruments made,
|
||
in a document about avoiding it.
|