A stale remote-tracking ref survives a failed fetch and the next successful --prune deletes it, so it cannot prove a branch's commits survive upstream. Record whether the pruning fetch succeeded and gate the origin/<branch> existence and containment proofs on it; a failed fetch removes the worktree and keeps the branch. Local-object proofs and the merged head SHA are unaffected.
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 forunkin-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 pending→success, 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 managed worktree; dry run unless --yes is given
agentws prune
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 decides, per worktree, whether its work is safely upstream:
| Signal (first match wins) | Verdict |
|---|---|
| uncommitted 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 |
| 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.
--keep-branches removes worktrees only, and its verdicts print as remove.
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 tokenprints a fresh token to stdout (handy for scripts).agentws credential getspeaks the git credential protocol on stdin and, for the configured Gitea host only, emitsusername=$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