Files
agent-tools/README.md
T
unkin-agent 62aeaf063b
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Keep worktrees git could not read, never delete them
Any git error on a directory under the worktree root was classified orphan,
and orphan deletes the directory outright, so a transient failure reading the
source root became data loss on a plain `agentws prune --yes`.

- prove a backing repo gone by stat before calling a directory an orphan
- classify an unexplained git failure as keep, naming the error
- refuse to remove a worktree whose git state is unknown, even with --include-keep
- spell out that --include-keep discards uncommitted and in-progress work
2026-09-12 00:37:10 +10:00

304 lines
13 KiB
Markdown

# agent-tools
Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They
act as an agent user (**`unkin-agent`** by default) by minting a scoped Gitea
token from Vault, so automated PRs, comments and pushes are attributed to the
agent — not to whoever happens to run the command. Set `AGENT_LOGIN` to act as a
different agent identity.
- **`agentpr`** — create pull requests and post PR comments as the agent user.
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
acting on.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning
into Ben's source checkout and isolating agent work under the XDG cache.
- **`agentvault`** — run deterministic Vault flows in one invocation, so agents
never plumb secret material through a shell.
## How it gets a token
On first use each tool performs a Vault AppRole login (`role_id` only, no
`secret_id`), then reads `gitea/creds/$AGENT_LOGIN` — or `GITEA_CREDS_PATH` when
set — to obtain a short-lived Gitea token, cached in-process for the run. With
neither variable set that is `gitea/creds/unkin-agent`, as before.
Everything is configured by environment variables, all with 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>`, and the login whose comments `watchpr` ignores |
| `GITEA_CREDS_PATH` | `gitea/creds/$AGENT_LOGIN` | Vault path minting the Gitea token (wins over `AGENT_LOGIN`) |
| `AGENTWS_SRC_ROOT` | `~/src/prodenv` | source-of-truth checkout root (`agentws`) |
| `AGENTWS_ROOT` | `~/.cache/agentws` | worktree root (`agentws`) |
| `AGENTWS_OWNER` | `unkin` | Gitea org that owns the repos (`agentws`) |
| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) |
## agentpr
```bash
# Verify identity (prints the agent login, unkin-agent by default)
agentpr whoami
# Open a PR
agentpr pr create --repo unkin/argocd-apps \
--base main --head benvin/my-change \
--title "Add woodpecker SA" --body "Adds the ServiceAccount ..."
# prints: #<number> <html_url>
# Comment on a PR
agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green."
agentpr --version
agentpr --help
```
Non-zero exit on any API error.
## watchpr
Poll PRs and exit (reporting what changed) when a tracked PR **merges/closes**,
gets a **new comment from someone other than the agent**, its **CI fails**
(failure/error), or it **loses mergeability** (a conflict appears). Benign
transitions — CI `pending``success`, the agent's own comments — are ignored.
```bash
# Watch until something meaningful happens (default interval 60s)
watchpr unkin/argocd-apps#42
# Multiple PRs, custom interval; refs accept #N or :N
watchpr --interval 30s unkin/argocd-apps#42 unkin/terraform-vault:98
# --interval takes a duration (30s, 2m, 1h30m) or a bare number of seconds
watchpr --interval 30 unkin/argocd-apps#42
# One-shot: print current state and exit 0 (great for scripts)
watchpr --once unkin/argocd-apps#42
watchpr --once --json unkin/argocd-apps#42
```
On a meaningful change `watchpr` prints the reason and the PR's current state,
then exits 0. Use `--json` for machine-readable output.
## agentws
`agentws` gives an agent an isolated git worktree per branch without disturbing
Ben's shared checkouts. Repos are cloned into the **source root**
(`~/src/prodenv/<repo>`) so branches created here are visible in the main
checkout too; the worktrees themselves live under the **worktree root**
(`~/.cache/agentws/<repo>__<branch>`).
```bash
# Clone unkin/argocd-apps into ~/src/prodenv if missing, then add a worktree for
# a new branch off the remote default branch. Prints the worktree path.
agentws new argocd-apps --branch benvin/my-change
# Branch off a specific base instead of the remote default
agentws new argocd-apps --branch benvin/hotfix --from release-1.2
# List managed worktrees (repo, branch, path)
agentws list
# Remove a worktree (by path or branch); refreshes the source repo afterwards
agentws rm benvin/my-change
agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch
# Classify every worktree found; dry run unless --yes is given
agentws prune
agentws prune --json
agentws prune --no-fetch
agentws prune --yes
agentws prune --yes --keep-branches
# Remove every managed worktree and prune each source repo
agentws clean
# Print a fresh Gitea token for the agent login
agentws token
```
### prune
`agentws prune` finds worktrees two ways and merges the results: the managed
directories under the worktree root, and `git worktree list` on every source
checkout — so hand-made worktrees, stale registrations whose directory is gone,
and leftover directories whose source repo was deleted all show up too.
It then decides, per worktree, whether its work is safely upstream:
| Signal (first match wins) | Verdict |
|---|---|
| working tree gone (registration only) | prune the registration |
| backing repo gone | delete the leftover directory |
| locked by `git worktree lock` | keep |
| rebase, merge, cherry-pick, revert or bisect in progress | keep |
| uncommitted, staged or untracked changes | keep |
| branch has an open PR | keep |
| tip contained in `origin/<default>` | remove worktree + local branch |
| every commit patch-equivalent to one in `origin/<default>`'s history | remove worktree + local branch |
| PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/<branch>`) | remove worktree + local branch |
| PR closed **and** HEAD contained in a verified `origin/<branch>` | remove worktree + local branch |
| detached HEAD carrying commits on no remote | keep |
| anything else | remove worktree, keep the branch |
A branch is deleted only where git proves its commits survive elsewhere. PR
state alone never authorises that: a merged or closed PR whose branch picked up
commits since keeps its branch, because those commits exist nowhere but here.
The delete runs `git branch -d` first so git's own unmerged check is a backstop,
falling back to `-D` only for a proven branch — squash merges keep the guard
tripping even once the work has landed.
Patch equivalence comes from `git cherry`, which these squash-merging repos need
because a merged branch's commits carry different SHAs upstream. It proves the
patches reached the default branch's history at some point — a later revert
still counts — not that they stand at its tip.
`origin/<branch>` counts as evidence only when this run's `git fetch --prune`
succeeded. A tracking ref left over from an earlier fetch may name a branch that
is already gone upstream and is itself due for deletion, so a failed fetch
downgrades those verdicts to `remove` and keeps the branch. Proofs that read
only local objects — containment in `origin/<default>`, patch equivalence, and
containment in a merged PR's head SHA — stand on their own.
Gitea PR state only adds to the git answer: when it cannot be reached, prune
says so and never deletes a branch it could not prove, and a PR listing that
hits the pagination cap is reported rather than read as "no PR". Matching a
branch to its PR uses `head.label`, since Gitea rewrites `head.ref` to
`refs/pull/<n>/head` once the branch is deleted on merge.
The last row is safe only because the local branch keeps the commits, so the
reason names the branch it is relying on. A detached HEAD has no such branch, so
unique commits there are kept instead.
Output is a table (`REPO BRANCH PATH VERDICT REASON`) with every verdict's reason
spelled out, or `--json` for scripting. Neither form needs a terminal.
| Flag | Effect |
|---|---|
| `--yes` | apply the plan; without it nothing is touched |
| `--keep-branches` | remove worktrees only; verdicts print as `remove` |
| `--no-fetch` | judge against the refs already on disk, for offline use |
| `--json` | emit the report as JSON on stdout, notes on stderr |
| `--include-unmanaged` | also remove worktrees outside the worktree root |
| `--include-keep` | dangerous: also remove worktrees classified `keep`, destroying uncommitted and in-progress work |
Without `--include-unmanaged` a hand-made worktree is reported and then skipped,
naming the flag that would remove it. `--include-keep` is the only way past a
`keep`. It leaves the branch, so committed work outlives the worktree, but
`git worktree remove --force` discards a dirty working tree and a paused
rebase's sequencer state without a word, and no branch was carrying those.
A directory under the worktree root is deleted outright only when its git dir
and the repo's shared `.git` are both proven absent by `stat`. When git merely
fails to answer for a checkout, the verdict is `keep` with the error as its
reason: an unread state is never a dead one.
### Auth / credential-helper design
Gitea tokens minted from Vault are short-lived (~1h), so `agentws` never
persists one in a remote URL or in git config. Instead it wires itself as an
**ephemeral git credential helper**:
- `agentws token` prints a fresh token to stdout (handy for scripts).
- `agentws credential get` speaks the git credential protocol on stdin and, for
the configured Gitea host only, emits `username=$AGENT_LOGIN` +
`password=<fresh token>`.
`agentws new` sets this up per worktree without touching the shared checkout: it
enables `extensions.worktreeConfig` on the repo once, then writes
`user.name` / `user.email` and `credential.helper = !<agentws> credential` to
the **per-worktree** config. Clone/fetch use the same helper via a transient
`-c credential.helper=...`; the shared `origin` URL is left clean. On worktree
removal `agentws` fetches in `~/src/prodenv/<repo>` so its default branch stays
current.
## agentvault
Deterministic Vault flows, each a single self-contained invocation: the tool
reads and writes the secrets itself, and prints only identifiers.
### seed-outpost
Copy an Authentik outpost's token into Vault KV-v2. `agentvault` reads the
Authentik API token from `kv/service/authentik/agent-api-token`, resolves the
named outpost's `token_identifier`, fetches its key via
`/api/v3/core/tokens/<identifier>/view_key/` and writes it to the destination
KV path. The token value is never printed or logged.
```bash
agentvault seed-outpost \
--outpost k8s-outpost \
--dest-path kubernetes/namespace/authentik/default/outpost-token
```
```
outpost: k8s-outpost
token_identifier: ak-outpost-k8s-outpost
dest: kv/kubernetes/namespace/authentik/default/outpost-token
version: 3
```
Re-running is safe: it writes a new KV version. Flags: `--outpost` and
`--dest-path` are required; `--dest-key` (default `token`), `--kv-mount`
(default `kv`), `--token-path` (default `service/authentik/agent-api-token`) and
`--authentik-url` override the rest.
Errors name the failing stage: AppRole login, KV read denied (policy not
applied), outpost not found (terraform not applied), `view_key` failure, or KV
write denied.
### seed-oauth
Make a Vault KV-v2 path hold a complete oauth2-proxy credential set. It is a
read-modify-write: `client_id` is set from the flag, `client_secret` and
`cookie_secret` are generated (32 bytes from `crypto/rand`) only when missing,
every other key on the path is written back untouched, and nothing is written
at all when the secret is already correct. `cookie_secret` is base64url so it
decodes to exactly the 32 bytes oauth2-proxy requires.
```bash
agentvault seed-oauth \
--path kubernetes/namespace/repospawner/default/oauth-credentials \
--client-id 4f1c…
```
```
path: kv/kubernetes/namespace/repospawner/default/oauth-credentials
keys: client_id, client_secret, cookie_secret
client_id: created
client_secret: kept
cookie_secret: created
version: 4
```
That is the common case: the provider's `client_secret` already lives on the
path, so only the missing keys are added. A run with nothing to do prints
`version: unchanged` and issues no write.
Flags: `--path` and `--client-id` are required; `--kv-mount` (default `kv`) and
`--rotate` override the rest. `--rotate` regenerates both secrets — only use it
when the IdP provider's secret is being rotated alongside, since a rotated
`client_secret` no longer matches the provider.
Errors name the failing stage: AppRole login, KV read denied, or KV write
denied. Only key names, actions and the KV version are printed.
## Build & package
```bash
make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault
make test # go test -race ./...
make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm
```
Release is tag-driven (`v*`) via Woodpecker: builds the RPM, `PUT`s it to the
artifactapi `rpm-internal` yum repo, and cuts a Gitea release with
cross-compiled binaries attached.
### Version bump
```bash
make patch # or: make minor / make major — tags vX.Y.Z and pushes the tag
```