Provision agent (bot) accounts on git.b4mad.industries: SSH + GPG keys, account, PAT, SOPS + SealedSecret, verified end to end.
  • Rust 58.6%
  • MDX 35.4%
  • Python 1.7%
  • TypeScript 1%
  • Astro 0.7%
  • Other 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Christoph Görn 0000b49fae
chore: update skills
Signed-off-by: Christoph Görn <goern@b4mad.net>
2026-07-29 17:54:28 +02:00
.agents/skills chore: update skills 2026-07-29 17:54:28 +02:00
.beads feat: plan before applying, and rotate existing agents in place 2026-07-29 17:53:10 +02:00
.claude chore: update skills 2026-07-29 17:54:28 +02:00
.codex feat: add the agents 2026-07-29 09:45:00 +02:00
.gitignore feat: add the agents 2026-07-29 09:45:00 +02:00
AGENTS.md feat: add the agents 2026-07-29 09:45:00 +02:00
bot-avatar.png feat: extract Forgejo agent provisioning into its own project 2026-07-29 08:39:41 +02:00
CLAUDE.md feat: extract Forgejo agent provisioning into its own project 2026-07-29 08:39:41 +02:00
create-forge-agent.py feat: plan before applying, and rotate existing agents in place 2026-07-29 17:53:10 +02:00
README.md feat: plan before applying, and rotate existing agents in place 2026-07-29 17:53:10 +02:00
skills-lock.json chore: update skills 2026-07-29 17:54:28 +02:00

forge-agents

Provisioning tooling for the agent ("bot") accounts on https://git.b4mad.industries.

Home: https://git.b4mad.industries/agentic-forges/forge-agents (ssh://git@git.b4mad.industries:2222/agentic-forges/forge-agents.git).

git.b4mad.industries is the same Forgejo that used to answer to forgejo.b4mad.net — a rename of the public route, not a migration. Both names serve port 2222 with the same RSA host key (SHA256:LFo9B+TvN+sXwkUBW58RxU4QVyqk8rRaohW5UPFiuYw), and the old one still resolves. So agents provisioned under the old name keep working and their keys and tokens need no backfill.

known_hosts does not carry over, though — it is keyed by hostname, so the new name is an unknown host until you add it. That is also your chance to check the rename really is one:

ssh-keyscan -p 2222 git.b4mad.industries 2>/dev/null | ssh-keygen -lf -
# expect SHA256:LFo9B+TvN+sXwkUBW58RxU4QVyqk8rRaohW5UPFiuYw (RSA)
ssh-keyscan -p 2222 git.b4mad.industries >> ~/.ssh/known_hosts

Agents live in Forgejo's own datastore, not in git — re-applying the manifests does not recreate them. This repo is the record of how to recreate them, and the 2026-07-27 move to CloudNativePG is the proof that mattered: it wiped every bot account, key and token, and each had to be rebuilt from tooling like this.

Provision an agent

The script plans first and applies second. On its own it surveys the live state, prints what it would do, and changes nothing:

./create-forge-agent.py b4mad-something something@b4mad.net \
    --scopes "write:repository,write:issue,read:user,read:organization"
plan for 'b4mad-something' — would do:

  create    account b4mad-something <something@b4mad.net>
  set       avatar from bot-avatar.png
  create    SSH key 'b4mad-something-ed25519' (ed25519)
  create    GPG key (ed25519, verified) and upload it
  mint      token 'b4mad-something' scoped write:repository,…
  write     …/forgejo-agent-b4mad-something.enc.yaml
  write     …/forgejo-agent-b4mad-something.yaml

dry run — nothing changed. Re-run with --confirm to apply.

Add --confirm to execute it. Dry run is the default because this script is routinely pointed at accounts that already exist, and the interesting question is always what is already there — see Existing agents. --dry-run also exists as an explicit spelling of the default, for scripts that want to say so out loud; passing it together with --confirm is an error rather than a silent winner.

One confirmed run does the whole lifecycle:

# Step How
0 survey what already exists GET only — a dry run never leaves this row
1 create the account POST /admin/users (gitea_admin basic auth)
2 re-assert the shared avatar POST /user/avatar (agent basic auth)
3 generate ed25519 SSH keypair ssh-keygen
4 generate ed25519 GPG keypair gpg --batch --gen-key
5 upload the SSH public half POST /admin/users/{u}/keys
6 upload the GPG public half POST /user/gpg_keys, with a detached signature over /user/gpg_key_token
7 mint the PAT POST /admin/users/{u}/tokens
8 write forgejo-agent-<u>.enc.yaml sops
9 write forgejo-agent-<u>.yaml the manifests repo's scripts/sops2sealedsecret
10 verify all of it read back from the API + a real ssh -T on :2222
11 shred the plaintext

