diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md index 4ca3d0d28e..737e63426c 100644 --- a/cli/plans/2026-07-25-buzz-agent-support-channel.md +++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md @@ -189,6 +189,8 @@ ingest `model/LocalCache.kt` (~L4780-4855); subscription 1. Reconcile 43001-43006 with Buzz upstream once it defines the job protocol. 2. Wire the P0 mobile screens (approvals inbox + jobs board) on top of `BuzzJobAggregator`. -3. A reference `--exec` wrapper that runs Claude Code, opens a PR with a PR-only token, and - returns the PR URL — plus a documented branch-protection + token-scope checklist. +3. ✅ **Done** — a reference `--exec` wrapper (`tools/buzz-agent/agent-exec.sh` + README) runs + the coding agent in the job worktree, commits, pushes the feature branch, opens a PR with a + PR-only token, and prints the URL as the job result — with the branch-protection + token-scope + checklist documented. Verified end-to-end against a stubbed `gh`/agent. 4. Consider promoting the approval gate (46010) into the responder for irreversible steps. diff --git a/tools/buzz-agent/README.md b/tools/buzz-agent/README.md new file mode 100644 index 0000000000..122831b9a7 --- /dev/null +++ b/tools/buzz-agent/README.md @@ -0,0 +1,74 @@ +# Buzz agent `--exec` wrapper + +`agent-exec.sh` is the reference command you point `amy buzz agent serve --exec` at to turn +Buzz job requests into pull requests. It runs a coding agent (Claude Code by default) on the +task inside the job's isolated git worktree, then commits, pushes the job branch, and opens a +PR — printing the PR URL as the job result. **It never touches the default branch and never +force-pushes; the merge is a human action on GitHub.** + +See the design in [`cli/plans/2026-07-25-buzz-agent-support-channel.md`](../../cli/plans/2026-07-25-buzz-agent-support-channel.md). + +## The contract (how the scheduler calls it) + +| Channel | Meaning | +|---|---| +| **stdin** | the task text (the kind-43001 request) | +| **cwd** | the job's git worktree — a fresh branch off `--base-ref` | +| **env** | `BUZZ_JOB_ID` `BUZZ_REQUESTER` `BUZZ_CHANNEL` `BUZZ_RELAY` `BUZZ_AGENT` `BUZZ_UPVOTES` `BUZZ_BRANCH` `BUZZ_WORKTREE` `BUZZ_BASE_REF` | +| **stdout** | becomes the job **result** (kind-43004) — the wrapper prints the PR URL | +| **non-zero exit** | becomes the job **error** (kind-43006); stderr is the detail | + +Its steps: read task → run agent → verify a diff exists → commit → push the branch → open (or +reuse) the PR → print the URL. + +## Usage + +```bash +amy buzz agent serve wss://your-buzz-relay \ + --exec /path/to/tools/buzz-agent/agent-exec.sh \ + --worktree /path/to/amethyst \ + --accept-from-channel \ + --parallel 2 +``` + +`--worktree` is **required** (the wrapper needs `BUZZ_BRANCH`/`BUZZ_WORKTREE`). `--parallel N` +runs N jobs at once, each in its own worktree+branch. + +## Config knobs (env) + +| Var | Default | Purpose | +|---|---|---| +| `AGENT_CMD` | *(unset)* | Override the whole agent invocation. Receives the prompt on **stdin** and as `$AGENT_PROMPT`. Use this for Goose/Codex or a custom runner. | +| `AGENT_ALLOWED_TOOLS` | `Edit,Write,Read,Bash,Glob,Grep` | Claude Code `--allowedTools` — the agent's capability boundary. | +| `COMMIT_PREFIX` | `feat` | Prefix for the auto-commit subject when the agent didn't commit. | + +Adjust the default `claude -p … --permission-mode acceptEdits --allowedTools …` line to match +your Claude Code version, or bypass it entirely with `AGENT_CMD`. + +## Security — the guardrails that make this safe + +Buzz authorizes by identity, not capability flags, so **none** of the "can't merge or destroy +`main`, can't code other things" guarantees come from Buzz. They come from three things you +configure here: + +1. **A PR-only git credential.** Authenticate `gh` on the agent host with a **fine-grained PAT + scoped to the `amethyst` repo only**, granting exactly **Contents: Read and write** + + **Pull requests: Read and write** — and nothing else. No Administration, no org scope. The + token can open PRs on feature branches; it cannot merge, bypass checks, or reach other repos. +2. **Branch protection on the default branch.** Protect `main`: require a pull request, require + review approval, require status checks (CI) to pass, and **block force-pushes and branch + deletion**. Do **not** grant the bot an exception. This is what actually stops a bad merge — + the wrapper only ever pushes `claude/job-*` feature branches. +3. **Scoped intake + agent tools.** Run the scheduler with `--accept-from` / `--accept-from-channel` + so only your team's keys can file jobs, keep `AGENT_ALLOWED_TOOLS` tight, and run on a host + whose checkout is amethyst only — so the agent "can't code other things." + +The merge is deliberately outside this loop: a completed job's result is its PR, and a human +merges it on GitHub. + +## Testing + +`agent-exec.sh` is verified end-to-end against a throwaway repo with a stubbed `gh` + agent +(reads task → runs agent → commits the diff → pushes the feature branch → prints the PR URL; +and errors cleanly when the agent makes no changes). Point `AGENT_CMD` at a stub to dry-run the +git/PR plumbing without invoking a real agent. diff --git a/tools/buzz-agent/agent-exec.sh b/tools/buzz-agent/agent-exec.sh new file mode 100755 index 0000000000..ace82bfb27 --- /dev/null +++ b/tools/buzz-agent/agent-exec.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# agent-exec.sh — reference `--exec` wrapper for `amy buzz agent serve`. +# +# The scheduler runs this once per Buzz job (kind-43001). Contract: +# stdin ............ the task text (the job request) +# cwd .............. the job's isolated git worktree (a fresh branch off --base-ref) +# env .............. BUZZ_JOB_ID BUZZ_REQUESTER BUZZ_CHANNEL BUZZ_RELAY BUZZ_AGENT +# BUZZ_UPVOTES BUZZ_BRANCH BUZZ_WORKTREE BUZZ_BASE_REF +# stdout ........... becomes the job RESULT (kind-43004) — we print the PR URL +# non-zero exit .... becomes the job ERROR (kind-43006); stderr is the error detail +# +# What it does: runs a coding agent (Claude Code by default) on the task inside the +# worktree, commits + pushes the job branch, opens a PR, and prints the PR URL. It NEVER +# touches the default branch and NEVER force-pushes — the merge is a human action on +# GitHub. See README.md for the required token scope + branch-protection checklist. +# +# Host requirements: git, gh (authenticated with a PR-ONLY token), and the agent CLI +# (`claude`), or a custom command via $AGENT_CMD. + +set -euo pipefail + +log() { printf '%s\n' "$*" >&2; } # progress → stderr (job-error tail on failure) +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# --- config knobs (all overridable via env) -------------------------------- +AGENT_CMD="${AGENT_CMD:-}" # override the entire agent invocation; reads the + # prompt on stdin and as $AGENT_PROMPT +AGENT_ALLOWED_TOOLS="${AGENT_ALLOWED_TOOLS:-Edit,Write,Read,Bash,Glob,Grep}" +COMMIT_PREFIX="${COMMIT_PREFIX:-feat}" + +# --- guards ---------------------------------------------------------------- +[[ -n "${BUZZ_BRANCH:-}" ]] || die "BUZZ_BRANCH unset — run the scheduler with --worktree" +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run the scheduler with --worktree" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "worktree is not a git repo" + +# The PR base = the repo's default branch. Never operate on it directly. +base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)" +case "$BUZZ_BRANCH" in + "$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;; +esac + +# Pin the branch's starting commit now, so "did the agent change anything?" is correct even +# when --base-ref is the symbolic "HEAD" (which moves as the agent commits). +base_sha="$(git rev-parse HEAD)" + +# --- 1. read the task ------------------------------------------------------ +task="$(cat)" +[[ -n "${task//[[:space:]]/}" ]] || die "empty task" +title="$(printf '%s' "$task" | head -n1 | cut -c1-72)" + +# --- 2. run the coding agent inside the worktree --------------------------- +log "[agent-exec] job ${BUZZ_JOB_ID:-?}: running agent on: $title" +prompt="You are working in a fresh git worktree on branch '$BUZZ_BRANCH' (off '${BUZZ_BASE_REF:-HEAD}'). +Implement the request below and then stop. Do NOT switch branches, push, or open a PR — the +wrapper handles git. Keep changes scoped to this repository. + +TASK: +$task" + +if [[ -n "$AGENT_CMD" ]]; then + export AGENT_PROMPT="$prompt" + agent_summary="$(printf '%s' "$prompt" | bash -c "$AGENT_CMD" 2>&1)" || die "agent command failed" +else + command -v claude >/dev/null 2>&1 || die "claude CLI not found (set AGENT_CMD to your agent)" + agent_summary="$(claude -p "$prompt" --permission-mode acceptEdits --allowedTools "$AGENT_ALLOWED_TOOLS" 2>&1)" || + die "claude run failed" +fi + +# --- 3. verify the agent produced changes ---------------------------------- +committed="$(git rev-list --count "$base_sha"..HEAD 2>/dev/null || echo 0)" +if [[ -z "$(git status --porcelain)" && "$committed" == "0" ]]; then + die "the agent produced no changes" +fi + +# --- 4. commit anything the agent left uncommitted ------------------------- +if [[ -n "$(git status --porcelain)" ]]; then + git add -A + git -c user.name="Buzz Agent" -c user.email="agent@localhost" commit -q \ + -m "$COMMIT_PREFIX: $title" -m "Filed via Buzz job ${BUZZ_JOB_ID:-unknown}." +fi + +# --- 5. push the job branch (feature branch only, never --force) ----------- +log "[agent-exec] pushing $BUZZ_BRANCH" +git push -u origin "HEAD:$BUZZ_BRANCH" || die "push failed (is a PR-only token configured?)" + +# --- 6. open (or reuse) the PR --------------------------------------------- +pr_url="$(gh pr list --head "$BUZZ_BRANCH" --state open --json url -q '.[0].url' 2>/dev/null || true)" +if [[ -z "$pr_url" ]]; then + body="$task + +--- +Filed via Buzz job \`${BUZZ_JOB_ID:-unknown}\`${BUZZ_REQUESTER:+ by \`$BUZZ_REQUESTER\`}. + +Agent notes: + +$agent_summary" + pr_url="$(gh pr create --base "$base_branch" --head "$BUZZ_BRANCH" --title "$title" --body "$body" 2>/dev/null)" || + die "gh pr create failed (PR-only token + branch protection configured?)" +fi + +# --- 7. emit the result → job 43004 ---------------------------------------- +printf 'Opened PR: %s\n' "$pr_url"