Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the runbook could not have known it needed. gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release publishes and smoke-tested with `--version` at build time, so a bad pin fails the build instead of the agent. Each entrypoint registers it at user scope for that container's own identity, remove-then-add so a restart is idempotent. The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in cleartext into ~/.claude.json, which every session in the container reads; GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in its read-only mount. Verified against the binary's own --help, not assumed. The tool filter stops being an experiment. The names are in the release README: each agent gets issues, notifications, labels, milestones and pull requests, and NOT `pull_request_review_write`. That one matters because separate identities open a hole the runbook did not name: Gitea refuses to let an author approve their own pull request, and does nothing about sylph-decoder approving sylph-port's. Two agents could satisfy `required_approvals = 1` between themselves and then merge, since branch protection blocks pushes to main and never blocked merges. Withholding the tool is defence in depth; the controls are in branch protection, and both docs now say so: approvals whitelisted to the human so an agent's approval does not count, merges whitelisted to the human so an approved PR is still merged by a person. Phase 2's check gains the step that actually tests it -- approve the throwaway PR yourself, then confirm the agent STILL has no merge button. Without that step, the check passes on an instance where the agents can merge each other's work. Also settles two entries on the runbook's own "not verified" list: the tool filter names, and the Gitea version (1.25.5, whose API schema carries enable_merge_whitelist and enable_approvals_whitelist under those names). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
280 lines
13 KiB
Bash
Executable File
280 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Launcher for the Godot port agent.
|
|
#
|
|
# ./sylph-port build build the image
|
|
# ./sylph-port shell interactive shell
|
|
# ./sylph-port loose [task] detached, self-running on a fixed interval
|
|
# ./sylph-port logs -f follow it
|
|
# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running)
|
|
# ./sylph-port remote a link to chat with it from anywhere
|
|
# ./sylph-port stop stop it
|
|
#
|
|
# Env:
|
|
# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4)
|
|
# 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 ────────────────────────────────────────────────
|
|
#
|
|
# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent
|
|
# or a human is working in. Sharing a working tree between two writers means
|
|
# files change under whoever is mid-edit, and a `git add -A` by one sweeps up
|
|
# the other's work. That happened; do not re-create it.
|
|
#
|
|
# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into
|
|
# a repo's config captures every commit made in that tree, including a
|
|
# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and
|
|
# nobody else's.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# The repo to mount at /work. Overridable so this script can be run from a
|
|
# worktree -- a human editing on `main` must not repoint the agent's checkout.
|
|
# The checkout is now a volume the container clones into, so this is only used
|
|
# to locate things that live BESIDE the repository -- the disc, chiefly. Three
|
|
# levels up from docker/port/ is the workspace root.
|
|
WORKSPACE="$(cd "$HERE/../../.." && pwd)"
|
|
IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}"
|
|
NAME="${SYLPH_PORT_NAME:-sylpheed-port}"
|
|
|
|
# Half of what the RE container takes. That container builds a C++ emulator and
|
|
# drives it; this one converts assets and runs Godot. Two full-size containers
|
|
# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding
|
|
# constraint, and an over-committed build has crashed this machine before.
|
|
CPUS="${SYLPH_PORT_CPUS:-3}"
|
|
MEM_GB="${SYLPH_PORT_MEM_GB:-4}"
|
|
|
|
DISC="${SYLPH_DISC:-$(cd "$WORKSPACE/sylph_extract" 2>/dev/null && pwd || true)}"
|
|
|
|
docker_args() {
|
|
local _out=(
|
|
--name "$NAME"
|
|
--hostname sylph-port
|
|
--cpus "$CPUS"
|
|
--memory "${MEM_GB}g"
|
|
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
|
|
# thrashes the whole host
|
|
--pids-limit 2048
|
|
-v "sylpheed-port-repo:/work"
|
|
-v "sylpheed-port-target:/sylph-home/port/target-container"
|
|
# CARGO_HOME on a volume, not the container overlay: without it the pinned
|
|
# decoder source is re-fetched from the network on every fresh container.
|
|
-v "sylpheed-port-cargo:/sylph-home/port/.cargo"
|
|
-v "sylpheed-port-claude:/sylph-home/port/.claude"
|
|
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude.seed:ro"
|
|
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
|
|
-v "sylpheed-exchange:/exchange"
|
|
-e "PROJECT_DIR=/work"
|
|
# Same guardrail as the decoder, added the same day and for its reason: the
|
|
# decoder was OOM-killed mid-task by a RAW `cargo test --release`, which
|
|
# never reaches `build-export`/`build-reference-cli` and so never saw their
|
|
# CARGO_BUILD_JOBS. This container is smaller (4 GB, 3 CPUs), so the same
|
|
# bypass is at least as easy to hit here.
|
|
-e "CARGO_BUILD_JOBS=${SYLPH_PORT_JOBS:-2}"
|
|
-e "SYLPH_EXCHANGE=/exchange"
|
|
-e "SYLPH_AGENT=port"
|
|
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
|
|
)
|
|
|
|
|
|
if [ -n "$DISC" ] && [ -d "$DISC" ]; then
|
|
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
|
else
|
|
echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2
|
|
echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2
|
|
fi
|
|
|
|
# Commits are attributed to the port agent, via the environment so that
|
|
# nothing is written into the repository's config. See constraint 2 above.
|
|
_out+=(
|
|
-e "GIT_AUTHOR_NAME=Sylpheed port agent"
|
|
-e "GIT_AUTHOR_EMAIL=port-agent@localhost"
|
|
-e "GIT_COMMITTER_NAME=Sylpheed port agent"
|
|
-e "GIT_COMMITTER_EMAIL=port-agent@localhost"
|
|
)
|
|
|
|
# Mounted as `.host` and copied to a writable file by the entrypoint, exactly
|
|
# like .claude.json. `credential.helper=store` REWRITES its file after a
|
|
# successful auth -- it writes a temp file and renames over the target, and
|
|
# renaming onto a bind-mount point gives EBUSY, which surfaces as
|
|
# `fatal: unable to write credential store: Device or resource busy`.
|
|
#
|
|
# The push still succeeds, which is the actual danger: a `fatal:` line that is
|
|
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
|
# rw would also silence it, but then the container can clobber the host's
|
|
# credential file; copying cannot.
|
|
# ── Claude auth ──
|
|
# See the decoder's launcher for the full note. Short version: the OAuth
|
|
# credential file holds a refresh token that ROTATES ON USE, three clients were
|
|
# seeded from one copy, and the loser of a rotation race has its tokens CLEARED
|
|
# to empty strings by Claude Code and parks at "Login expired". A long-lived
|
|
# `claude setup-token` credential passed in the environment has nothing to
|
|
# rotate and no file to empty. Same subscription, not API billing.
|
|
# Inert until the file exists.
|
|
# Pass through: set SYLPH_OWN_LOGIN=1 when this container has run
|
|
# `claude auth login` itself, so the entrypoint never copies the host's
|
|
# rotating credentials over its own grant. Remote Control needs a real
|
|
# login -- the long-lived token does not carry the sessions scope.
|
|
[ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN")
|
|
|
|
local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
|
if [ -f "$claudetok" ]; then
|
|
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")
|
|
echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2
|
|
fi
|
|
|
|
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
|
if [ -f "$gitcred" ]; then
|
|
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
|
else
|
|
echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2
|
|
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 ──
|
|
# This block did not exist until 2026-09-01, and its absence was reported as
|
|
# a symptom rather than a cause: **the port agent reported low FPS.** Godot 4
|
|
# renders through Vulkan, and with nothing passed through it was falling back
|
|
# to lavapipe — software Vulkan, which is correct and slow. The decoder's
|
|
# launcher had this logic; this one never did, so the container that actually
|
|
# runs a renderer was the one without a GPU.
|
|
#
|
|
# Three distinct cases, and conflating them is how you end up believing you
|
|
# have hardware Vulkan while running llvmpipe:
|
|
#
|
|
# NVIDIA needs the NVIDIA Container Toolkit (`--gpus all`). Passing
|
|
# /dev/dri alone does NOT work — Mesa cannot drive an NVIDIA card,
|
|
# and the proprietary userspace lives outside the image.
|
|
# Mesa (AMD/Intel) works with a plain /dev/dri passthrough plus the
|
|
# host's render/video GIDs.
|
|
# neither software Vulkan (lavapipe): correct, and slow.
|
|
if [ "${SYLPH_VULKAN:-auto}" = "sw" ]; then
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
|
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q nvidia; then
|
|
_out+=(--gpus all)
|
|
else
|
|
echo "==> NOTE: NVIDIA GPU found but the NVIDIA Container Toolkit is not" >&2
|
|
echo " installed, so Godot falls back to lavapipe (software — correct," >&2
|
|
echo " slow, and the reason for any low-FPS report). To enable it:" >&2
|
|
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \\" >&2
|
|
echo " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg" >&2
|
|
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \\" >&2
|
|
echo " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\" >&2
|
|
echo " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list" >&2
|
|
echo " sudo apt update && sudo apt install -y nvidia-container-toolkit" >&2
|
|
echo " sudo nvidia-ctk runtime configure --runtime=docker" >&2
|
|
echo " sudo systemctl restart docker" >&2
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
fi
|
|
elif [ -e /dev/dri/renderD128 ]; then
|
|
_out+=(--device /dev/dri)
|
|
for g in render video; do
|
|
gid=$(getent group "$g" | cut -d: -f3 || true)
|
|
[ -n "$gid" ] && _out+=(--group-add "$gid")
|
|
done
|
|
else
|
|
_out+=(-e SYLPH_VULKAN=sw)
|
|
fi
|
|
|
|
printf '%s\n' "${_out[@]}"
|
|
}
|
|
|
|
mapfile -t ARGS < <(docker_args)
|
|
|
|
case "${1:-}" in
|
|
build)
|
|
exec docker build -t "$IMAGE" \
|
|
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE"
|
|
;;
|
|
|
|
shell)
|
|
TTY=(-i); [ -t 0 ] && TTY=(-it)
|
|
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash
|
|
;;
|
|
|
|
loose)
|
|
shift
|
|
TASK="${1:-}"
|
|
if [ -z "$TASK" ]; then
|
|
if [ -f "$HERE/../../docs/agents/port-loop.md" ]; then
|
|
TASK="$(cat "$HERE/../../docs/agents/port-loop.md")"
|
|
else
|
|
TASK="Work the milestones in docs/MISSION.md."
|
|
fi
|
|
fi
|
|
# A FIXED interval, not self-pacing: the one thing an agent deep in a
|
|
# milestone reliably forgets is the bookkeeping after it, and a forgotten
|
|
# wake-up silently ends the loop.
|
|
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
|
|
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
|
|
echo "==> repo: own clone in volume sylpheed-port-repo -> /work"
|
|
# `unless-stopped`, NOT `on-failure`: an OOM kill on this setup reports
|
|
# `OOMKilled: true` with **ExitCode 0**, so `on-failure` would read a memory
|
|
# kill as a clean finish and leave the agent down. Restarting into the same
|
|
# death is handled in the entrypoint, which refuses to `--continue` when the
|
|
# last start was under two minutes ago.
|
|
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
|
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
|
echo
|
|
echo " running detached as '$NAME'."
|
|
echo " ./sylph-port remote link to chat with it from anywhere"
|
|
echo " ./sylph-port logs -f follow it"
|
|
echo " ./sylph-port attach chat with it locally"
|
|
echo " ./sylph-port stop stop it"
|
|
;;
|
|
|
|
logs) shift; exec docker logs "$@" "$NAME" ;;
|
|
attach) exec docker attach "$NAME" ;;
|
|
stop) exec docker rm -f "$NAME" ;;
|
|
|
|
remote)
|
|
echo "waiting for the session to register" >&2
|
|
for _ in $(seq 60); do
|
|
# Read the container LOG, not the session transcript. The transcript
|
|
# records every command run inside the container -- including this
|
|
# lookup -- so grepping it matched our own pattern string back.
|
|
url=$(docker logs "$NAME" 2>&1 \
|
|
| grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \
|
|
| tail -1 || true)
|
|
[ -n "$url" ] && { echo "$url"; exit 0; }
|
|
sleep 2
|
|
done
|
|
echo "no session link yet — try ./sylph-port logs -f" >&2
|
|
exit 1
|
|
;;
|
|
|
|
*)
|
|
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
|
;;
|
|
esac
|