- Python 89.3%
- Shell 10.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`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 |
||
| .agents/skills | ||
| .beads | ||
| .claude | ||
| .codex | ||
| docs | ||
| openshift | ||
| .gitattributes | ||
| .gitignore | ||
| .releaserc | ||
| AGENTS.md | ||
| agents.toml | ||
| bot-avatar.png | ||
| CLAUDE.md | ||
| create-forge-agent.py | ||
| extract-agent-keys.sh | ||
| rad-init-and-pin.py | ||
| README.md | ||
| skills-lock.json | ||
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.industriesis the same Forgejo that used to answer toforgejo.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_hostsdoes 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-fingerprintannotations 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.yamlhas 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-ansible → inventory/host_vars/192.168.0.75.yaml → radicle_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.--avataroverrides 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: falsemeans 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.net → git.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.