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.
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 asunkin-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 forunkin-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):
- AppRole login:
POST $VAULT_ADDR/v1/auth/approle/loginwithrole_idonly (nosecret_id) →client_token. GET $VAULT_ADDR/v1/<creds path>withX-Vault-Token→.data.token, where the creds path isGITEA_CREDS_PATHif set, elsegitea/creds/$AGENT_LOGIN(so unset env still readsgitea/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:
- AppRole login (shared
approleLogin), then KV-v2 read ofkv/service/authentik/agent-api-token(fieldtoken, falling back toapi_token). GET /api/v3/outposts/instances/?search=<name>— Authentik'ssearchis a substring match, so the exactnameis re-checked client-side.GET /api/v3/core/tokens/<token_identifier>/view_key/for the key.- KV-v2 write to
--dest-pathunder--dest-key(defaulttoken).
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:
- AppRole login (shared
approleLogin), then a KV-v2 read viaReadKVOptional— a 404 or a deleted version means "empty", not an error, so the first seed of a path works. - Desired keys are computed over the existing map:
client_idfrom the flag (kept/created/updated),client_secretandcookie_secretgenerated from 32crypto/randbytes only when absent or when--rotateis set (kept/created/rotated).cookie_secretis base64url so it decodes to exactly the 32 bytes oauth2-proxy demands;client_secretis standard base64. - Any other key on the path is carried through unchanged (
preserved), which is why the write goes throughWriteKVAnyrather thanWriteKV. - 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
watchprexits 0 with no output changes on--once(just prints state).- The token cache is process-wide (
sync.Once); tests call the unexportedfetchGiteaTokento avoid it. agentvaultnever puts a secret in an error string: Vault decode failures and Authentikview_keyresponses are reported without their bodies, andseed-oauthreports key names only.--rotateregenerates theclient_secrettoo, 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.