Files
spatial-agent-app/.gitea/workflows/agent-run.yml
T
mathias dd3f607744
CD / Detect unsubstituted template (push) Successful in 1s
CD / Build & Import (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Deploy via GitOps (push) Failing after 0s
ci: add agent-run workflow (copied from cobalt-dingo)
2026-10-06 13:25:49 +00:00

310 lines
17 KiB
YAML

name: agent run
# Prototype of the agent-in-CI substrate (2026-08-27).
#
# The bet: the trigger, RBAC, audit trail, isolation and rollback should come
# from the CI platform rather than from a bespoke dispatcher. dispatch's watcher
# reimplemented all five and produced three control-plane defects in one day of
# real use (dispatch#53, #54, #55) plus an eight-week silent outage. Its
# EXECUTOR, by contrast, produced five commits with green CI — so this keeps the
# executor (`dispatch run`) and deletes the rest.
#
# What the platform gives us for free here:
# trigger — issues.labeled, no polling CronJob to go silently dead
# RBAC — who may apply the label is Gitea's permission model
# audit — one Actions run per attempt, linked to the issue, retained
# isolation — the runner, not a shared long-lived pod
# rollback — revert the PR
#
# THE AGENT NEVER PUSHES. It edits files in the checkout; this job creates the
# branch, commits, and opens the PR. That is the important difference from the
# issue flow, where the agent held a push credential and pushed to main. Agent
# edits to workflows or tests now arrive as a reviewable diff (dispatch#55).
#
# CORRECTED 2026-09-17: this said "behind branch protection", and there was none.
# The API returned {"protected": false} for this repo, dispatch and hyperguild
# alike. What actually held the line was "Assert origin/main did not move" below,
# which is a DETECTOR, not a preventer -- it caught both real escapes after the
# fact. Protection with a push whitelist and a rubric review on the agent lane
# landed in ADR-0010; the required-status-check half is #112 and is not live yet,
# so do not read this line as a wall again until that closes.
#
# Deliberately NOT `status:dispatch`: this repo is still in dispatch-repos.txt,
# so reusing that label would fire the CronJob and this workflow for the same
# issue.
#
# REQUIRED SECRETS (all set on this repo since 2026-09-02):
# LITELLM_API_KEY — gateway key. A rotated one fails the run in ~60ms
# with a LiteLLM 401 indistinguishable from a
# missing key, and nothing alerts on it.
# LITELLM_BASE_URL — http://litellm.ai-stack.svc.cluster.local:4000/v1
# DISPATCH_BOT_TOKEN_RO — read:repository + read:issue, dispatch-bot account
#
# The runner is an in-cluster pod (`gitea-runner` namespace), so the service DNS
# resolves and there are no NetworkPolicies in either namespace to block it.
# This is the same URL the dispatch CronJob already uses successfully.
on:
issues:
types: [labeled]
concurrency:
# One agent run per issue. Also stops a label-flap from starting a fleet.
group: agent-run-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
run:
# RESUMED 2026-09-03 (dispatch#67, risk/high). Was `if: false` after run 5228
# against cobalt-dingo#14 proved "THE AGENT NEVER PUSHES" (line 19) false:
# the agent used the GITEA_TOKEN handed to the "Run the agent" step to write
# straight to main via the gitea MCP write tool, bypassing the run_bash
# denylist that was supposed to be the only push-capable surface. Resuming
# now that fix #1 has landed (dispatch@0044a4f): `dispatch run`'s CI path
# constructs its executors with CIPolicy, not DefaultPolicy, and CIPolicy
# has no forge-mutating MCP tool at all — file_write_branch is not merely
# denylisted, it is not granted. Fix #4 below is the verifier: it does not
# trust that claim, it checks origin/main directly.
#
# Fix #2 landed 2026-09-05: the two steps that hand a credential to the
# UNTRUSTED agent now use `secrets.DISPATCH_BOT_TOKEN_RO`, a token owned by
# the dedicated `dispatch-bot` account and scoped `read:repository` +
# `read:issue`. Read-only comes from the SCOPE, not from per-repo
# permission: scope is the half that travels with the token, so a
# read-only-by-permission credential would still be push-capable against
# every other repo its account can write to -- the escape would relocate
# rather than close. Verified before wiring: GetIssue 200, clone OK, and a
# real `git push` REFUSED.
#
# The "Open a PR" step below deliberately KEEPS the write-capable
# secrets.GITEA_TOKEN. That credential is the JOB's, used after the agent
# has finished, and opening the PR is the one thing the design requires be
# writable. Do not "tidy" it to the read-only token.
#
# The secret is NOT named GITEA_TOKEN_RO as dispatch#67 originally proposed:
# Gitea reserves the GITEA_/GITHUB_ secret-name prefix and rejects creating
# one with 400 invalid variable or secret name. The env var inside each step
# is still GITEA_TOKEN, which is what `dispatch run` reads.
#
# Still not done here (issue's fix #5): not trusting the agent's own summary
# line is a downstream-consumer discipline change, not a workflow change.
# PAUSED 2026-09-07 after run 5915 on issue #11 escaped a second time, and
# RESUMED 2026-09-15 once both halves of that escape were closed.
#
# The pause comment blamed "a hosted MCP server carrying its own
# credential". That was wrong, and is corrected here rather than left to
# mislead the next reader: the agent used plain `git commit` + `git push`
# from the checkout. Two things had to be true at once, and each is now
# false independently --
#
# CAPABILITY: CIPolicy rewrote MCPTools but inherited BashPrefixes
# verbatim, and DefaultPolicy carries a bare "git", which authorises
# `git push` as much as `git status`. Only COMPOUND commands were ever
# refused (the log shows `|` and `&&` denied); the escape used neither.
# Closed by dispatch#72: CI mode gets git status|diff|log|show.
#
# CREDENTIAL: actions/checkout had planted a write-capable auth header
# in .git/config. Closed by persist-credentials: false above.
#
# Fix #4 below stays exactly as it is. It caught BOTH escapes within a
# minute, because it checks the fact rather than trusting the policy --
# including on this resume, where the policy is once again believed correct.
if: >-
github.event.label.name == 'agent/ci-run' ||
contains(github.event.issue.labels.*.name, 'agent/ci-run')
runs-on: self-hosted
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# dispatch#67 round two. By default this action writes
# http.<host>.extraheader = AUTHORIZATION: basic ***
# -- the JOB's write-capable token -- into .git/config, and only
# strips it in a post-step that runs AFTER the agent has finished.
# For the whole agent window the untrusted model sits inside a
# repository that can push to main with no credential of its own.
# That is how run 5915 escaped while the read-only PAT handed to the
# agent step (fix #2) was working exactly as designed: that variable
# was never the credential in play. The PR step supplies its own.
persist-credentials: false
# dispatch#67 fix #4, the verifier for fix #1. "The agent's tools no
# longer include a forge write" is a claim about CIPolicy; this checks
# the fact CIPolicy exists to guarantee, independent of trusting the
# policy code, the executor's own report, or anything the agent said.
# Per the estate's own rule, a gate without a case proving it fails when
# it should is a claim, not a control -- this is that case.
- name: Record origin/main before the agent runs
run: git rev-parse origin/main > /tmp/main-before.txt
- name: Build the executor
env:
GITEA_TOKEN: ${{ secrets.DISPATCH_BOT_TOKEN_RO }}
# The runner's filesystem PERSISTS between jobs — a fixed clone path
# works on the first run and fails on every one after with
# "destination path already exists". Unique dir per run; the binary
# path is a file so overwriting it is fine.
run: |
set -euo pipefail
git config --global credential.helper '!f() { echo username=ci; echo password=$GITEA_TOKEN; }; f'
src="$(mktemp -d)"
git clone --depth 1 https://git.d-ma.be/mathias/dispatch.git "$src"
( cd "$src" && go build -o /tmp/dispatch-bin ./cmd/dispatch )
/tmp/dispatch-bin run 2>&1 | head -1 || true
- name: Run the agent
env:
GITEA_TOKEN: ${{ secrets.DISPATCH_BOT_TOKEN_RO }}
LITELLM_BASE_URL: ${{ secrets.LITELLM_BASE_URL }}
LITELLM_API_KEY: ${{ secrets.LITELLM_API_KEY }}
# infra#347/dispatch#68: repointed from the dead berget/kimi-k2.6.
LITELLM_EXECUTORS: "kimi=berget/kimi-k3,qwen=koala/qwen36-35b-a3b,glm=berget/glm-4.7-fp8"
DISPATCH_KIMI_MAX_TURNS: "80"
# signals#47: event values reach the shell only through env, never
# through `${{ }}` expansion inside run:.
ISSUE_NUMBER: ${{ github.event.issue.number }}
# Exit status is the contract: non-zero means no work happened, and the
# job stops rather than opening an empty PR.
run: /tmp/dispatch-bin run "$GITHUB_REPOSITORY" "$ISSUE_NUMBER" .
# dispatch#67 fix #4. Independent of the diff check below: a forge write
# does not touch this checkout's working tree at all, so a clean `git
# diff` here is exactly what run 5228 saw while a commit was landing on
# main through a different door. Fails the job outright -- this is not
# "no PR to open", it is "the sandbox did not hold".
- name: Assert origin/main did not move
# Runs even if "Run the agent" itself failed. A step-failure default
# would skip this on exactly the run where an escape is most plausible
# -- the agent doing something out-of-band and THEN erroring out never
# gets checked otherwise.
if: always()
run: |
git fetch origin main -q
before="$(cat /tmp/main-before.txt)"
after="$(git rev-parse origin/main)"
if [ "$before" != "$after" ]; then
echo "::error::origin/main moved during the run ($before -> $after)." \
"The agent wrote to the forge outside this job's own commit/PR path." >&2
exit 1
fi
- name: Open a PR if the agent changed anything
env:
# cobalt-dingo#101. The PR is opened as `dispatch-bot`, NOT as the
# Actions identity, because Gitea does not fire workflows for events
# its own identity creates. A PR opened by gitea-actions therefore got
# zero runs -- no CI, and no rubric review either, so ADR-0010's
# "on the agent lane it BLOCKS" could never fire on the agent lane.
# Verified 2026-09-17: a dispatch-bot-authored PR produces
# pull_request runs; a gitea-actions one produces none.
#
# The token is scope write:repository, which in Gitea is per-CATEGORY
# and therefore INCLUDES merge. The bot is stopped from merging by
# branch protection (enable_merge_whitelist on main), not by the
# scope -- an identity gate is only as real as the identity
# separation beneath it (agentsquad#47). Verified mechanically with
# this exact credential before wiring it here:
# open a PR -> 201
# merge that PR -> 405 User not allowed to merge PR
# commit to main -> 403 user cannot commit to repo
# Re-run those three after any branch-protection change.
GITEA_TOKEN: ${{ secrets.AGENT_PR_BOT_TOKEN }}
AGENT_PR_DEPLOY_KEY: ${{ secrets.AGENT_PR_DEPLOY_KEY }}
# signals#47: the issue title is attacker-controlled (anyone who can
# open an issue writes it). It used to be expanded by `${{ }}` straight
# into the curl -d string below, i.e. into the shell source, in the
# step that holds the PR bot token and the write deploy key. A title
# containing `$(...)` or a `"` ran as code. It now arrives only as an
# env var and is JSON-encoded by python3, never parsed by the shell.
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
# dispatch#74: `git diff` does not see UNTRACKED files, so an agent
# whose work is entirely NEW files -- adding a test file to a repo
# that has none, say -- produces two clean diffs and is reported as
# having done nothing. veckomeny run 8238 wrote internal/web/index_test.go,
# verified it by breaking the template on purpose, and had the whole
# run discarded here. cobalt-dingo never hit it because its agent
# tasks modified files that already existed.
#
# git status --porcelain covers modified, staged, deleted AND
# untracked, and respects .gitignore so generated output is not
# counted as work.
if [ -z "$(git status --porcelain)" ]; then
# dispatch#67 fix #3: "no changes" used to exit 0. The job cannot
# distinguish "the agent did nothing" from "the agent did it
# somewhere this checkout can't see" -- run 5228 was the second
# case wearing the first case's exit code. Fail and let a human
# look; a genuinely idle run costs one red job, which is cheap
# next to what a silent one already cost.
echo "::error::agent made no changes in the checkout — nothing to open." \
"Verify by hand this was truly a no-op before treating it as one." >&2
exit 1
fi
branch="agent/issue-$ISSUE_NUMBER"
git config user.name "agent-ci"
git config user.email "agent-ci@d-ma.be"
git checkout -b "$branch"
git add -A
git commit -m "agent: address #$ISSUE_NUMBER" -m "Opened by the agent-run workflow. Closes nothing automatically — a human reviews, merges and closes."
# Push over SSH, not HTTPS. persist-credentials: false left this
# checkout with no auth, and the obvious replacement -- a credential
# helper feeding secrets.GITEA_TOKEN -- gets 403: the Actions token
# is not push-capable on this instance (run 7157 committed
# agent/issue-15 fine, then "fatal: ... The requested URL returned
# error: 403").
#
# SSH from the runner is the pattern the rest of the estate already
# uses for exactly this: dispatch's own CD pins the infra manifest
# via ssh://git@10.0.1.20:30022 and notes "every repo's self-hosted
# runner -- no new credential needed here". Port 30022 is the
# NodePort gitea actually serves SSH on; the advertised port 22 is
# refused.
#
# This does not hand the agent a push path: `git push` and `git
# remote` are both outside CIPolicy's git verbs (dispatch#72), and
# this step runs after the agent has finished.
# AGENT_PR_DEPLOY_KEY is a write deploy key for THIS repo only
# (cobalt-dingo#98), mirroring the INFRA_DEPLOY_KEY shape dispatch's
# CD uses one repo over. Narrower than a PAT on purpose: git only, no
# API surface, and it cannot reach any other repo -- where a
# write-scoped PAT would, since effective access is scope n permission
# and only the scope half travels with the token (dispatch#67 fix #2).
#
# It is bound to THIS step, which runs after the agent has finished,
# and persist-credentials: false keeps it out of the checkout the
# agent works in. That placement is the whole point: run 5915 escaped
# on a credential that was in the working tree, not on one the job
# held afterwards.
#
# The runner filesystem persists between jobs, so the key is removed
# on the way out whether or not the push succeeds.
mkdir -p ~/.ssh
trap 'rm -f ~/.ssh/id_agent_pr' EXIT
printf '%s\n' "$AGENT_PR_DEPLOY_KEY" > ~/.ssh/id_agent_pr
chmod 600 ~/.ssh/id_agent_pr
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_agent_pr -o IdentitiesOnly=yes"
git push "ssh://git@10.0.1.20:30022/$GITHUB_REPOSITORY.git" "$branch"
# --fail-with-body: the branch is already pushed by this point, so a
# rejected PR creation must fail the job loudly rather than exit 0 and
# leave a branch nobody opened anything for. The Actions token cannot
# git-push on this instance; whether it can create a PR through the
# API is a separate permission and is being learned here.
payload="$(BRANCH="$branch" python3 -c '
import json, os
n = os.environ["ISSUE_NUMBER"]
print(json.dumps({
"head": os.environ["BRANCH"],
"base": "main",
"title": "agent: #%s %s" % (n, os.environ["ISSUE_TITLE"]),
"body": "Opened by `agent-run` for #%s.\n\nCI is the ground-truth verdict. A human reviews, merges and closes \u2014 this workflow does none of those." % n,
}))
')"
curl -sS --fail-with-body -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"https://git.d-ma.be/api/v1/repos/$GITHUB_REPOSITORY/pulls"