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
2026-08-12 21:37:09 +10:00

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

# 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 pendingsuccess, the agent's own comments — are ignored.

# 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>).

# 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.

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.

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

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, PUTs it to the artifactapi rpm-internal yum repo, and cuts a Gitea release with cross-compiled binaries attached.

Version bump

make patch   # or: make minor / make major  — tags vX.Y.Z and pushes the tag
S
Description
CLI tools for orchestrator PR automation as unkin-agent
Readme 667 KiB
v0.10.0 Latest
2026-09-20 23:08:34 +10:00
Languages
Go 98.6%
Makefile 0.8%
Shell 0.6%