Step 6 is the one worth understanding: uploading a GPG key without answering the challenge leaves it present but unverified, which buys nothing — Forgejo will not mark the agent's commits as verified. Answering it inline is the whole point.

A successful run ends like this, and any step that cannot be confirmed is a hard failure rather than a warning — a half-provisioned agent that looks provisioned is worse than one that visibly did not get created:

✓ token authenticates as b4mad-something
✓ SSH key registered (SHA256:…)
✓ GPG key registered and VERIFIED (…)
✓ ssh -T git@git.b4mad.industries:2222 authenticates
✓ forgejo-agent-b4mad-something.enc.yaml decrypts and round-trips
✓ forgejo-agent-b4mad-something.yaml generated for namespace b4mad-forgejo
# re-assert the shared avatar on an existing agent, touching nothing else:
./create-forge-agent.py b4mad-something something@b4mad.net --avatar-only --confirm

--help covers the rest. Exit codes: 0 done (or a clean dry run), 1 failure, 2 the plan needs --overwrite and did not get it.

Existing agents

An agent that already exists is not an error, but it is a decision, so the plan spells it out and the run stops until you answer it:

plan for 'b4mad-castra' — would do:

  keep      account b4mad-castra (id 7) — repos, org membership and commit attribution untouched
  rotate    its password to a throwaway (the avatar and GPG endpoints are user-scoped)
  set       avatar from bot-avatar.png
  delete    SSH key 'b4mad-castra-ed25519' (id 4, SHA256:aaa)   [--overwrite]
  delete    GPG key 1A2B3C4D5E6F7788 (id 3)                     [--overwrite]
  revoke    token 'b4mad-castra' (id 12, created 2026-07-01…)   [--overwrite]
  create    SSH key 'b4mad-castra-ed25519' (ed25519)
  …
  leave     1 SSH key(s) not titled 'b4mad-castra-ed25519' alone — this tool did not put them there

⚠️ 'b4mad-castra' already exists and 4 step(s) above would destroy existing credentials.

--overwrite authorises exactly the [--overwrite] steps: rotation in place. The account itself is kept, and with it every repo it owns, its commit attribution and its org membership — only credentials are replaced. There is no mode that deletes the account; Forgejo refuses to delete a user who owns repos, and detaching an agent's commit history to re-mint a token would be a wildly disproportionate trade.

Whether a run needs --overwrite at all is decided by what it would destroy:

Situation Needs --overwrite?
Account does not exist no — nothing to destroy
Account exists, no keys, no token by that name no — this is the backfill case below
Token named --token-name already exists yes (Forgejo rejects a duplicate name outright)
SSH key titled <username>-ed25519 exists yes
Any GPG key on the account yes
forgejo-agent-<u>.enc.yaml / .yaml already written yes

So adding a token alongside the current one needs no --overwrite — pass a different --token-name and the old one keeps working.

Ownership is decided by name, because the API stores nothing else to go on: SSH keys are this tool's if titled <username>-ed25519, and any other key is reported and left alone. GPG keys have no title to filter on, so on a machine account all of them are treated as this tool's — which is why they are itemised by key ID in the plan before anything is deleted.

Within a confirmed rotation the token is revoked last: if a later step fails, the agent still holds the credential it had rather than nothing.

⚠️ Rotation is not atomic and Forgejo has no transaction to offer. If a run dies midway the plan is the recovery map — re-run it, read what it now says about the world, and continue.

--force is a deprecated alias for --overwrite.

Where the credentials go

This repo holds the tool. It never holds a credential.

git.b4mad.industries                   github.com
 agentic-forges/forge-agents            b4mad/op1st-emea-b4mad
  create-forge-agent.py        ──▶       manifests/applications/b4mad-forgejo/
                                           forgejo-agent-<name>.enc.yaml   (SOPS — source of truth)
                                           forgejo-agent-<name>.yaml       (SealedSecret — Argo CD applies this)
                                           README.md                       (the roster)

--manifests-dir points at a checkout of that GitOps repo; it defaults to ~/Source/github.com/operate-first/op1st-emea-b4mad. Tool and output are separate on purpose — Argo CD syncs the SealedSecret from the manifests repo's kustomization, so the credential has to live there.

⚠️ Key custody. The plaintext private keys are ephemeral: they exist in a 0700 temp dir for the duration of the run and are shredded at the end. The .enc.yaml is the ONE durable copy — deliberately one, not two, because a credential duplicated across locations rotates in one place and goes silently stale in the other. --keep-dir is the escape hatch; using it means you now own a second copy that will not follow rotations.

