Files
agent-tools/AGENTS.md
T
unkin-agent 155392a809
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add agentvault seed-oauth for oauth2-proxy credentials
Seeding an oauth2-proxy secret by hand means an agent shell-plumbing a
client secret and a cookie secret, which the classifier blocks. seed-oauth
does it in one self-contained invocation: it reads the KV path, fills in
only the keys that are missing, preserves everything else and prints key
names and the new version, never a value.

- Add SeedOAuth in internal/agent: read-modify-write of the client_id,
  client_secret and cookie_secret keys with per-key created/kept/rotated
  actions and a no-op when nothing changed.
- Generate secrets from 32 crypto/rand bytes; cookie_secret is base64url so
  it decodes to exactly the 32 bytes oauth2-proxy requires.
- Add ReadKVOptional (missing secret = empty) and WriteKVAny (non-string
  fields survive a round trip) to the KV-v2 client.
- Wire the seed-oauth subcommand and document it in README and AGENTS.md.
- Cover fresh create, patch-preserves-client_secret, other-key
  preservation, --rotate, idempotence, denial errors and secret leakage.
2026-08-30 15:31:25 +10:00

9.1 KiB

AGENTS.md

Project Overview

This repo ships several Gitea-automation CLIs in one RPM (agent-tools). They act as an agent user (unkin-agent by default) by minting a scoped Gitea token from Vault, so actions are attributed to the agent rather than to whoever runs the tool. Setting AGENT_LOGIN selects a different agent identity, so a service like repospawner can run these tools as itself.

  • agentpr — create pull requests and post PR comments as unkin-agent (fixes the "tea posts as Ben" attribution problem). Subcommands: pr create, pr comment, whoami.
  • watchpr — poll one or more PRs and exit when a tracked PR changes meaningfully: it merges/closes, gets a new non-agent comment, its CI fails, or it loses mergeability. Benign transitions (CI pending→success, the agent's own comments) are ignored.
  • agentws — manage per-branch git worktrees for unkin-agent. Clones repos into the source root (~/src/prodenv/<repo>), creates worktrees under the worktree root (~/.cache/agentws/<repo>__<branch>), and authenticates clone/fetch/push via an ephemeral credential helper. Subcommands: new, list, rm, clean, token, credential.

All tools are separate main packages under cmd/ and share the internal/agent package (Vault AppRole login, Gitea REST client, PR-ref parsing, watch-state comparison, git worktree helpers).

Structure

cmd/agentpr/main.go      # agentpr CLI (pr create / pr comment / whoami)
cmd/watchpr/main.go      # watchpr CLI (poll + meaningful-change exit)
cmd/agentws/main.go      # agentws CLI (new / list / rm / clean / token / credential)
cmd/agentvault/main.go   # agentvault CLI (seed-outpost / seed-oauth)
internal/agent/          # shared plumbing:
  token.go               #   env config + in-process Gitea-token cache
  vault.go               #   AppRole login + read the gitea creds path
  gitea.go               #   Gitea REST client (PR create/get, comments, status, whoami)
  parse.go               #   owner/repo#N and owner/repo parsing
  watch.go               #   PRState snapshot + MeaningfulChange comparison
  git.go                 #   git worktree/clone/fetch helpers (os/exec, no go-git)
  vaultkv.go             #   AppRole-authenticated Vault client + KV-v2 read/write
  authentik.go           #   Authentik REST client (outpost search, token view_key)
  seedoutpost.go         #   seed-outpost flow (Authentik token -> Vault KV)
  seedoauth.go           #   seed-oauth flow (oauth2-proxy credential set in Vault KV)
go.mod                   # module git.unkin.net/unkin/agent-tools
Makefile                 # build / test / lint / completions / rpm / version-bump
packaging/nfpm.yaml      # nfpm spec (envsubst-templated) for the RPM (all binaries)
scripts/build-rpm.sh     # generates completions + packages the RPM with nfpm
.woodpecker/             # CI: build, test, pre-commit (PR) + release (tag)
dist/                    # build output: binaries, completions, RPM (not committed)

Every binary is a separate main package, so make build builds each with its own -o (a single go build ./... can't emit multiple mains to one file).

Token acquisition (shared)

All tools call agent.GiteaToken(), which (once per process):

  1. AppRole login: POST $VAULT_ADDR/v1/auth/approle/login with role_id only (no secret_id) → client_token.
  2. GET $VAULT_ADDR/v1/<creds path> with X-Vault-Token.data.token, where the creds path is GITEA_CREDS_PATH if set, else gitea/creds/$AGENT_LOGIN (so unset env still reads gitea/creds/unkin-agent).

Config via env (all have defaults):

Variable Default Purpose
VAULT_ADDR https://vault.service.consul:8200 Vault/OpenBao address
AGENT_APPROLE_ROLE_ID built-in default AppRole role_id (overridable)
GITEA_URL https://git.unkin.net Gitea base URL
AGENT_LOGIN unkin-agent agent identity: selects gitea/creds/<login>; login whose comments watchpr ignores; agentws git identity
GITEA_CREDS_PATH gitea/creds/$AGENT_LOGIN Vault path minting the Gitea token (wins over AGENT_LOGIN)
AGENTWS_SRC_ROOT ~/src/prodenv agentws source-of-truth checkout root
AGENTWS_ROOT ~/.cache/agentws agentws worktree root
AGENTWS_OWNER unkin Gitea org that owns agentws-managed repos
AUTHENTIK_URL https://identity.k8s.syd1.au.unkin.net Authentik base URL (agentvault)

agentws git auth (ephemeral credential helper)

Gitea tokens are ~1h ephemeral, so agentws never bakes one into a remote URL or config. agentws token prints a fresh token; agentws credential get implements the git credential protocol (reads the key=value request on stdin, and for the configured Gitea host only emits username=$AGENT_LOGIN + password=<fresh token>). agentws new wires this per worktree — it enables extensions.worktreeConfig on the repo once, then writes user.name, user.email and credential.helper = !<agentws> credential to the per-worktree config so the shared checkout's identity/config is untouched. Clone/fetch pass the same helper transiently via -c credential.helper=.... Worktrees are created FROM ~/src/prodenv/<repo> (git worktree add) so agent branches are visible in Ben's main checkout; rm/clean fetch there afterwards to keep the default branch current.

Build

make build          # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault (CGO disabled, static)

Requires Go 1.21+. Dependency: github.com/spf13/cobra (CLI).

Packaging (RPM)

make rpm            # build all binaries + package into dist/*.rpm via nfpm

scripts/build-rpm.sh generates bash/zsh/fish completions from the built binaries and bundles them alongside /usr/bin/agentpr, /usr/bin/watchpr, /usr/bin/agentws and /usr/bin/agentvault. On a v* tag the release pipeline builds the RPM and PUTs it to the artifactapi rpm-internal repo, then cuts a Gitea release.

Shell completions

Cobra provides a completion subcommand for each binary (agentpr completion bash, etc.). The RPM installs them to the standard system paths (/usr/share/bash-completion/completions/, /usr/share/zsh/site-functions/, /usr/share/fish/vendor_completions.d/).

Testing

make test           # go test -v -race ./...

internal/agent covers PR-ref parsing, the MeaningfulChange table (benign vs alerting transitions), request-body construction, and the Vault+Gitea client against httptest servers (fake AppRole login + gitea creds + PR create / comment / whoami / status). No live Vault/Gitea access is required for tests.

agentvault seed-outpost

agentvault seed-outpost --outpost <name> --dest-path <kv/path> does the whole flow in-process:

  1. AppRole login (shared approleLogin), then KV-v2 read of kv/service/authentik/agent-api-token (field token, falling back to api_token).
  2. GET /api/v3/outposts/instances/?search=<name> — Authentik's search is a substring match, so the exact name is re-checked client-side.
  3. GET /api/v3/core/tokens/<token_identifier>/view_key/ for the key.
  4. KV-v2 write to --dest-path under --dest-key (default token).

Only the outpost name, token identifier, dest path and new KV version are printed. Errors are wrapped per stage (login / read denied / outpost missing / view_key / write denied) via the ErrVaultDenied, ErrVaultNotFound and ErrOutpostNotFound sentinels.

agentvault seed-oauth

agentvault seed-oauth --path <kv/path> --client-id <id> makes a KV-v2 path hold a complete oauth2-proxy credential set, in-process:

  1. AppRole login (shared approleLogin), then a KV-v2 read via ReadKVOptional — a 404 or a deleted version means "empty", not an error, so the first seed of a path works.
  2. Desired keys are computed over the existing map: client_id from the flag (kept/created/updated), client_secret and cookie_secret generated from 32 crypto/rand bytes only when absent or when --rotate is set (kept/created/rotated). cookie_secret is base64url so it decodes to exactly the 32 bytes oauth2-proxy demands; client_secret is standard base64.
  3. Any other key on the path is carried through unchanged (preserved), which is why the write goes through WriteKVAny rather than WriteKV.
  4. The write is skipped entirely when nothing changed; the command then prints version: unchanged.

Only key names, per-key actions and the new KV version are printed. Errors are wrapped per stage (login / read denied / write denied) via ErrVaultDenied.

Gotchas

  • watchpr exits 0 with no output changes on --once (just prints state).
  • The token cache is process-wide (sync.Once); tests call the unexported fetchGiteaToken to avoid it.
  • agentvault never puts a secret in an error string: Vault decode failures and Authentik view_key responses are reported without their bodies, and seed-oauth reports key names only.
  • --rotate regenerates the client_secret too, which then no longer matches the IdP provider unless that is rotated alongside.
  • CI "combined status" comes from /commits/{sha}/status; an empty head SHA yields an empty state without an API call.