Provision agent (bot) accounts on git.b4mad.industries: SSH + GPG keys, account, PAT, SOPS + SealedSecret, verified end to end.
  • Python 89.3%
  • Shell 10.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Christoph Görn 271b2d51f9
feat: rad-init-and-pin.py — init on Radicle, then file the pin request
`rad init` is the easy half. The half that gets forgotten is that a fresh
Radicle repo lives only on the node that created it — it becomes durable when
radicle-seed.b4mad.industries pins it, and that seed is configured from git:
radicle-seed-ansible's inventory/host_vars/192.168.0.75.yaml. So the second
half of "publish this" is a request to another repo's maintainer, carrying a
30-character RID nobody transcribes correctly from memory.

Dry run by default, like create-forge-agent.py, plus a reason of its own: the
apply step files something in public on someone else's repo. Both halves are
idempotent and surveyed first — an inited repo reuses its RID, an already
pinned RID files nothing, and an open issue or PR naming the RID is linked
rather than duplicated (one request, two shapes).

--pr makes the edit instead of asking for it: branch, append, open the PR. The
append is line surgery, not a YAML round-trip, because the file is read by
humans and PyYAML would eat every comment in it; the write carries the blob sha
the survey read, so a file that moved on gets rejected rather than clobbered.

No credential is unwrapped for the survey — the seed repo is public. The write
takes --token/$FORGE_ISSUE_TOKEN or unwraps b4mad-gitops via
extract-agent-keys.sh and deletes the copy on the way out.

Closes: af-67s, af-6xs
2026-07-31 17:51:15 +02:00
.agents/skills chore: 🔧 vendor the worktrunk skills, mark .agents generated 2026-07-31 16:29:51 +02:00
.beads feat: rad-init-and-pin.py — init on Radicle, then file the pin request 2026-07-31 17:51:15 +02:00
.claude chore: 🔧 vendor the worktrunk skills, mark .agents generated 2026-07-31 16:29:51 +02:00
.codex feat: add the agents 2026-07-29 09:45:00 +02:00
docs fix: ✏️ brand agent GPG UIDs "#B4mad Forge", not "#B4mad Forgejo" 2026-07-31 15:42:26 +02:00
openshift feat: run semantic-release on OpenShift with agent credentials 2026-07-30 21:00:40 +02:00
.gitattributes chore: 🔧 vendor the worktrunk skills, mark .agents generated 2026-07-31 16:29:51 +02:00
.gitignore feat: add the agents 2026-07-29 09:45:00 +02:00
.releaserc fix: 🐛 stop bare "#B4mad" turning into dead issue links 2026-07-31 16:22:50 +02:00
AGENTS.md fix: 🐛 rejoin the shared 'af' bead database 2026-07-31 15:58:06 +02:00
agents.toml chore: 🔧 fill in b4mad-gitops's email, the last TBD in agents.toml 2026-07-31 16:49:08 +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 fix: ✏️ brand agent GPG UIDs "#B4mad Forge", not "#B4mad Forgejo" 2026-07-31 15:42:26 +02:00
extract-agent-keys.sh feat: unwrap an agent's keys on demand with extract-agent-keys.sh 2026-07-31 14:29:57 +02:00
rad-init-and-pin.py feat: rad-init-and-pin.py — init on Radicle, then file the pin request 2026-07-31 17:51:15 +02:00
README.md feat: rad-init-and-pin.py — init on Radicle, then file the pin request 2026-07-31 17:51:15 +02:00
skills-lock.json chore: 🔧 vendor the worktrunk skills, mark .agents generated 2026-07-31 16:29:51 +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 — unless --keep-existing ssh
Any GPG key on the account yes — unless --keep-existing gpg
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. And a key named by --keep-existing is not destroyed, so it stops being a reason to need --overwrite at all — see Rotating the token but not the identity.

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.

Agent manifest (agents.toml)

agents.toml is the authoritative list of which agents should exist and why — one entry per bot with its username, email, a one-line persona describing what it does, and (optionally) scopes. It is not a credential store: like this whole repo, it never holds a token, key, or password — see Where the credentials go.

create-forge-agent.py reads it to resolve email/scopes/persona for a <username> it doesn't already have on the command line:

# email, persona and scopes all come from agents.toml — no flags needed
./create-forge-agent.py b4mad-castra

An explicit CLI value always wins over the manifest, and a field left as the placeholder "TBD" counts as absent — so an agent without a confirmed scope list still needs --scopes on the command line. As of 2026-07-31 all four agents have a confirmed list, so none of them does; b4mad-gitops still has a TBD email and needs it given explicitly.

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.

Getting a key back out (extract-agent-keys.sh)

When you need an agent's SSH key, GPG key or token in hand — signing something locally as the agent, debugging a push — unwrap the blob on demand rather than keeping a copy:

./extract-agent-keys.sh b4mad-release-agent          # -> a 0700 temp dir
./extract-agent-keys.sh b4mad-release-agent --print-token
eval "$(./extract-agent-keys.sh b4mad-release-agent --eval)"   # sets $AGENT_DIR

