Files
Sylpheed/docs/agents/GITEA-SETUP.md
MechaCat02 87932e4017 docs: the status block said nothing existed while nine issues were live
Phases 1-4 and 6 are done on the instance. This file still opened with "Nothing
exists on the instance: no agent users, no API tokens, no labels, no milestones,
no branch protection" -- every clause of which was false by the time the merge
that carried it landed.

Replaced with a table of measured state, and each row says what was MEASURED
rather than what was run:

  * protection is verified behaviourally -- a real push to main refused with
    `pre-receive hook declined`, as the repository owner -- not read off a
    settings page. That distinction is the whole subject of this file.
  * the tokens are probed: right identity, 403 on branch_protections for both
    agents, so the Write-not-Admin carve-out is demonstrated and not asserted.
  * the labels are 11 because the instance holds 11.

And a standing note that this block is the part most likely to be wrong, with
what to believe instead: `gitea-protect --verify` and the issue list MEASURE,
this block REMEMBERS. A remembered status is a cache with no invalidation, which
is the same failure as a 1,227-line BLOCKED.md and as the two documents this
runbook was split across an hour ago.
2026-09-04 17:55:28 +02:00

20 KiB
Raw Blame History

Runbook: standing the Gitea working surface up

For the human. Work top to bottom — later phases depend on earlier ones. 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 14 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/proposedawaiting 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 formtools/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.

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:

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/version200 {"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.

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 🤖

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:
    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.

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/proposedstate/approved). This is the cheap gate — before effort, not after.


Phase 7 · Restart, and verify the loop 🤖 + 👤

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, superseding push-work — push the branch and open the PR with Closes #N and set the label, in one step. Today push-work does the first third; the other two thirds being manual is how they get skipped. Its existing refusals stay: no main, no force-push.
  • 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 version1.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 13 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.