Merge pull request 'Gitea working surface: MCP, branch protection, runbook, and two protocol rules' (#10) from agents/gitea-mcp into main
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
69
.github/workflows/ci.yml
vendored
69
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
# SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent)
|
||||
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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://<user>:<token>@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 ──
|
||||
|
||||
@@ -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' '<token>' > ~/.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 <host>`, 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 <url>`, `-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.
|
||||
|
||||
@@ -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 <file> --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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
243
tools/gitea-protect
Executable file
243
tools/gitea-protect
Executable file
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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()):
|
||||
|
||||
Reference in New Issue
Block a user