diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aa22bad..19dc9943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,36 +10,53 @@ 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 + # `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: - targets: ${{ matrix.target }} + components: clippy - 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 +69,30 @@ 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 + # 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 --target ${{ matrix.target }} -- -D warnings + run: cargo clippy --workspace -- -D warnings # ── WASM / Web build ───────────────────────────────────────────────────────── wasm: 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..45608eda 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -9,15 +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, no API tokens, no labels, no - branch protection, no MCP. +**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. --- @@ -57,7 +67,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 | |---|---|---| @@ -65,6 +102,46 @@ 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. + +### 🔴 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 > @@ -74,10 +151,35 @@ 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. 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 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. + +### ⚠️ 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. --- @@ -86,11 +188,21 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`: 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 @@ -104,6 +216,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. @@ -116,7 +236,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 @@ -132,34 +252,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. --- @@ -211,8 +369,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. @@ -222,9 +382,43 @@ 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. + +Settled since, rather than assumed: + +* ~~the `--tools` filter names~~ — read out of the pinned release, and the + binary's `--help` run directly. Phase 5 lists the set. +* ~~the exact Gitea version~~ — **1.25.5**, confirmed independently from *both* + machines. `enable_merge_whitelist`, `enable_approvals_whitelist` and + `block_admin_merge_override` are all present in this instance's own API + schema, so the Phase 2 settings exist under those names on the Branches screen. +* ~~which machine can reach what~~ — the desktop reaches the Gitea API fine. + The token split in Phase 3 is about which session runs which script, and an + earlier draft that justified it as a network constraint was wrong. + +### Wrong, not merely unverified + +Kept separate, because "I had not checked" and "I asserted the opposite" are +different failures and only the second is worth a heading: + +* **that requiring an approval closes the gate.** It does not. Merging ignores + the push whitelist entirely, and any Write collaborator is an official + reviewer — so the first version of Phase 2 would have let the two agents + approve each other and merge. Both whitelists exist because of it. +* **that the check could catch that.** It could not: with the approval + requirement unmet, Gitea offers *nobody* a merge button, so the original + steps 1–3 pass on a completely unprotected instance. Step 4 is the test. +* **that the `fabi` token should gain `write:repository`.** That scope is a push + credential. +* **that the desktop could not reach Gitea.** It can; `curl` was being refused + by a local permission prompt, which is not the same thing and was read as if + it were. + +The first two were caught by the other agent. The pattern in all four is one +thing: **a property was inferred from something adjacent to it** — protection +from a settings page, reachability from a DNS record — instead of being tested +directly. That is the same failure the port's frozen-splash instruments made, +in a document about avoiding it. 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/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 diff --git a/tools/gitea-protect b/tools/gitea-protect new file mode 100755 index 00000000..8c52efa7 --- /dev/null +++ b/tools/gitea-protect @@ -0,0 +1,243 @@ +#!/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) + # 🔴 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 + ' is not a collaborator':<42} Phase 1.2 is undone") + ok = False + 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()) 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 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()):