⚠️ Nothing is committed for you. git add both output files. An uncommitted run is a lost agent, because the plaintext is already gone.

Rotation

./create-forge-agent.py b4mad-something something@b4mad.net \
    --scopes "…" --overwrite            # read the plan
./create-forge-agent.py b4mad-something something@b4mad.net \
    --scopes "…" --overwrite --confirm  # do it

Then commit both regenerated files — the .enc.yaml is the only copy of the new private keys.

By hand, if you need to revoke something the script does not own (a foreign SSH key, a token under a name you are not re-minting). The admin endpoints take the gitea_admin break-glass credentials, so no password rotation on the agent is needed:

ADMIN=$(oc -n b4mad-forgejo get secret forgejo-admin -o jsonpath='{.data.username}' | base64 -d)
PW=$(oc -n b4mad-forgejo get secret forgejo-admin -o jsonpath='{.data.password}' | base64 -d)

curl -s -u "$ADMIN:$PW" https://git.b4mad.industries/api/v1/admin/users/<agent>/tokens
curl -s -u "$ADMIN:$PW" -X DELETE \
     https://git.b4mad.industries/api/v1/admin/users/<agent>/tokens/<id-or-name>
# SSH keys: DELETE /api/v1/admin/users/<agent>/keys/<id>
# GPG keys are user-scoped only — DELETE /api/v1/user/gpg_keys/<id> as the agent

Conventions this enforces

  • Every agent wears the same avatar (bot-avatar.png), so service accounts are distinguishable from humans at a glance. It is re-applied on every run, which is what keeps them uniform. --avatar overrides it — deliberate divergence, don't, unless you mean it.
  • Keys are ed25519 (GPG: EDDSA + cv25519), passphrase-less, non-expiring — matching the shape of the Forgejo server's own commit-signing key.
  • The GPG UID carries #B4mad Forgejo agent, marking the key as a machine identity the same way the avatar does in the web UI.
  • There are no agent passwords to store. The script mints a throwaway purely to authenticate the two user-scoped endpoints admin credentials cannot reach (avatar upload, GPG key upload), then discards it. ENABLE_INTERNAL_SIGNIN: false means agents cannot sign in at all.
  • Token scopes are not secret and belong in the plaintext roster table in the manifests repo, not in the encrypted blob.

⚠️ read:organization is not optional for anything that walks org repos — Renovate resolves each repo's owning org before processing it, so without that scope every repository fails with 403 … token does not have at least one of required scope(s): [read:organization].

⚠️ A token scoped write:package is not sufficient to push to an org package namespace — the account must also be a member of that org. The registry rejects the push as a bare authentication required, with nothing pointing at membership as the cause.

Requirements

gpg, ssh-keygen, ssh, oc, sops, kubeseal, Python 3.11+ (stdlib only). oc must be logged in to nostromo — the admin credentials are read from the live forgejo-admin Secret in b4mad-forgejo, not from the repo, because what is deployed is what the API will accept.

Known gap

The four pre-existing bots — b4mad-renovate, b4mad-gitops, b4mad-castra, b4mad-release-bot — have no SSH and no GPG key (validated 2026-07-29). They predate this tool. Their commits are unsigned, so anything holding the token is indistinguishable from the agent itself.

Backfilling one is now close to a plain run: having no keys, they have nothing for the tool to destroy on the account. Give the run a fresh --token-name and the agent's live token keeps working while it gains keys and a second one:

./create-forge-agent.py b4mad-renovate renovate@b4mad.net \
    --scopes "…" --token-name b4mad-renovate-signed

Read that plan before adding --confirm, and check two things: that it says keep account rather than create, and whether the output-file steps came out as write or as overwrite … [--overwrite] — a bot whose credentials already live in a forgejo-agent-<u>.enc.yaml needs --overwrite for those, and whatever consumes the old secret has to be moved to the new one in the same change. Tracked as Systems-3ywf in the b4mad-erdgeschoss-systems beads tracker.

Provenance

Extracted from b4mad-erdgeschoss-systems at commit 761df40, where it lived as forgejo/create-forge-agent.py. It replaced create-forge-bot.sh, which only ever created an account + PAT + avatar.

The forge's public route was renamed forgejo.b4mad.netgit.b4mad.industries on 2026-07-29; see the note at the top of this file for why that is a rename and not a migration. Sibling projects under agentic-forges/ — notably forgejo-mcp — moved with it.