generated from mathias/template-go-web
ci: add agent-run workflow (copied from cobalt-dingo)
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user