It reads the .enc.yaml from --manifests-dir's default location (FORGE_MANIFESTS_DIR overrides) and writes id_ed25519, id_ed25519.pub, gpg-private.asc, gpg-public.asc and token. rm -rf the directory when done — the same one-durable-copy rule applies, and a forgotten extraction is exactly the stale second copy --keep-dir warns about.

docs/demo-agent-identity.md is a runnable walkthrough of this: SSH auth, API whoami, and a GPG-signed commit, all as b4mad-release-agent, with real captured output. Re-run it with showboat verify.

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.

Rotating the token but not the identity (--keep-existing)

A full rotation replaces the signing keys too, and that is usually more than you meant. Rotating an agent's GPG key retroactively un-verifies every commit it ever signed: the key those signatures name no longer exists anywhere. Steep price for "mint a fresh token".

--keep-existing reads the private halves back out of the agent's own forgejo-agent-<u>.enc.yaml and re-asserts them unchanged, so the token rotates and the identity does not:

./create-forge-agent.py b4mad-something --scopes "…" \
    --keep-existing --overwrite --confirm        # keep both keys
./create-forge-agent.py b4mad-something --scopes "…" \
    --keep-existing gpg --overwrite --confirm    # keep GPG, mint a new SSH key

The plan says so out loud, and the kept keys carry no [--overwrite] marker because nothing is destroyed:

  keep      SSH key SHA256:aaa from forgejo-agent-b4mad-something.enc.yaml — not rotated
  keep      GPG key 1A2B…7788 from forgejo-agent-b4mad-something.enc.yaml — not rotated
  revoke    token 'b4mad-something' (id 12, created 2026-07-01…)   [--overwrite]

Notes:

  • The source is the SOPS manifest, not the live Secret and not the Forge — the Forge only stores public halves, and a hand-edited Secret would hand back an identity the next Argo CD sync silently reverts.
  • The recorded b4mad.net/ssh-fingerprint / b4mad.net/gpg-fingerprint annotations are cross-checked against the key material beside them. A mismatch aborts: the plan named the annotation, so continuing would keep a key you were never shown.
  • If a kept key turns out to be missing from the account it is re-uploaded; a GPG key that is present but unverified is re-added with the challenge signature. Same fingerprint either way, so neither is a rotation.
  • An agent with no .enc.yaml has nothing to keep, and the run says so rather than quietly minting a new identity and reporting success. That is every agent predating this tool — see Known gap.

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

Publish a repo on Radicle (rad-init-and-pin.py)

rad init is the easy half. The half that gets forgotten is that a freshly initialized repo lives only on the node that created it — it becomes durable when the seed at radicle-seed.b4mad.industries pins it, and that seed is configured from git:

agentic-forges/radicle-seed-ansibleinventory/host_vars/192.168.0.75.yamlradicle_pinned_repos

So the second half of "publish this" is a request to another repo's maintainer, carrying a 30-character RID nobody types correctly from memory. This does both:

./rad-init-and-pin.py ~/Source/some-repo --description "what it is"
plan for /home/goern/Source/some-repo — would do:

  init      radicle repo 'some-repo' (public, branch main, scope all)
  open      issue on agentic-forges/radicle-seed-ansible pinning the new RID

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

Dry run is the default for the same reason as above, plus one of its own: the apply step files something on someone else's repo, in public. Both halves are idempotent and each is surveyed before it runs — an already-initialized repo reuses its RID, an RID already in radicle_pinned_repos exits without asking for anything, and an open issue or PR that already names the RID is linked rather than duplicated (they are one request in two shapes, so either one suppresses the other). Re-running after "did that go through?" is the intended way to find out.

--pr: make the edit instead of asking for it

./rad-init-and-pin.py ~/Source/some-repo --pr --confirm

branches pin/<rid>, appends the RID to radicle_pinned_repos, and opens the PR — the same request with the transcription already done and reviewable. Prefer it. The issue stays the default because it needs only write:issue, and because a maintainer who has not opted into bot PRs would rather be asked.

The edit is line surgery, not a YAML round-trip, and that is the point: the file is read by humans and PyYAML would silently eat every comment in it, including the # NID from: rad node status note. It appends one line after the last list entry, matching the indent and quoting already there, and touches nothing else. The write carries the blob sha the survey read, so a file that moved on in the meantime gets a rejected write rather than a clobbered one.

⚠️ Merging the PR changes nothing on the seed by itself. pin-repos.yaml is what applies it.

Filing the request needs write:issue, plus write:repository for --pr. Pass --token/$FORGE_ISSUE_TOKEN, or let it unwrap one via extract-agent-keys.sh--as-agent, default b4mad-gitops, which carries both scopes precisely for "commit the edit and raise the paperwork when automation needs a human". The unwrapped copy is deleted before the script exits, and it is never unwrapped for the survey: the seed repo is public, so everything up to the plan runs with no credential at all.

Requirements: rad (with rad auth done) and git. The RID goes to stdout, everything else to stderr, so RID=$(./rad-init-and-pin.py . --confirm) works. --no-issue inits and stops.

⚠️ Pinning a repo your node never announces gives the playbook's wait for repository data step nothing to wait for — the script warns when rad node status says the node is down, but it cannot fix it for you.

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