From 034e98eeb0ac3ae19dca557b63b3a215f3b1cc13 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 16:39:12 +0200 Subject: [PATCH 01/10] docker: give each agent its own Gitea hands, and close the cross-approval hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the runbook could not have known it needed. gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release publishes and smoke-tested with `--version` at build time, so a bad pin fails the build instead of the agent. Each entrypoint registers it at user scope for that container's own identity, remove-then-add so a restart is idempotent. The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in cleartext into ~/.claude.json, which every session in the container reads; GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in its read-only mount. Verified against the binary's own --help, not assumed. The tool filter stops being an experiment. The names are in the release README: each agent gets issues, notifications, labels, milestones and pull requests, and NOT `pull_request_review_write`. That one matters because separate identities open a hole the runbook did not name: Gitea refuses to let an author approve their own pull request, and does nothing about sylph-decoder approving sylph-port's. Two agents could satisfy `required_approvals = 1` between themselves and then merge, since branch protection blocks pushes to main and never blocked merges. Withholding the tool is defence in depth; the controls are in branch protection, and both docs now say so: approvals whitelisted to the human so an agent's approval does not count, merges whitelisted to the human so an approved PR is still merged by a person. Phase 2's check gains the step that actually tests it -- approve the throwaway PR yourself, then confirm the agent STILL has no merge button. Without that step, the check passes on an instance where the agents can merge each other's work. Also settles two entries on the runbook's own "not verified" list: the tool filter names, and the Gitea version (1.25.5, whose API schema carries enable_merge_whitelist and enable_approvals_whitelist under those names). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- docker/decoder/Dockerfile | 18 +++++ docker/decoder/entrypoint.sh | 48 +++++++++++++ docker/decoder/sylph-decoder | 25 +++++++ docker/port/Dockerfile | 18 +++++ docker/port/entrypoint.sh | 48 +++++++++++++ docker/port/sylph-port | 25 +++++++ docs/agents/GITEA-SETUP.md | 131 ++++++++++++++++++++++++++-------- docs/agents/WORKFLOW-gitea.md | 16 +++-- 8 files changed, 295 insertions(+), 34 deletions(-) diff --git a/docker/decoder/Dockerfile b/docker/decoder/Dockerfile index b3f85389..571d4ced 100644 --- a/docker/decoder/Dockerfile +++ b/docker/decoder/Dockerfile @@ -82,6 +82,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* +# ── gitea-mcp ──────────────────────────────────────────────────────────────── +# The agent's hands on issues, pull requests and notifications — Gitea's own MCP +# server, so there is no second store of truth to drift out of sync with the +# first. +# +# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is +# handed a token that can write to the repository. The checksum is the one +# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset. +ARG GITEA_MCP_VERSION=1.7.0 +ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d +RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \ + "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \ + && echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \ + && chmod +x /usr/local/bin/gitea-mcp \ + && rm -f /tmp/gitea-mcp.tar.gz \ + && gitea-mcp --version + # ── The agent user ─────────────────────────────────────────────────────────── # NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions # when it has root privileges. uid/gid 1000 matches the host account so files diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index f9d4e39b..525de7f7 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -208,6 +208,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true chmod 600 "$HOME/.claude.json" 2>/dev/null || true +# ── The Gitea MCP server ───────────────────────────────────────────────────── +# Registered at USER scope rather than from a committed `.mcp.json`: the token +# differs per agent and none of it belongs in git. +# +# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat +# …)` would write the secret in cleartext into ~/.claude.json, where it is read +# by every session in this container and lands in any copy of that file. +# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its +# read-only mount and lets the server read it itself. +# +# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name +# that already exists, and ~/.claude.json is re-seeded above — neither ordering +# survives alone. +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}" +GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}" +# Which tools this agent gets. Deliberately not all of them: +# +# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one. +# Gitea will not let an author approve its own pull request — but the moment +# the two agents are separate people, nothing stops them approving each +# OTHER's and satisfying `required_approvals` between themselves with no +# human involved. Separate identities open that hole; withholding the tool +# closes it here, and the approvals whitelist on `main` closes it there. +# * the file / branch / repo WRITE tools are absent: a change reaches `main` +# as a reviewable commit through git, or it does not reach it. +# +# `pull_request_write` bundles `merge` into one tool and cannot be split, so +# merging stays blocked where the agent cannot reach it — the merge whitelist in +# branch protection. This list is defence in depth BEHIND that, never instead. +GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-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}" +if [ ! -s "$GITEA_TOKEN_FILE" ]; then + echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered." + echo "[entrypoint] This agent cannot read its notifications or open a pull" + echo "[entrypoint] request, which is most of what its brief asks of it." +elif ! command -v gitea-mcp >/dev/null 2>&1; then + echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2 +else + claude mcp remove gitea -s user >/dev/null 2>&1 || true + if claude mcp add -s user gitea \ + -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \ + -- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then + echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL" + else + echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2 + echo "[entrypoint] no pull requests and no notifications." >&2 + fi +fi + # ── Claude Code ────────────────────────────────────────────────────────────── if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then # Drop the image's default CMD first, or `claude` is handed the literal string diff --git a/docker/decoder/sylph-decoder b/docker/decoder/sylph-decoder index 29689f65..7c49e9f2 100755 --- a/docker/decoder/sylph-decoder +++ b/docker/decoder/sylph-decoder @@ -23,6 +23,8 @@ # SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent) # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work # (default: $HOME/.sylph-git-credentials) +# SYLPH_GITEA_TOKEN this agent's own Gitea token file +# (default: $HOME/.sylph-gitea-token-decoder) # SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m) # SYLPH_CPUS / SYLPH_MEM_GB override the computed half set -euo pipefail @@ -229,6 +231,29 @@ docker_args() { echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2 fi + # ── Gitea ── + # This agent's OWN token, for its OWN Gitea account — not the push credential + # and not the human's. Three reasons it is separate: `~/.sylph-git-credentials` + # is scoped `write:repository` and every issue endpoint REFUSES it; a pull + # request the agent authored is one a human can approve, which is the entire + # review gate; and revoking one agent then touches neither the other nor you. + # + # Mounted read-only and passed to the MCP server BY PATH — see the entrypoint + # for why the value must not go through the environment. + # Inert until the file exists: the container still runs, with no issues. + GITEATOK="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-decoder}" + if [ -f "$GITEATOK" ]; then + _out+=( + -v "$GITEATOK:/sylph-home/re/.sylph-gitea-token:ro" + -e "GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token" + ) + else + echo "==> NOTE: no Gitea token at $GITEATOK — this agent cannot read its" >&2 + echo " notifications, open an issue or open a pull request. Generate one" >&2 + echo " while logged in AS sylph-decoder: Settings -> Applications, scopes" >&2 + echo " write:repository, write:issue, write:notification, read:user." >&2 + fi + [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") [ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN") [ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE") diff --git a/docker/port/Dockerfile b/docker/port/Dockerfile index e0893494..e89e945d 100644 --- a/docker/port/Dockerfile +++ b/docker/port/Dockerfile @@ -62,6 +62,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* +# ── gitea-mcp ──────────────────────────────────────────────────────────────── +# The agent's hands on issues, pull requests and notifications — Gitea's own MCP +# server, so there is no second store of truth to drift out of sync with the +# first. +# +# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is +# handed a token that can write to the repository. The checksum is the one +# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset. +ARG GITEA_MCP_VERSION=1.7.0 +ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d +RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \ + "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \ + && echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \ + && chmod +x /usr/local/bin/gitea-mcp \ + && rm -f /tmp/gitea-mcp.tar.gz \ + && gitea-mcp --version + # ── The agent user ─────────────────────────────────────────────────────────── # NOT root: Claude Code refuses --dangerously-skip-permissions with root # privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index c8c90ba0..ffebced2 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -87,6 +87,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true chmod 600 "$HOME/.claude.json" 2>/dev/null || true +# ── The Gitea MCP server ───────────────────────────────────────────────────── +# Registered at USER scope rather than from a committed `.mcp.json`: the token +# differs per agent and none of it belongs in git. +# +# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat +# …)` would write the secret in cleartext into ~/.claude.json, where it is read +# by every session in this container and lands in any copy of that file. +# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its +# read-only mount and lets the server read it itself. +# +# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name +# that already exists, and ~/.claude.json is re-seeded above — neither ordering +# survives alone. +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}" +GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}" +# Which tools this agent gets. Deliberately not all of them: +# +# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one. +# Gitea will not let an author approve its own pull request — but the moment +# the two agents are separate people, nothing stops them approving each +# OTHER's and satisfying `required_approvals` between themselves with no +# human involved. Separate identities open that hole; withholding the tool +# closes it here, and the approvals whitelist on `main` closes it there. +# * the file / branch / repo WRITE tools are absent: a change reaches `main` +# as a reviewable commit through git, or it does not reach it. +# +# `pull_request_write` bundles `merge` into one tool and cannot be split, so +# merging stays blocked where the agent cannot reach it — the merge whitelist in +# branch protection. This list is defence in depth BEHIND that, never instead. +GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-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}" +if [ ! -s "$GITEA_TOKEN_FILE" ]; then + echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered." + echo "[entrypoint] This agent cannot read its notifications or open a pull" + echo "[entrypoint] request, which is most of what its brief asks of it." +elif ! command -v gitea-mcp >/dev/null 2>&1; then + echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2 +else + claude mcp remove gitea -s user >/dev/null 2>&1 || true + if claude mcp add -s user gitea \ + -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \ + -- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then + echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL" + else + echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2 + echo "[entrypoint] no pull requests and no notifications." >&2 + fi +fi + # ── The repository, cloned into THIS AGENT'S OWN volume ───────────────────── # Not a bind mount of a human's working tree. That arrangement bit this project # three times: an agent's `git config --local` captured a human's commits, a diff --git a/docker/port/sylph-port b/docker/port/sylph-port index 246c5101..f1f49065 100755 --- a/docker/port/sylph-port +++ b/docker/port/sylph-port @@ -14,6 +14,8 @@ # SYLPH_PORT_REPO repo to mount at /work (default: this script's parent) # SYLPH_DISC extracted disc root # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work +# SYLPH_GITEA_TOKEN this agent's own Gitea token file +# (default: $HOME/.sylph-gitea-token-port) # SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m) # # ── Two hard-won constraints ──────────────────────────────────────────────── @@ -133,6 +135,29 @@ docker_args() { echo " so its work dies with the container." >&2 fi + # ── Gitea ── + # This agent's OWN token, for its OWN Gitea account — not the push credential + # and not the human's. Three reasons it is separate: `~/.sylph-git-credentials` + # is scoped `write:repository` and every issue endpoint REFUSES it; a pull + # request the agent authored is one a human can approve, which is the entire + # review gate; and revoking one agent then touches neither the other nor you. + # + # Mounted read-only and passed to the MCP server BY PATH — see the entrypoint + # for why the value must not go through the environment. + # Inert until the file exists: the container still runs, with no issues. + local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}" + if [ -f "$giteatok" ]; then + _out+=( + -v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro" + -e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token" + ) + else + echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2 + echo " notifications, open an issue or open a pull request. Generate one" >&2 + echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2 + echo " write:repository, write:issue, write:notification, read:user." >&2 + fi + [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") # ── GPU ── diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index d28a4c5c..3b88696b 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -13,8 +13,10 @@ Steps are marked **👤 you** (a decision or a credential only you can make) or * ✅ The briefs already describe this workflow. **They describe tooling that does not exist yet** — which is why the agents are stopped and must stay stopped until Phase 7. -* ❌ Nothing exists on the instance: no agent users, no API tokens, no labels, no - branch protection, no MCP. +* ❌ Nothing exists on the instance: no agent users (only `fabi`), no API + tokens, no labels, no milestones, no branch protection. +* ⏳ The MCP wiring (Phase 5) is written and in the tree; the images have not + been rebuilt, and no token exists for it to use yet. ⚠️ **Do not start an agent before Phase 7.** Its brief will tell it to read notifications and open issues, and it will have no tool that can. @@ -65,6 +67,26 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`: | 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. > ### Check — and actually run it, do not assume it > @@ -74,10 +96,14 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`: > 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. Close the PR, delete the branch. +> 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. Close the PR, delete the branch. > -> If step 2 offers a Merge button, stop — the rest of this runbook assumes it -> does not. +> If step 2 or step 4 offers a Merge button, stop — the rest of this runbook +> assumes neither does. --- @@ -132,34 +158,72 @@ filter turns out to be insufficient. ## Phase 5 · The MCP server 🤖 -`gitea-mcp` **v1.7.0** — `gitea-mcp_Linux_x86_64.tar.gz` from -`https://gitea.com/gitea/gitea-mcp/releases`. Confirmed flags: `-t stdio`, -`-H `, token via `GITEA_ACCESS_TOKEN`. +**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 `, `-O/--tools`, `-S/--scope`, +`-r/--read-only` and `GITEA_ACCESS_TOKEN_FILE` are confirmed, not assumed. -Three edits per image, which I make: +Three edits per image, made: -1. **`Dockerfile`** — fetch and unpack the release binary to `/usr/local/bin`, - pinned to v1.7.0 with a checksum. -2. **`entrypoint.sh`** — register it for that agent's own identity: +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=$(cat "$GITEA_TOKEN_FILE")" \ - -- gitea-mcp -t stdio -H https://git.mc02.dev + 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" ``` - User scope, not a committed `.mcp.json` — the token differs per agent and none - of it belongs in git. -3. **`sylph-decoder` / `sylph-port`** — mount the matching token file read-only. + 🔴 **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. -Then rebuild both images. ⚠️ `CARGO_BUILD_JOBS=4` and limited `-j`; a -full-parallel build has OOM-crashed this box. +**👤 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. -📌 **Worth trying, unverified:** the server takes `--tools` / `GITEA_TOOLS` to -filter which tools it exposes. If a merge tool can be excluded by name, that is -defence in depth behind Phase 2 — belt *and* braces. I have not confirmed the -tool names, so this is an experiment at install time, **not** a substitute for -branch protection. +```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. +> 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. --- @@ -222,9 +286,16 @@ 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. -* **the `--tools` filter names** (Phase 5) — an experiment, not a control. + 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. -* **the exact Gitea version** — the API was unreachable from my sandbox on the - last three attempts. Every screen named here has been stable for many - releases, but if a menu is not where I say it is, that is why. + +Two things on this list have since been settled 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**. `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. diff --git a/docs/agents/WORKFLOW-gitea.md b/docs/agents/WORKFLOW-gitea.md index e0ae0911..0234eaf8 100644 --- a/docs/agents/WORKFLOW-gitea.md +++ b/docs/agents/WORKFLOW-gitea.md @@ -67,10 +67,18 @@ One PR per item, closing its issue: too big to be an item. The discipline stops depending on an agent's judgement. 🔴 **Agents must not merge their own pull requests.** The MCP's -`pull_request_write` includes `merge`, so this cannot be left to instruction — -it goes in **branch protection on `main`**, requiring review. Same principle that -fixed the build-jobs cap: policy belongs where the agent cannot reach it, not in -a document asking it not to. +`pull_request_write` includes `merge` and the tool cannot be split, so this +cannot be left to instruction — it goes in **branch protection on `main`**. Same +principle that fixed the build-jobs cap: policy belongs where the agent cannot +reach it, not in a document asking it not to. + +⚠️ **"Requiring review" is not the rule that does it.** Gitea stops an author +approving their own pull request; it does not stop *the other agent* approving +it, and it never blocked merging in the first place — `Enable Push: off` blocks +pushes. The rule that holds is the pair of whitelists: **approvals whitelisted to +the human**, so an agent's approval does not count, and **merges whitelisted to +the human**, so an approved PR is still merged by a person. See +[`GITEA-SETUP.md`](GITEA-SETUP.md) Phase 2. ### 🔴 The wiki is NOT for the RE corpus From 7e5719d45792e2d96942330d89b69959c57214b7 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 16:49:58 +0200 Subject: [PATCH 02/10] tools: apply and re-check the branch protection rule, rather than clicking it Phase 2 as a file. Six settings where two are load-bearing and both were missing from the first draft is the shape of thing that gets mis-clicked at 1am, so it goes through the API: what was applied is readable in a diff, and `--verify` can re-check it later instead of it being checked once. --verify states its expectations INDEPENDENTLY of what the apply path sends. A check derived from "whatever we posted" cannot fail -- it re-derives the expectation from the thing under test, which is the same instrument-shaped failure as a check that passes on an instance with no rule at all. It also asserts both agents are still Write and not Admin, because an agent promoted to Admin can edit the rule and then merge, so a green rule proves nothing on its own. That is the `gitea-verify` card from "Still to build"; what is left of it is only putting it on a timer. `block_admin_merge_override` stays false on purpose, and the reasoning is in the file: approvals are whitelisted to `fabi`, and Gitea will not let `fabi` approve a `fabi` PR -- so with the override blocked, a human-authored PR could never reach one approval and could never merge at all. The override is not a hole in the agent gate because the agents are Write, not Admin. Phase 1.2 pays for that; this is where it is spent. Reads the repository-scoped credential that already exists on the agent box (~/.sylph-git-credentials) rather than the issue-only ~/.sylph-gitea-api-token, which every branch-protection endpoint refuses. That keeps the setup needing no new credential, and keeps push rights on one machine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- docs/agents/GITEA-SETUP.md | 35 +++++- tools/gitea-protect | 231 +++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 3 deletions(-) create mode 100755 tools/gitea-protect diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 3b88696b..7a81b432 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -59,7 +59,34 @@ agent remove the rule that stops it merging. **Do this before the agents hold tokens**, so there is no window in which they can push to `main`. -Repo → Settings → Branches → *Protected Branches* → add rule for `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 | |---|---|---| @@ -275,8 +302,10 @@ Not blockers for Phase 7, but the workflow is not finished without them: 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`** — asserts protection is still on and the agents are still - Write-not-Admin. Phase 2 is checked once; this checks it every day. +* ~~**`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. diff --git a/tools/gitea-protect b/tools/gitea-protect new file mode 100755 index 00000000..c1ac40e8 --- /dev/null +++ b/tools/gitea-protect @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md. + + tools/gitea-protect --dry-run print the exact rule it would send; no token + tools/gitea-protect create or update the rule (idempotent) + tools/gitea-protect --verify assert the live rule still holds; exit 1 if not + +Six settings, of which two were missing from the first draft of the runbook and +both of those are the ones that close the gate. That is the shape of thing that +gets mis-clicked in a web form at 1am, so it goes through the API instead: what +was applied is reviewable in a diff, and `--verify` re-checks it every day +rather than once. + +── Why each field is what it is ───────────────────────────────────────────── + +Read out of Gitea's own models/git/protected_branch.go, not inferred: + + EnableMergeWhitelist=false merging falls back on "whether the user has + write permission" -- and both agents have + Write. This is THE gate; without it every + other row is decoration. + EnableApprovalsWhitelist=false "anyone with write access is considered + official reviewer". Gitea refuses to let an + author approve their OWN pull request and does + nothing about sylph-decoder approving + sylph-port's, so without this the two agents + satisfy the human gate between themselves. + enable_push=false blocks PUSHES to main. It has no effect on + merging whatsoever, which is the assumption + that made the first version of this phase read + as protection while being none. + +🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the +human out of their 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 -- with the override blocked -- could never be merged at all. The +admin override is what keeps that door open, and it is not a hole in the agent +gate because the agents are Write, not Admin. That is what "Write, not Admin" in +Phase 1.2 is buying, and this is where it gets spent. + +── Where the token comes from ─────────────────────────────────────────────── + +Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token` +(write:issue, read:repository) cannot do it -- that token exists precisely so the +issue work needs no repository rights. + +The credential that CAN is one you already have: `~/.sylph-git-credentials`, on +the agent box, scoped write:repository. Reusing it means this needs no new +credential and no second machine holding push rights, which is the whole reason +to run this here rather than on the Pi. +""" + +import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request + +HOST = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev") +REPO = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed") +HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi") +BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main") +AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",") + +RULE = { + "rule_name": BRANCH, + "enable_push": False, + "required_approvals": 1, + "dismiss_stale_approvals": True, + "block_on_rejected_reviews": True, + "enable_merge_whitelist": True, + "merge_whitelist_usernames": [HUMAN], + "enable_approvals_whitelist": True, + "approvals_whitelist_username": [HUMAN], + "block_admin_merge_override": False, # see the module docstring +} + +# What --verify asserts. Kept separate from RULE because a check that is written +# as "whatever we sent" cannot fail: it would re-derive the expectation from the +# thing under test. These are stated independently, on purpose. +EXPECTED = { + "enable_push": (lambda v: v is False, "pushes to the branch are blocked"), + "required_approvals": (lambda v: v >= 1, "at least one approval required"), + "dismiss_stale_approvals": (lambda v: v is True, "stale approvals dismissed"), + "block_on_rejected_reviews": (lambda v: v is True, "rejected reviews block the merge"), + "enable_merge_whitelist": (lambda v: v is True, "MERGE WHITELIST ON -- the gate"), + "merge_whitelist_usernames": (lambda v: v == [HUMAN], f"only {HUMAN} may merge"), + "enable_approvals_whitelist": (lambda v: v is True, "APPROVALS WHITELIST ON"), + "approvals_whitelist_username": (lambda v: v == [HUMAN], f"only {HUMAN}'s approval counts"), +} + + +def token(): + """The first credential that can plausibly do this, and a clear no otherwise.""" + explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN") + if explicit and os.path.exists(explicit): + return open(explicit).read().strip(), explicit + + cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS", + "~/.sylph-git-credentials")) + if os.path.exists(cred): + for line in open(cred): + line = line.strip() + if HOST in line and "@" in line: + parsed = urllib.parse.urlsplit(line) + if parsed.password: + return urllib.parse.unquote(parsed.password), cred + + sys.exit( + f"gitea-protect: no repository-scoped credential found.\n\n" + f" Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n" + f" NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n" + f" by design, and every branch-protection endpoint refuses it. Run this on\n" + f" the machine that already holds the push credential rather than issuing a\n" + f" repository-scoped token to a second box.\n" + ) + + +def api(method, path, tok, body=None): + url = f"https://{HOST}/api/v1/repos/{REPO}{path}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": f"token {tok}", + "Content-Type": "application/json", + "Accept": "application/json", + }) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + if e.code in (401, 403) and "scope" in raw: + sys.exit(f"🔴 that credential lacks repository scope:\n {raw.strip()}") + return e.code, raw + except urllib.error.URLError as e: + sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}") + + +def apply_rule(tok): + status, existing = api("GET", f"/branch_protections/{BRANCH}", tok) + if status == 200: + status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok, + {k: v for k, v in RULE.items() if k != "rule_name"}) + verb = "updated" + elif status == 404: + status, out = api("POST", "/branch_protections", tok, RULE) + verb = "created" + else: + sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}") + + if status not in (200, 201): + sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}") + print(f" {verb} the protection rule on {BRANCH}") + return out + + +def verify(tok): + """Assert, one line per property, and say which one failed rather than 'no'.""" + ok = True + status, rule = api("GET", f"/branch_protections/{BRANCH}", tok) + if status == 404: + print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.") + return False + if status != 200: + sys.exit(f"🔴 could not read the rule ({status}): {rule}") + + for key, (pred, why) in EXPECTED.items(): + got = rule.get(key) + good = pred(got) + ok &= good + print(f" {'✅' if good else '🔴'} {why:<42} {key}={got!r}") + + # The other half of what a daily check is for: Phase 1.2's "Write, not + # Admin". An agent promoted to Admin could edit the rule above and then + # merge, so a green rule proves nothing on its own. + for agent in AGENTS: + status, perm = api("GET", f"/collaborators/{agent}/permission", tok) + if status == 404: + print(f" ⚪ {agent:<42} not a collaborator (yet)") + continue + if status != 200: + print(f" 🔴 {agent:<42} permission unreadable ({status})") + ok = False + continue + role = perm.get("permission") + good = role == "write" + ok &= good + print(f" {'✅' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}") + + return ok + + +def main(): + p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0]) + g = p.add_mutually_exclusive_group() + g.add_argument("--dry-run", action="store_true", + help="print the rule that would be sent; needs no credential") + g.add_argument("--verify", action="store_true", + help="check the live rule against what this file asserts") + a = p.parse_args() + + print(f"repo https://{HOST}/{REPO}") + print(f"branch {BRANCH}\n") + + if a.dry_run: + print(f"would PUT this rule (no credential read, nothing sent):\n") + print(json.dumps(RULE, indent=2)) + print(f"\ndry run -- nothing was changed.") + return 0 + + tok, where = token() + print(f"credential from {where}\n") + + if a.verify: + ok = verify(tok) + print() + print("protection holds." if ok else + "🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.") + return 0 if ok else 1 + + apply_rule(tok) + print() + ok = verify(tok) + print() + if ok: + print("Now run the check that a settings page cannot give you, from") + print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway") + print("PR yourself, then confirm sylph-port STILL has no merge button.") + print("Steps 1-3 pass on an instance with no rule at all.") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 17c0e3f2ab70504a18881961e064c11b98ae97a3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 17:18:46 +0200 Subject: [PATCH 03/10] docs: fold the page's revisions into the file, and separate wrong from unchecked The runbook existed as two documents -- a published page and this file -- with no mechanism keeping them equal, only an intention to remember. Two versions was the predicted outcome of that, not an accident on top of it. This is the fold, and the rule that follows it: THIS FILE IS THE SOURCE, the page is derived from it. When something is urgent enough to push to the page first, it lands here in the same turn, not "shortly after". Four things the file did not carry: * YOUR OWN PUSHES TO main STOP. `enable_push: false` compiles to CanUserPush, which returns false with no bypass for admins or the owner -- quoted from the source. Three commits went in by direct push the day this was written, so the first notice would have been mid-task. Now a check step. * the token files' MACHINES, which the table had lost. * do NOT add `write:repository` to the `fabi` token. That scope IS a push credential. Written down because that advice was given, in chat, by the author of this file. * Gitea 1.25.5 confirmed from the desktop too, not just the Pi. And one thing deliberately NOT folded in: the page said the desktop's outbound HTTP was blocked, and that is false. `python3 -c 'urllib...'` returns 200 {"version":"1.25.5"} from this box. What is refused here is `curl`, by a local permission prompt -- which I read as a network constraint and then published as one. The Phase 3 locations stand; the reason given for them did not. The "not verified" section now separates WRONG from UNCHECKED. Four entries are wrong -- requiring an approval does not close the gate, the check could not have caught that, the token scope, the reachability -- and the pattern in all four is identical: a property inferred from something ADJACENT to it (protection from a settings page, reachability from a DNS record) instead of tested directly. That is the frozen-splash failure, committed in the document about avoiding it. The first two were caught by the other agent, which is the argument for the review gate this file exists to build. --- docs/agents/GITEA-SETUP.md | 88 +++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 7a81b432..6b3abeb6 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -127,11 +127,32 @@ these, not a substitute: an agent still has a browser-shaped API token. > 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. Close the PR, delete the branch. +> 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 👤 @@ -139,11 +160,21 @@ these, not a substitute: an agent still has a browser-shaped API token. Three principals, three tokens. Settings → Applications → *Generate New Token* while logged in **as that user**. -| whose | scopes | goes in | -|---|---|---| -| **you** (`fabi`) | `write:issue`, `read:repository` | `~/.sylph-gitea-api-token` | -| `sylph-decoder` | `write:repository`, `write:issue`, `write:notification`, `read:user` | `~/.sylph-gitea-token-decoder` | -| `sylph-port` | same four | `~/.sylph-gitea-token-port` | +| 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' '' > ~/.sylph-gitea-api-token && chmod 600 ~/.sylph-gitea-api-token @@ -157,6 +188,14 @@ containers, exactly like `~/.sylph-claude-token`. `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. @@ -320,11 +359,38 @@ does not: which is what the approvals whitelist is for. * **Gitea's Projects API**, which is why Phase 4 creates no board. -Two things on this list have since been settled rather than assumed: +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**. `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. +* ~~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. From 057bbd438ccaed08439a63dff9367abafc5920c2 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 17:48:58 +0200 Subject: [PATCH 04/10] agents: name what branch protection does not gate, and stop the tool contradicting it Two things that read as protection while being none. Phase 2's rule binds everyone who reaches Gitea through the API or the web, and does not bind anyone with `gitea admin` in the container -- which includes the supervising agent that created the agent accounts and minted their tokens. From that shell the rule is editable and an admin token is one command away. That is the boundary of what the phase buys, not a hole to plug there, and the document read as though the gate were universal. Phases 1 and 2 gate the two CONTAINERISED agents, whose design assumption is that policy lives where they cannot reach it; a supervisor with a host shell is not in that set. And `gitea-setup` finished by telling the reader to go and build a Gitea project board by hand, four sections after the doc explains that a board is a second copy of the state to hand-sync and is precisely the failure that produced a 1,227-line BLOCKED.md. A tool instructing you to do the thing its own documentation argues against is the drift this whole surface exists to end. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- docs/agents/GITEA-SETUP.md | 20 ++++++++++++++++++++ tools/gitea-setup | 11 ++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 7a81b432..665e28e6 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -115,6 +115,26 @@ Neither whitelist is decoration, and neither replaces the other: 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 diff --git a/tools/gitea-setup b/tools/gitea-setup index ebff4650..45fa1977 100755 --- a/tools/gitea-setup +++ b/tools/gitea-setup @@ -113,7 +113,12 @@ if [ "$DRY" = 1 ]; then else echo "labels and bundles are in place at https://$HOST/$REPO/issues" echo - echo "Remaining, by hand in the web UI (the API for Projects lags it):" - echo " Projects -> New Project -> columns:" - echo " Proposed | Approved | In progress | Needs human | Done" + # No board, and this used to say the opposite. Gitea's project board does not + # follow labels, so it would be a SECOND copy of the state to hand-sync -- the + # exact failure that produced a 1,227-line BLOCKED.md. Labels are the truth and + # a saved issue filter gives the same view for nothing. Leaving the old + # "remaining, by hand: Projects -> New Project" line here would have had the + # tool instructing the reader to build the thing the doc argues against. + echo "No project board, deliberately -- labels are the truth. See" + echo "docs/agents/GITEA-SETUP.md Phase 4. Use a saved issue filter instead." fi From a008836ea01aa98708b0c4c1a01c981c28fb3921 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 17:50:50 +0200 Subject: [PATCH 05/10] docs: the tool creates 11 labels, not 12 -- I counted its own definition Caught by the Pi agent against the live instance after Phase 4 ran. The tool creates 5 state/*, 2 agent/*, 4 kind/* = 11. Where the 12 came from is worth a line, because it is a shape that recurs: $ grep -c '^mklabel' tools/gitea-setup 12 $ grep -n '^mklabel' tools/gitea-setup | grep -v ':mklabel "' 74:mklabel() { # name colour description I counted the function DEFINITION as a call. A measurement taken one token away from the thing being measured -- the same shape as reading protection off a settings page and reachability off a DNS record, which is now three today. The version that cannot make this mistake is counting what the instance holds, and that is what found it. --- docs/agents/GITEA-SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 6b3abeb6..beac65ed 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -208,7 +208,7 @@ tools/gitea-setup --dry-run # read it first tools/gitea-setup # idempotent; safe to re-run ``` -Creates 12 labels (the `state/*` set, `agent/*`, `kind/*`) and 4 milestones +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 From 87932e401704cc5996118bba8cdf9557364270cf Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 17:55:28 +0200 Subject: [PATCH 06/10] 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. --- docs/agents/GITEA-SETUP.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 7cb08a9e..45608eda 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -9,17 +9,25 @@ Steps are marked **👤 you** (a decision or a credential only you can make) or ## Where things stand -* ✅ `main` carries the play-tested port work and the rewritten briefs (`cd3a81af`). -* ✅ The briefs already describe this workflow. **They describe tooling that does - not exist yet** — which is why the agents are stopped and must stay stopped - until Phase 7. -* ❌ Nothing exists on the instance: no agent users (only `fabi`), no API - tokens, no labels, no milestones, no branch protection. -* ⏳ The MCP wiring (Phase 5) is written and in the tree; the images have not - been rebuilt, and no token exists for it to use yet. +**Updated 2026-09-04, against the live instance.** Phases 1–4 and 6 are done. -⚠️ **Do not start an agent before Phase 7.** Its brief will tell it to read -notifications and open issues, and it will have no tool that can. +| 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. --- From 7fdcd6943461325389e35347ecc79d633d36d286 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 17:59:51 +0200 Subject: [PATCH 07/10] tools: a missing collaborator is a failure, not a blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --verify's collaborator loop printed ⚪ and continued on 404 without touching `ok`, so the one instrument that checks Phase 1.2 could not report Phase 1.2 being undone. An agent removed from the repository read as "nothing to say" rather than as a gate that is no longer there. It has never fired: Gitea answers that endpoint with permission "read" for a non-collaborator rather than 404, so the case was caught by the role test two lines down. Correct outcome, wrong reason -- the same shape as the check that passed on an instance with no rule at all, and not worth keeping because the luck has held so far. Found by the port agent reading the file rather than running it, which is the only way this one was ever going to surface. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- tools/gitea-protect | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/gitea-protect b/tools/gitea-protect index c1ac40e8..8c52efa7 100755 --- a/tools/gitea-protect +++ b/tools/gitea-protect @@ -172,8 +172,20 @@ def verify(tok): # merge, so a green rule proves nothing on its own. for agent in AGENTS: status, perm = api("GET", f"/collaborators/{agent}/permission", tok) + # 🔴 A MISSING COLLABORATOR IS A FAILURE, not a blank. This branch used + # to print ⚪ and `continue`, leaving `ok` untouched -- so the one + # instrument that checks Phase 1.2 could not report Phase 1.2 being + # undone. An agent removed from the repository read as "nothing to say" + # rather than as a gate that is no longer there. + # + # It never actually fired: Gitea answers this endpoint with permission + # "read" for a non-collaborator rather than 404, so the case was caught + # by the role test below -- by luck, not by design. That is the same + # shape as a check that passes on an instance with no rule at all, and + # it is not worth keeping just because the luck has held. if status == 404: - print(f" ⚪ {agent:<42} not a collaborator (yet)") + print(f" 🔴 {agent + ' is not a collaborator':<42} Phase 1.2 is undone") + ok = False continue if status != 200: print(f" 🔴 {agent:<42} permission unreadable ({status})") From 25bfa1553fed2275cc387162444f34310785fa24 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 18:27:39 +0200 Subject: [PATCH 08/10] protocol: findings before citing code, and checks that were kind once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules that look unrelated and are one failure, plus the change that makes the second enforceable. 1. A FINDING REACHES `main` BEFORE THE CODE THAT CITES IT. A citation resolving only on a peer branch is dead the moment it merges. Not hypothetical: 495 decoder and 366 port commits sit off `main`, and `port/scripts/boot.gd` already cites two docs/re pages present on neither its own branch nor main. 2. A CHECK MAY ONLY SOFTEN AGAINST A CONDITION IT CAN TEST -- the Pi agent's wording, and better than mine, because it is applicable while writing rather than a call to be vigilant. The mechanical form: Can this branch tell the difference between "not yet" and "no longer"? `gitea-protect --verify` printed ⚪ "not a collaborator (yet)" and continued, so the only instrument checking Write-not-Admin could not report that gate being REMOVED. `check-citations` reported peer citations instead of failing them, because under the old topology that was unfixable from the container. Both were correct AND kind when written; neither recorded that the kindness had a scope. Nobody edits these into being wrong -- the world moves and the allowance stays, which is why they survive review. The smell is leniency with an expiry date nobody set; the fix is the testable-condition rule. check-citations gains `--for-merge`, which turns the peer class into a failure. A flag rather than a new default because BOTH readings are still live: mid-work on a topic branch the peer class really is unfixable noise. What the old code could not express is where the code is GOING, and that is a condition the caller can state. Measured on this tree: 19 citations resolve only on a peer branch -- which is the size of the #7-depends-on-#8 edge, not the 2 I had counted in boot.gd. The selftest gains that third class, because a flag whose classification is unexercised is the shape this rule exists to catch. Controlled: emptying PEER_REFS makes the peer case collapse into "nowhere" and the selftest reports 🔴 BROKEN, rc=2. ⚠️ Pre-existing and NOT from this change: the default run already exits 1 on 4 citations of `export/...` paths. Those are the generated tree, gitignored by design, and main's copy of the tool fails identically. The CITE regex treats `export/` as a repo prefix. Reported, not fixed -- it is the port's file and its call whether the regex or the citations are wrong. --- docs/agents/PROTOCOL.md | 34 ++++++++++++++++++++++++++++ tools/port/check-citations | 46 ++++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/agents/PROTOCOL.md b/docs/agents/PROTOCOL.md index 682db7e6..8f812bdf 100644 --- a/docs/agents/PROTOCOL.md +++ b/docs/agents/PROTOCOL.md @@ -155,6 +155,40 @@ rule is written here so you know it, not so it depends on you. A PR you cannot describe in a paragraph is an item that was too big. That is the signal to split it, not to write a longer description. +### 🔴 A finding reaches `main` before the code that cites it + +A citation that resolves only on a peer branch is **dead the moment it merges**. +Open the finding's PR first and make it a dependency of the code's. + +This is not hypothetical and it is not small: **495 decoder commits and 366 port +commits sit off `main`**, so nearly anything either agent re-proposes will hit +it. `port/scripts/boot.gd` already cites two `docs/re/` pages that exist on +neither its own branch nor `main`. + +## Checks that were kind once + +Two rules that look unrelated and are the same failure. + +**A check may only soften against a condition it can test.** + +`gitea-protect --verify` printed ⚪ *"not a collaborator (yet)"* and continued +without failing — so the one instrument that checks Write-not-Admin could not +report that gate being **removed**. `check-citations` reported peer-branch +citations rather than failing them, because under the old branch topology that +was a state nobody could fix. Both were **correct and kind when written**, and +neither recorded that the kindness had a scope. + +The test is mechanical, and you apply it to your own code: + +> **Can this branch tell the difference between *not yet* and *no longer*?** + +If it cannot, it does not get to be lenient. `--verify` could always ask whether +a collaborator exists, so the "yet" was never needed. + +📌 **Nobody edits these into being wrong** — the world moves and the allowance +stays. That is why they survive review, and why the smell is worth naming: +*leniency with an expiry date nobody set.* + `share put --note "…" --for port` records the sender, the time, **the commit they were on**, and whether their tree was dirty. A capture with no provenance is not evidence, it is a picture. diff --git a/tools/port/check-citations b/tools/port/check-citations index 53c824b3..465aefc0 100755 --- a/tools/port/check-citations +++ b/tools/port/check-citations @@ -77,11 +77,28 @@ def main() -> int: open(bad, "w").write("see `docs/port/this-file-does-not-exist-anywhere.md`\n") good = os.path.join(tmp, "good.md") open(good, "w").write("see `docs/port/PORT-MISSION.md`\n") + # The THIRD class, which `--for-merge` turns into a failure. It has to be + # told apart from both others: a peer citation is not dangling (the file + # exists) and does not resolve here (the reader still gets nothing), and + # a scanner that collapsed it into either would make the flag meaningless + # while still passing the two checks above. + peerfile = os.path.join(tmp, "peer.md") + open(peerfile, "w").write("see `docs/re/f5-a-press-snaps-the-plate.md`\n") + rp, pp, np_ = scan([peerfile]) + _, _, nb = scan([bad]) r, _, ng = scan([good]) - ok = len(nb) == 1 and len(ng) == 0 and r == 1 - print("selftest: planted dangling caught=%s, real citation passed=%s -> %s" - % (len(nb) == 1, len(ng) == 0 and r == 1, "ok" if ok else "🔴 BROKEN")) + caught = len(nb) == 1 + passed = len(ng) == 0 and r == 1 + peer_ok = len(pp) == 1 and rp == 0 and len(np_) == 0 + ok = caught and passed and peer_ok + print("selftest: planted dangling caught=%s, real citation passed=%s, " + "peer-branch classed separately=%s -> %s" + % (caught, passed, peer_ok, "ok" if ok else "🔴 BROKEN")) + if not peer_ok: + print(" 🔴 --for-merge cannot mean anything if the peer class is " + "not distinguished; got resolves=%d peer=%d nowhere=%d" + % (rp, len(pp), len(np_))) return 0 if ok else 2 files = sorted(glob.glob("docs/port/*.md")) @@ -89,9 +106,30 @@ def main() -> int: total = resolves + len(peer) + len(nowhere) print("citations of repo paths in docs/port/*.md: %d" % total) print(" resolve here : %d" % resolves) - print(" on a peer branch, not merged: %d (reported, not failed)" % len(peer)) + # 🔴 --for-merge TURNS THE PEER CLASS INTO A FAILURE. + # + # Reporting-not-failing was right when it was written: a peer-branch + # citation was "a state nobody in this container can fix", so failing on it + # would have been red for something unactionable. Under the pull-request + # workflow that stopped being true -- a PR into `main` is EXACTLY where it + # becomes fixable, by opening the finding's PR first and depending on it. + # The citation is dead the moment this merges, so the merge is the last + # place the leniency can still be withdrawn. + # + # Left as a flag rather than made unconditional, because both readings are + # still live: mid-work on a topic branch the peer class really is unfixable + # noise. The difference the old code could not express is WHERE the code is + # going, and that is a condition the caller can state. + merging = "--for-merge" in sys.argv + label = "🔴 FAILS (--for-merge)" if merging else "reported, not failed" + print(" on a peer branch, not merged: %d (%s)" % (len(peer), label)) for m, (src, ref) in sorted(peer.items()): print(" %-52s %s <- %s" % (m, ref.split("/")[-1], os.path.basename(src))) + if peer and merging: + print("\n🔴 %d citation(s) resolve only on a peer branch." % len(peer)) + print(" After this merges they resolve NOWHERE -- the reader gets a dead") + print(" path. Land the finding first and make it a dependency of this PR.") + return 1 if nowhere: print(" 🔴 resolve NOWHERE : %d" % len(nowhere)) for m, src in sorted(nowhere.items()): From c457320210eb9859aa8b8d08541d5b73cc5a71a7 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 21:13:43 +0200 Subject: [PATCH 09/10] ci: build for the machine that exists, on the runner that exists This workflow has never once gone green on this instance: 23 runs cancelled, 2 waiting, zero successes. Not a regression -- it has been decorative since it was written, because it describes GitHub's hosted fleet and runs on one self-hosted aarch64 Pi advertising ["ubuntu-latest","ubuntu-24.04", "ubuntu-22.04"]. Two failures, both configuration rather than code: `windows-latest` and `macos-latest` match no runner label, so those jobs sit in WAITING for ever and the RUN never reaches a terminal state. A pull request's checks therefore never resolve either way -- not red, just never finished, which is worse than red because a red check tells you something. Removed: a second architecture here needs a second runner, not a second matrix row. `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a cross-compile, and `wayland-sys`'s build script dies on it with "pkg-config has not been configured to support cross-compilation". Dropped; the native job now builds for its host. NOT touched, deliberately: the WASM and Formatting jobs still fail, on real code state rather than on configuration -- `getrandom` needs the `wasm_js` backend for wasm32-unknown-unknown, and `cargo fmt --check` reports a ~13,000 line diff across the tree. Editing those two into passing is precisely the leniency with an expiry date nobody sets that PROTOCOL.md now forbids. They are issues, not workflow lines. (One latent defect noted while reading: `jetli/trunk-action` fetches trunk-x86_64-unknown-linux-gnu onto this aarch64 host. It has never been reached because the WASM check fails first, and it will bite the moment that is fixed.) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aa22bad..6b5fccf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,36 +10,45 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +# ── What this file may assume about where it runs ──────────────────────────── +# +# It runs on ONE self-hosted runner: `rpi5-runner`, aarch64, advertising +# ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04"]. Nothing else exists. +# +# This file was written for GitHub's hosted fleet — three operating systems and +# x86_64 throughout — and had never once gone green here: 23 runs cancelled, 2 +# waiting, zero successes. Two separate reasons, and both are configuration +# describing a world that is not this one: +# +# * `windows-latest` / `macos-latest` match no runner label, so those jobs sit +# in WAITING for ever. The run therefore never reaches a terminal state, and +# a pull request's checks never resolve either way — not red, just never +# finished. That is worse than a failure: a red check tells you something. +# * `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a +# cross-compile, and `wayland-sys`'s build script dies on it — +# "pkg-config has not been configured to support cross-compilation". +# +# So: one job, on the machine that exists, building for the machine that exists. +# If a second architecture is ever wanted here it needs a second RUNNER, not a +# second matrix row. + jobs: - # ── Native builds: Windows, macOS, Linux ──────────────────────────────────── + # ── Native build, on the one runner there is ──────────────────────────────── native: - name: Native — ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: macos-latest - target: aarch64-apple-darwin + name: Native — linux + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - name: Cache Cargo registry and build uses: Swatinem/rust-cache@v2 # Linux: install Bevy's system dependencies (X11, Wayland, audio) - name: Install Linux system dependencies - if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y \ @@ -52,16 +61,16 @@ jobs: pkg-config - name: Check (fast compile check) - run: cargo check --workspace --target ${{ matrix.target }} + run: cargo check --workspace - name: Build (debug) - run: cargo build --workspace --target ${{ matrix.target }} + run: cargo build --workspace - name: Run tests - run: cargo test --workspace --target ${{ matrix.target }} + run: cargo test --workspace - name: Clippy - run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings + run: cargo clippy --workspace -- -D warnings # ── WASM / Web build ───────────────────────────────────────────────────────── wasm: From a3d99adaa657d9e03be19a39ac8cb4e6f0db68da Mon Sep 17 00:00:00 2001 From: sylph-pi Date: Sat, 5 Sep 2026 15:39:44 +0200 Subject: [PATCH 10/10] ci: install the clippy component the Clippy step needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dtolnay/rust-toolchain@stable` installs a minimal profile. The `native` job named no components, so every run that reached the Clippy step died on error: 'cargo-clippy' is not installed for the toolchain 'stable-aarch64-unknown-linux-gnu' before clippy read a line of source. That is not a lint result; the step had never run. The `fmt` job below always named `components: rustfmt` correctly — this one never did. Two lines of behaviour change. The rest is the comment explaining why the step is left gating on `-D warnings` rather than softened: the workspace is not clippy-clean (run 203's build alone emits ~13 rustc warnings that `-D warnings` promotes to errors), and `continue-on-error` cannot tell "debt not yet paid" from "debt paid". That debt is scoped in #13, the way the rustfmt debt is in #12. Run 203 is what made this visible. With the aarch64 fix in c457320 the native job got all the way through: cargo check --workspace ok 10m01s cargo build --workspace ok 19m04s cargo test --workspace ok 16m22s 214 passed, 0 failed cargo clippy --workspace toolchain error Refs #13 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b5fccf8..19dc9943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,15 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain + # `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no + # more. Components have to be named. Without this line the Clippy step + # below dies on "'cargo-clippy' is not installed for the toolchain + # 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it + # is the step never having run. The `fmt` job below always got this + # right; this one never did. uses: dtolnay/rust-toolchain@stable + with: + components: clippy - name: Cache Cargo registry and build uses: Swatinem/rust-cache@v2 @@ -69,6 +77,20 @@ jobs: - name: Run tests run: cargo test --workspace + # This step has never once executed on this codebase: the toolchain above + # shipped without the component, so every run died on "not installed" + # before clippy saw a line of source. Its result was never pass or fail, + # only unmeasured. With the component installed it becomes a real check, + # and the first honest thing it will report is that the workspace is not + # clean — the build already emits ~13 plain rustc warnings (unused + # imports, unused variables, needless `mut`, dead fields) that + # `-D warnings` promotes to errors, before clippy's own lints are counted. + # + # Left gating on purpose. A red check that measures something is worth + # more than a green one that measures nothing, and the alternative — + # `continue-on-error`, or dropping `-D warnings` — cannot tell "debt not + # yet paid" from "debt paid", which is the shape PROTOCOL.md forbids. + # The debt is scoped in #13, as the rustfmt debt is in #12. - name: Clippy run: cargo clippy --workspace -- -D warnings