320 lines
22 KiB
Markdown
320 lines
22 KiB
Markdown
---
|
|
phase: 09-botfights-platform-upgrade
|
|
plan: 06
|
|
type: execute
|
|
wave: 3
|
|
depends_on: [09-05]
|
|
files_modified:
|
|
- apps/botfights/manifest.yml
|
|
- app-catalog/catalog.json
|
|
- scripts/image-versions.sh
|
|
- releases/app-catalog.json
|
|
autonomous: false
|
|
requirements: [BOT-04]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "A fresh BotFights install on any node starts successfully because the manifest declares JWT_SECRET as a generated secret — without it the 1.2.0 image throws at import and crash-loops (D-04/BOT-04)"
|
|
- "Each node gets its own JWT signing secret, materialised 0600 by the orchestrator, never hardcoded and never shared between nodes"
|
|
- "Node instances point at the shared public arena by default via ARENA_UPSTREAM_URL in the manifest, with a documented way for an operator to unset it and run standalone (D-03/BOT-03)"
|
|
- "Both catalog files and the image-version map name the same 1.2.0 image, so the drift checker is clean"
|
|
- "The regenerated releases/app-catalog.json embeds the new manifest and is signed by the release-root key before it is published"
|
|
artifacts:
|
|
- path: apps/botfights/manifest.yml
|
|
provides: "BotFights 1.2.0 manifest with generated JWT secret, secret_env injection and default-on arena federation"
|
|
contains: "botfights-jwt-secret"
|
|
- path: releases/app-catalog.json
|
|
provides: "Regenerated, release-root-signed catalog carrying the embedded 1.2.0 manifest"
|
|
contains: "botfights"
|
|
key_links:
|
|
- from: apps/botfights/manifest.yml
|
|
to: the orchestrator's secrets provider
|
|
via: "generated_secrets (kind hex32) materialises /var/lib/archipelago/secrets/botfights-jwt-secret and secret_env injects it as JWT_SECRET"
|
|
pattern: "secret_env"
|
|
- from: releases/app-catalog.json
|
|
to: every node's container::app_catalog fetch
|
|
via: "the catalog manifest overlay wins over the on-disk manifest, so this file is what actually takes effect on nodes"
|
|
pattern: "manifest"
|
|
---
|
|
|
|
<objective>
|
|
Deliver D-04/BOT-04 in the archy repo: bump the BotFights app manifest to 1.2.0, give it the
|
|
generated `JWT_SECRET` it now cannot start without, turn on shared-arena federation by default,
|
|
regenerate the signed catalog, have the release key sign it, and publish.
|
|
|
|
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
|
|
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public arena (its default-on clause is
|
|
implemented here), D-04 = BOT-04 registry/manifest + signed catalog updated and republished.
|
|
|
|
Purpose: the catalog manifest overlay is authoritative on nodes — editing a manifest on disk does
|
|
nothing for a catalog-covered app. So this plan is the only way the fleet ever sees 1.2.0. It also
|
|
carries a blocking correctness fix: `server/src/middleware/jwt.ts` throws at module import when
|
|
`JWT_SECRET` is unset and `NODE_ENV=production`, and today's manifest sets `NODE_ENV=production`
|
|
with no `JWT_SECRET` — publishing 1.2.0 without the generated secret would crash-loop every fresh
|
|
install.
|
|
|
|
**Repo: `/home/archipelago/Projects/archy`.** Commit target: `git push gitea-ai main` (main is
|
|
protected; the `ai` account is the push path), plus the vps2 mirror that serves the catalog.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
|
|
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
|
|
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
|
|
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
|
|
@.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md
|
|
@apps/botfights/manifest.yml
|
|
@apps/netbird-server/manifest.yml
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Manifest 1.2.0 — generated JWT secret, default-on arena, both catalogs in lockstep</name>
|
|
<reversibility rating="costly">Publishing this manifest switches every node's BotFights to the
|
|
shared arena by default, and node-local fighter data stops being read (it is preserved on disk,
|
|
never deleted). Mechanically revertible by republishing a catalog without `ARENA_UPSTREAM_URL`,
|
|
but any bots users register on the public arena in the meantime stay there. The blocking human
|
|
gate in Task 2 is where that trade is accepted or refused.</reversibility>
|
|
<files>apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh, releases/app-catalog.json</files>
|
|
<read_first>
|
|
- `apps/botfights/manifest.yml` — all 76 lines. Only `app.version`, `container.image`,
|
|
`container.generated_secrets`, `container.secret_env` and `environment` change; the security,
|
|
ports, volumes, health_check, interfaces and metadata blocks stay exactly as they are.
|
|
- `apps/netbird-server/manifest.yml` lines 17-32 — the established `generated_secrets` shape
|
|
(`- name: <file>` / `kind: <kind>`), with its comment explaining why netbird needs `base64`.
|
|
- `apps/fedimint-clientd/manifest.yml` lines 24-27 and `apps/barkd/manifest.yml` lines 24-26 —
|
|
the `secret_env` shape (`- key: <ENV>` / `secret_file: <file>`).
|
|
- `core/container/src/manifest.rs` lines 358-400 — `SecretGenKind` (`hex16`, `hex32`, `base64`,
|
|
`bcrypt`) and `GeneratedSecret::target_files`. `hex32` is 32 random bytes as 64 lowercase hex
|
|
chars, which is exactly what the app's own docs tell an operator to generate.
|
|
- `app-catalog/catalog.json` lines 109-133 — the legacy hand-maintained `botfights` entry
|
|
(`version`, `dockerImage`, `containerConfig.env`). This file is separate from
|
|
`releases/app-catalog.json` and is what `scripts/check-app-catalog-drift.py` compares against.
|
|
- `scripts/image-versions.sh` line 93 — `BOTFIGHTS_IMAGE`.
|
|
- `scripts/generate-app-catalog.sh` — the `EMBED_MANIFESTS` loop that reads every
|
|
`apps/*/manifest.yml` and embeds the whole document under each entry's `manifest` key;
|
|
`releases/app-catalog.json` is a build output and is never hand-edited.
|
|
- `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — the verified public arena
|
|
URL and the 1.2.0 image digest.
|
|
</read_first>
|
|
<action>
|
|
Edit `apps/botfights/manifest.yml` (the current values are in the file — read it first):
|
|
- `app.version`: set to `1.2.0`.
|
|
- `container.image`: set the tag to `1.2.0`, same registry path. No occurrence of the previous
|
|
tag may survive anywhere in this file.
|
|
- Add under `container`, following the netbird/fedimint-clientd shapes:
|
|
`generated_secrets` with one entry named `botfights-jwt-secret` of kind `hex32`, and
|
|
`secret_env` with one entry mapping key `JWT_SECRET` to secret_file `botfights-jwt-secret`.
|
|
Use `hex32`, not `base64` — the app uses the value directly as an HMAC key with no decode
|
|
step, so copying netbird's kind verbatim would be wrong. Add a comment naming the concrete
|
|
failure this prevents: the auth module throws at import when this variable is absent under
|
|
`NODE_ENV=production`, so a fresh install without it never starts.
|
|
- Extend `environment` (keeping `NODE_ENV=production`) with:
|
|
`PORT=9100`, `ARENA_UPSTREAM_URL=<the arena base URL from 09-05-SUMMARY.md>`, and a comment
|
|
recording that an operator may remove `ARENA_UPSTREAM_URL` to run a standalone, node-local
|
|
arena. Do not add any wallet/payments variable and do not add `FIGHT_LOOP_ENABLED` — in proxy
|
|
mode a node-local fight loop would write to a database nothing reads.
|
|
- Leave every other block byte-identical.
|
|
|
|
Edit `app-catalog/catalog.json`'s `botfights` entry: `version` → `1.2.0`, `dockerImage` tag →
|
|
`1.2.0`. Leave its `containerConfig` block otherwise as-is — this legacy file is not what nodes
|
|
install from, but drift here trips the checker.
|
|
|
|
Edit `scripts/image-versions.sh`: `BOTFIGHTS_IMAGE` tag → `1.2.0`.
|
|
|
|
Regenerate the signed catalog input: `scripts/generate-app-catalog.sh` (EMBED_MANIFESTS defaults
|
|
on). Then confirm by reading `releases/app-catalog.json` that the `botfights` entry's `version`
|
|
is `1.2.0` and its embedded `manifest` carries the image tag, the generated secret, the
|
|
secret_env mapping and the arena URL.
|
|
|
|
Build the signer binary now so the human ceremony in Task 2 is not blocked waiting on a compile:
|
|
`scripts/sign-catalog.sh` refuses to compile anything itself and needs
|
|
`/tmp/archy-sign-bin/release/archipelago` to exist. Build the release binary and place it there
|
|
(background the build; if it hits `rust-lld: undefined hidden symbol`, that is incremental-cache
|
|
corruption — rebuild with `CARGO_INCREMENTAL=0`).
|
|
|
|
Finally, prepare the impact briefing the human gate needs, and put it in the SUMMARY: the number
|
|
of bots and fights currently held in each reachable node's local BotFights database (on
|
|
archi-dev-box, read `/var/lib/archipelago/botfights/botfights.db` with a read-only sqlite
|
|
connection — planning measured 115 bots and 102,440 fights), what happens to them under proxy
|
|
mode (preserved on disk, no longer displayed), and how an operator reverts a single node.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/archipelago/Projects/archy && python3 scripts/check-app-catalog-drift.py && cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `python3 scripts/check-app-catalog-drift.py` reports no drift for `botfights`.
|
|
- `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests` exits 0 (the new manifest deserializes and validates as an overlay).
|
|
- `cd core && cargo test -p container manifest` exits 0.
|
|
- `grep -q 'botfights-jwt-secret' apps/botfights/manifest.yml` and `grep -q 'kind: hex32' apps/botfights/manifest.yml` both succeed.
|
|
- `grep -q 'JWT_SECRET' apps/botfights/manifest.yml` succeeds and no line in that file assigns it a literal value (`grep -Eq 'JWT_SECRET=' apps/botfights/manifest.yml` finds nothing).
|
|
- `grep -c '1.2.0' apps/botfights/manifest.yml` is at least 2; `grep -c '1.1.0' apps/botfights/manifest.yml` is 0.
|
|
- `grep -q 'ARENA_UPSTREAM_URL' apps/botfights/manifest.yml` succeeds and the value matches the arena URL recorded in `09-05-SUMMARY.md`.
|
|
- `python3 -c "import json;d=json.load(open('releases/app-catalog.json'));b=d['apps']['botfights'];m=b['manifest']['app'];assert b['version']=='1.2.0';assert m['container']['image'].endswith(':1.2.0');assert any(s['name']=='botfights-jwt-secret' for s in m['container']['generated_secrets']);assert any(e['key']=='JWT_SECRET' for e in m['container']['secret_env']);assert any('ARENA_UPSTREAM_URL' in e for e in m['environment']);print('catalog ok')"` prints `catalog ok`.
|
|
- `test -x /tmp/archy-sign-bin/release/archipelago` succeeds.
|
|
- `git -C /home/archipelago/Projects/archy status --short` shows only the four intended files changed (another agent shares this tree — stage by explicit path).
|
|
</acceptance_criteria>
|
|
<done>The manifest, both catalog files and the image map all describe BotFights 1.2.0 with a per-install JWT secret and default-on arena federation, and the regenerated catalog proves it.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-action" gate="blocking">
|
|
<name>Task 2: Sign the regenerated catalog with the release master mnemonic (fleet-impact gate)</name>
|
|
<what-built>
|
|
`releases/app-catalog.json` has been regenerated with BotFights 1.2.0 embedded: a per-install
|
|
generated `JWT_SECRET`, and `ARENA_UPSTREAM_URL` pointing at the public arena. The signer binary
|
|
is built and waiting. Nothing has been pushed yet.
|
|
|
|
**What signing and publishing this changes, fleet-wide:**
|
|
- Every node that refreshes its catalog sees BotFights 1.2.0 and can update to it.
|
|
- Updated nodes become thin clients of the shared public arena: all nodes see all fighters, and
|
|
fights cross nodes. That is the point of this phase (D-03).
|
|
- Each node's own BotFights database stops being read. Nothing is deleted — the file stays at
|
|
`/var/lib/archipelago/botfights/` — but locally-registered fighters no longer appear in the
|
|
UI. On archi-dev-box that is 115 bots and 102,440 fights (see the SUMMARY for the current
|
|
count on every reachable node, and whether they were seeded into the arena in plan 09-04).
|
|
- Each node generates its own JWT secret on next install/update, so existing browser sessions on
|
|
that node are signed out once.
|
|
- Reverting means regenerating and re-signing a catalog without `ARENA_UPSTREAM_URL`; bots that
|
|
users register on the public arena in the meantime stay on the public arena.
|
|
|
|
A single node can opt out at any time by removing `ARENA_UPSTREAM_URL` from its container env.
|
|
</what-built>
|
|
<action>
|
|
Human-only: run the signing ceremony and enter the 24-word release master mnemonic at your own
|
|
terminal. The mnemonic never passes through Claude, is never stored, and is never scripted
|
|
around. This is also the approval gate for switching the fleet's BotFights to the shared arena.
|
|
</action>
|
|
<instructions>
|
|
If you are happy to publish, run the ceremony:
|
|
|
|
```
|
|
bash /home/archipelago/Projects/archy/scripts/sign-catalog.sh
|
|
```
|
|
|
|
Paste your 24-word release master mnemonic, press Enter, then Ctrl-D. The script signs
|
|
`releases/app-catalog.json` in place and checks the signature was made by the expected
|
|
release-root key. Your mnemonic is read from the terminal only — never stored, never passed to
|
|
Claude. The script prints either a success line or a clear failure; if it fails, do not commit —
|
|
say so and Claude will investigate.
|
|
|
|
If you would rather not switch the fleet to the shared arena yet, say "hold federation" and
|
|
Claude will regenerate the catalog with `ARENA_UPSTREAM_URL` omitted (nodes stay standalone,
|
|
opt-in per node) before you sign anything.
|
|
</instructions>
|
|
<verification>
|
|
The script prints `✅ SUCCESS — catalog signed by the correct release-root key` on success.
|
|
Claude then re-checks independently in Task 3: `releases/app-catalog.json` carries a non-empty
|
|
`signature` and `signed_by` equal to `did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur`,
|
|
and the `botfights` entry is still 1.2.0 with its embedded manifest intact.
|
|
</verification>
|
|
<resume-signal>Reply "signed" once the script reports success, or "hold federation", or paste the failure output</resume-signal>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Verify the signature, commit both repos' work and publish the catalog</name>
|
|
<precondition>The signing ceremony in Task 2 reported success and `releases/app-catalog.json` now carries a `signature` and the expected `signed_by` DID.</precondition>
|
|
<files>releases/app-catalog.json, apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh</files>
|
|
<read_first>
|
|
- `scripts/sign-catalog.sh` lines 30-40 — the exact success condition it checks
|
|
(`"signed_by": "did:key:z6Mkkid…"` present alongside a `signature` key); re-assert it
|
|
independently rather than trusting the reply.
|
|
- `core/archipelago/src/container/app_catalog.rs` lines 403-445 — how a node verifies the
|
|
fetched catalog: an absent signature is accepted during the migration window, but a present
|
|
signature that fails verification is a hard reject. A malformed publish would take BotFights
|
|
(and every other app entry) off the fleet's catalog, so publishing a broken signature is worse
|
|
than publishing none.
|
|
- `core/archipelago/src/container/app_catalog.rs` line ~572 — the raw URL nodes fetch:
|
|
`http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`. That is
|
|
the vps2 Gitea, so the publish is not complete until `main` is on that remote.
|
|
- `CLAUDE.md` "Commit & push every unit of work" — stage by explicit path (another agent shares
|
|
this tree), never `git add -A`, and `main` is protected so pushes go through `gitea-ai`.
|
|
</read_first>
|
|
<action>
|
|
Verify the signature independently of the script's own report: confirm
|
|
`releases/app-catalog.json` contains a non-empty `signature` and a `signed_by` equal to the
|
|
expected release-root DID, that the file is still valid JSON, and that the `botfights` entry is
|
|
still 1.2.0 with its embedded manifest intact (signing must not have altered the payload).
|
|
|
|
Commit the four archy files in one focused commit, staged by explicit path, with a message
|
|
describing the BotFights 1.2.0 catalog publish and the generated-secret fix, ending with the
|
|
`Co-Authored-By: Claude …` trailer. Push with `git push gitea-ai main`.
|
|
|
|
Publish: ensure the same `main` lands on the vps2 Gitea that nodes fetch the catalog from, then
|
|
confirm from off-host that the published bytes are the signed ones —
|
|
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`
|
|
must return JSON whose `botfights` version is `1.2.0` and whose `signature` matches the local
|
|
file's. If a remote's token is stale (a known recurring issue with the vps2/local-origin
|
|
remotes), report it rather than improvising a workaround, and do not leave the catalog published
|
|
to some mirrors and not others without saying so explicitly in the SUMMARY.
|
|
|
|
Do not commit any secret: no registry token, no mnemonic, no JWT value. Verify the diff before
|
|
committing.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/archipelago/Projects/archy && python3 -c "import json;d=json.load(open('releases/app-catalog.json'));assert d.get('signature');assert d.get('signed_by')=='did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur';assert d['apps']['botfights']['version']=='1.2.0';print('signed ok')" && curl -fsS --max-time 20 http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json | python3 -c "import json,sys;d=json.load(sys.stdin);assert d['apps']['botfights']['version']=='1.2.0';assert d.get('signature');print('published ok')"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- The local `releases/app-catalog.json` has a non-empty `signature` and the expected `signed_by` DID, and its `botfights` entry is 1.2.0 with the embedded manifest intact.
|
|
- `curl` of the vps2 raw catalog URL returns the same signed content with `botfights` at 1.2.0.
|
|
- `git -C /home/archipelago/Projects/archy log --oneline -1` shows the publish commit, and `git status --short` shows no leftover staged changes from this plan.
|
|
- `git -C /home/archipelago/Projects/archy show --stat HEAD` lists exactly the four intended files.
|
|
- `git -C /home/archipelago/Projects/archy show HEAD | grep -Eic '(mnemonic|BEGIN [A-Z ]*PRIVATE KEY)'` is 0.
|
|
</acceptance_criteria>
|
|
<done>The signed 1.2.0 catalog is live at the URL nodes fetch, verified from off-host.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| release-root key (human, offline) → catalog | The only authority that makes a catalog trustworthy to nodes |
|
|
| published catalog → every node's orchestrator | A catalog entry decides which image a node runs and with what env |
|
|
| orchestrator secrets provider → container env | `JWT_SECRET` is materialised and injected without ever passing through a manifest literal |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-09-28 | Denial of service | fresh 1.2.0 installs crash-looping on a missing `JWT_SECRET` | critical | mitigate | `generated_secrets` (`hex32`) + `secret_env` in the manifest, asserted by both the catalog JSON check and the manifest-overlay test (Task 1) |
|
|
| T-09-29 | Spoofing | a tampering mirror serving an altered catalog | high | mitigate | Release-root signature over the raw JSON; nodes hard-reject a present-but-invalid signature. The signature is verified locally and again from the published URL (Tasks 2-3) |
|
|
| T-09-30 | Information disclosure | a shared or hardcoded JWT secret across the fleet | high | mitigate | Per-install generation by the orchestrator, written 0600 and owned by the rootless service user; no literal value anywhere in git |
|
|
| T-09-31 | Elevation of privilege | the mnemonic passing through the agent | critical | mitigate | The ceremony is a human-only TTY step in `scripts/sign-catalog.sh`; the plan never scripts around it and the commit is grepped for key material |
|
|
| T-09-32 | Tampering | publishing a catalog whose signature does not match its payload | high | mitigate | Independent post-ceremony verification plus a published-bytes check from off-host (Task 3) |
|
|
| T-09-33 | Repudiation | node-local fighter data becoming invisible without the operator knowing | medium | mitigate | The blocking gate states the exact counts and the revert path; no data is deleted |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `python3 scripts/check-app-catalog-drift.py` — clean.
|
|
- `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests && cargo test -p container manifest` — green.
|
|
- The published catalog at the vps2 raw URL carries a valid release-root signature and BotFights 1.2.0.
|
|
- Maps to `09-VALIDATION.md` row "BOT-04 | crash-loop | manifest declares JWT_SECRET via
|
|
generated_secrets | unit (archy) | `cd core && cargo test -p container manifest`", upgrading it
|
|
from "partial" with the added catalog-content assertion.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- BotFights 1.2.0 is described identically by the manifest, both catalog files and the image map.
|
|
- A fresh install cannot crash-loop for want of a JWT secret, and no two nodes share one.
|
|
- Node instances default to the shared public arena, with a documented per-node opt-out.
|
|
- The catalog is signed by the release-root key and published where nodes fetch it.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` when done, recording the
|
|
per-node local bot/fight counts that were surfaced at the gate, the decision taken there, and the
|
|
published catalog URL.
|
|
Stage by explicit path, commit, and `git push gitea-ai main`.
|
|
</output>
|