diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index f44057268d..f31d4b835f 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -111,7 +111,6 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - MERGED_BY: ${{ github.event.pull_request.merged_by.login }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | VERSION="${VERSION#desktop-v}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a331825619..0e50798020 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,6 +704,17 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: NIP-MP coordinate deletion guard + # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: + # a stale tombstone (created_at earlier than the live head) spares that + # head, and an equal-timestamp tombstone deletes it. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -739,7 +750,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..1bf2efb66b --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.3", + "base_sha": "54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a", + "previous_tag": "v0.5.2", + "tag": "desktop-v0.5.3", + "commit_count": 58 +} diff --git a/AGENTS.md b/AGENTS.md index b50c1eaec5..301c877a54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,17 @@ it will not be fixed by configuration. **If an agentic (05:00) PR ever lands, re-merge upstream by hand afterwards** or the counter stays stuck: `git merge upstream/main` on `main`, resolve, push. +**The patch transfer also drops the executable bit, and it surfaces as a red CI +run rather than as an obvious defect.** The 2026-08-01 agentic PR (#12) carried +upstream's new `scripts/test-desktop-release-authorization.sh` and +`scripts/verify-desktop-release-authorization.sh` at mode `100644` where upstream +has `100755`, so `scripts/test-release-ref-contract.sh` could not execute them: +`Detect Changed Paths` died with `Permission denied` and exit 126, which then +skipped every downstream job. Upstream's content was fine — the same contract +script passes on a hand merge of the same range. **A mode-only change is a +zero-line entry in `git diff --stat`**, so a diff skim will not show it; when a +sync adds a script, compare `git ls-tree -- ` against `upstream/main`. + **When the agentic stage files an issue, read the bottom of it before assuming it gave up.** gh-aw converts an intended pull request into an issue when the push fails, keeping the whole PR body — resolutions included — and appending the git @@ -173,10 +184,9 @@ place. | `linux-canary.yml`, `windows-canary.yml` | `RELEASE_REPO` guard | Were pinned to `block/buzz` | | `infra/aws/` | new directory | Terraform deploying the relay to AWS account `618867225791` (`eu-west-3`) on ECS Fargate + RDS + ElastiCache + S3 + EFS, serving `wss://relay.bitcoinmarkets.app`. Upstream deploys via `deploy/charts/buzz` (Helm) and has no Terraform, so this adds only new paths and should never conflict. See [`infra/aws/README.md`](infra/aws/README.md) | | `.github/workflows/deploy-aws.yml` | new | Continuous deployment of the relay to AWS on every push to `main`. Runs after `docker.yml` via `workflow_run`, authenticates by OIDC (no stored keys), and applies Terraform with the commit's immutable `:sha-<7>` image | -| `desktop/src-tauri/src/relay_allowlist.rs` | new | Single-relay host allowlist. Upstream is multi-community by design; this fork ships a client that reaches only `relay.bitcoinmarkets.app` | +| `desktop/src-tauri/src/relay/allowlist.rs` | new | Single-relay host allowlist. Upstream is multi-community by design; this fork ships a client that reaches only `relay.bitcoinmarkets.app`. **Lives under `relay/`, not at the crate root** — see the `relay.rs` row | | `desktop/src-tauri/src/native_websocket.rs` | allowlist call in `open_connection` | The transport is the one path every relay session takes, so a host restriction there cannot be bypassed from the UI | -| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay | Without it a release build defaults to `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all | -| `desktop/src-tauri/src/lib.rs` | `mod relay_allowlist;` | Registers the new module | +| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because upstream's `lib.rs` sits at exactly the 1000-line desktop file-size ratchet limit with no headroom, so the fork's two-line `mod` block there failed `just desktop-check` as soon as upstream added anything (it did, in the 2026-08-01 sync). `lib.rs` now carries no fork patch at all | | `mobile/lib/shared/relay/relay_allowlist.dart` | new | Mobile counterpart. Skips enforcement under `flutter test` (`FLUTTER_TEST`) because upstream tests use `wss://relay.example.com`; editing those 13 files would be a large permanent conflict surface | | `mobile/lib/shared/relay/relay_socket.dart` | allowlist call in `connect()` | Transport choke point, as on desktop | | `mobile/lib/shared/relay/relay_validation.dart` | allowlist call after the shape checks | One hunk covers all four invite/deep-link call sites; placed after the existing checks so malformed input keeps its original error | @@ -239,6 +249,17 @@ exactly where upstream adds new parameterized-replaceable kinds. Leaving a fork constant inside it re-creates this conflict on every such addition. **Put new fork-local kinds in that block, not next to the upstream kind they relate to.** +**That placement stops integer collisions, not text conflicts — expect a routine +one there and do not read it as a collision.** The fork block sits at the end of +`kind.rs`'s constant list, and that is also where upstream appends, so both sides +insert at the same anchor. The 2026-08-01 sync hit exactly this when upstream +added `KIND_PROJECT = 30621` (NIP-MP, #3171): a three-way conflict on adjacent +lines between two kinds that share no integer and no schema. **The resolution is +keep-both, upstream's constant first in its natural position and the fork block +after it** — no renumber, no migration, no `ALL_KINDS` or assertion edit beyond +what each side already brought. Check the integers before reaching for the +renumber procedure above; it applies only when the *values* actually coincide. + Moving a kind is a **wire-format change**: events already stored under the old integer are not rewritten, and clients pinned to it stop matching. Check for existing events before moving one that has been live. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3953040d..5810387f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## v0.5.3 + +### Desktop and shared changes + +- Revert "chore(release): release Buzz Desktop version 0.5.3" ([#3960](https://github.com/block/buzz/pull/3960)) ([`bb34bc4d98fe4dabe847046103ac5e2859917ac5`](https://github.com/block/buzz/commit/bb34bc4d98fe4dabe847046103ac5e2859917ac5)) +- chore(release): release Buzz Desktop version 0.5.3 ([`d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131`](https://github.com/block/buzz/commit/d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131)) +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): require exact-head approval for desktop tags ([#3973](https://github.com/block/buzz/pull/3973)) ([`54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`](https://github.com/block/buzz/commit/54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a)) +- fix(release): make desktop tagging squash-safe ([#3965](https://github.com/block/buzz/pull/3965)) ([`db7e84d4f815127236b9cb080c5d374f48eaac09`](https://github.com/block/buzz/commit/db7e84d4f815127236b9cb080c5d374f48eaac09)) +- docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS ([#2864](https://github.com/block/buzz/pull/2864)) ([`209536ade6c5ebf7fa82671d7ca0b74f599a40cc`](https://github.com/block/buzz/commit/209536ade6c5ebf7fa82671d7ca0b74f599a40cc)) +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/README.md b/README.md index 2c58ceecad..a049e87072 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,14 @@ Grab a packaged build from the [latest release](https://github.com/block/buzz/re By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally. +### I want my own hosted relay + +To run a relay for your team without managing servers, you can deploy one to Railway in a click: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/buzz-relay-block) + +See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details. + ### I work at Block Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 158477c0af..348bc138e4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3624,7 +3624,11 @@ pub(crate) fn build_turn_metric_counts( // from input+output. total_tokens: usage.turn_total_tokens, cost_usd: usage.turn_cost_usd, - cache_read_tokens: None, + // Field-local: present when the cumulative counter was monotonic + // across this turn. Zero means no cache hits this turn (not absent). + cache_read_tokens: usage.turn_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }) } else { @@ -3642,7 +3646,13 @@ pub(crate) fn build_turn_metric_counts( // one. Never derived from input+output (NIP-AM MUST NOT). total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, - cache_read_tokens: None, + // Session-cumulative cache-read tokens; None when the harness never + // reported this field (e.g. goose or older buzz-agent sessions). + // Passes through directly — do not wrap in Some() as the field already + // carries provenance (None vs Some(0) are distinct meanings). + cache_read_tokens: usage.cumulative_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }); (turn_counts, cumulative_counts) @@ -6022,10 +6032,12 @@ mod tests { turn_output_tokens: Some(50), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // owner_pubkey = None → early return, no panic. @@ -6056,10 +6068,12 @@ mod tests { turn_output_tokens: Some(80), turn_total_tokens: None, turn_cost_usd: Some(0.001), + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish and fail (no real relay) but must not panic. @@ -6091,10 +6105,12 @@ mod tests { turn_output_tokens: Some(20), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. @@ -6126,10 +6142,12 @@ mod tests { turn_output_tokens: None, turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. @@ -6158,10 +6176,12 @@ mod tests { turn_output_tokens: Some(30), turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 500, cumulative_output_tokens: 120, cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6205,10 +6225,12 @@ mod tests { turn_output_tokens: Some(60), turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 60, cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6248,6 +6270,96 @@ mod tests { ); } + /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn + /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is + /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta. + /// This is the acceptance-criterion test: it proves the threading is live, + /// not hardcoded to None. + #[test] + fn test_build_turn_metric_counts_cache_read_tokens_thread_through() { + // Wire-parse a buzz-agent payload with cache, run it through the tracker, + // and verify the published TokenCounts carry the cache field. + let raw1 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + } + }); + let raw2 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 28_500, + "accumulatedOutputTokens": 310, + "accumulatedCachedInputTokens": 11_000, + } + }); + + let mut tracker = crate::usage::UsageTracker::default(); + + // Turn 1 — establish baseline (delta unreliable, but cumulative still present). + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw1) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t1 = tracker.take().expect("turn 1"); + + // Turn 1: cumulative must carry the cache count; turn delta is None (no baseline). + let (turn1, cum1) = crate::pool::build_turn_metric_counts(&t1); + // delta_reliable = false on first turn → no turn counts. + assert!(turn1.is_none(), "first turn: no reliable turn counts"); + let cum1 = cum1.expect("cumulative always present"); + assert_eq!( + cum1.cache_read_tokens, + Some(5_033), + "cumulative.cacheReadTokens must be 5033 after turn 1" + ); + + // Turn 2 — delta reliable. + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw2) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t2 = tracker.take().expect("turn 2"); + + let (turn2, cum2) = crate::pool::build_turn_metric_counts(&t2); + + let turn2 = turn2.expect("reliable turn counts on turn 2"); + // Per-turn cache delta: 11_000 - 5_033 = 5_967. + assert_eq!( + turn2.cache_read_tokens, + Some(5_967), + "turn.cacheReadTokens must be the per-turn delta" + ); + // cache_write_tokens is always None — buzz-agent doesn't emit it. + assert!( + turn2.cache_write_tokens.is_none(), + "cache_write_tokens must be None — not emitted by buzz-agent" + ); + + let cum2 = cum2.expect("cumulative always present"); + assert_eq!( + cum2.cache_read_tokens, + Some(11_000), + "cumulative.cacheReadTokens must be 11_000 after turn 2" + ); + assert!( + cum2.cache_write_tokens.is_none(), + "cache_write_tokens must be None on cumulative too" + ); + } + fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 1629eee935..56b772d12c 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,12 +85,16 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, - /// The cache-served subset of `accumulated_input_tokens`. Optional — goose - /// does not send it, and buzz-agent only reports a non-zero value when the - /// provider returned a cache split, so `0` legitimately means either "no - /// cache hits" or "provider reported none". - #[serde(default)] - pub accumulated_cached_input_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose, which + /// never emits it). `Some(0)` when the harness explicitly reported zero + /// cache hits. The distinction matters: `None` means "we don't know", + /// while `Some(0)` means "provider confirmed no cache was used". + /// + /// Do NOT use `#[serde(default)]` here — that would collapse the absent + /// case into `Some(0)` and destroy provenance in the append-only archive. + pub accumulated_cached_input_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -125,6 +129,12 @@ struct SessionState { /// `None` when the session has never emitted a provider total (Unseen) or /// when any prior turn lacked one (poisoned). last_total: Option, + /// Cumulative cache-read input tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field (e.g. goose). + /// `Some(n)` when at least one payload included the field. Field-local: + /// a decrease in this counter taints only the cache-read delta, not + /// `delta_reliable` or the input/output deltas. + last_cached_input: Option, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -151,6 +161,12 @@ pub struct TurnUsage { /// Per-turn cost delta (`current − previous`); `None` when unreliable or /// either snapshot is missing. pub turn_cost_usd: Option, + /// Per-turn cache-read token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None` (harness did not report it), + /// or the cumulative counter decreased (field-local taint). Field-local: + /// a decrease here never flips `delta_reliable` or invalidates the + /// input/output deltas. + pub turn_cache_read_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. @@ -160,6 +176,11 @@ pub struct TurnUsage { pub cumulative_total_tokens: Option, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, + /// Session-cumulative cache-read input tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field (e.g. goose or + /// any harness that omits `accumulatedCachedInputTokens`). + /// `Some(0)` when the harness reported zero cache hits. + pub cumulative_cache_read_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, @@ -239,6 +260,7 @@ impl UsageTracker { let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; + let current_cached_input = payload.accumulated_cached_input_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that @@ -294,6 +316,21 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-read token delta: field-local — never affects `delta_reliable` + // or the input/output deltas. Null when: no baseline exists, either + // snapshot is None (harness did not report the field), or the cumulative + // counter decreased (harness restart, overflow). + // Some(0) is a valid result when both snapshots are Some(0) — it means + // the harness confirmed zero cache hits this turn, not that data is absent. + let turn_cache_read = match self.sessions.get(session_id) { + Some(prev) => match (current_cached_input, prev.last_cached_input) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). @@ -305,10 +342,12 @@ impl UsageTracker { turn_output_tokens: turn_output, turn_total_tokens: turn_total, turn_cost_usd: turn_cost, + turn_cache_read_tokens: turn_cache_read, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, + cumulative_cache_read_tokens: current_cached_input, model: payload.model.clone(), }); } else if self.in_flight_session.is_none() { @@ -327,6 +366,7 @@ impl UsageTracker { last_output: current_output, last_cost: current_cost, last_total: current_total, + last_cached_input: current_cached_input, }, ); } @@ -355,6 +395,7 @@ impl UsageTracker { last_output: record.cumulative_output_tokens, last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, + last_cached_input: record.cumulative_cache_read_tokens, }, ); Some(record) @@ -366,9 +407,9 @@ mod tests { use super::*; /// The camelCase key buzz-agent actually puts on the wire must land on the - /// field. A rename mismatch here would deserialize to the serde default of - /// 0, and every trial would price as if nothing had ever been cached — the - /// exact silent failure this field was added to remove. + /// field. A rename mismatch here would deserialize to None, and every trial + /// would be treated as "not reported" — the exact silent failure this field + /// was added to remove. #[test] fn cached_input_tokens_deserialize_from_the_wire_key() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ @@ -379,13 +420,14 @@ mod tests { "accumulatedCachedInputTokens": 5_033, })) .expect("payload must deserialize"); - assert_eq!(p.accumulated_cached_input_tokens, 5_033); - assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); } - /// goose does not send the field; its payloads must still deserialize. + /// goose does not send the field; its payloads must deserialize with None — + /// not zero — so that "not reported" is preserved distinct from "reported zero". #[test] - fn a_payload_without_the_cache_field_defaults_to_zero() { + fn a_payload_without_the_cache_field_deserializes_as_none() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ "used": 500, "contextLimit": 200_000, @@ -393,7 +435,28 @@ mod tests { "accumulatedOutputTokens": 100, })) .expect("payload must deserialize without the cache field"); - assert_eq!(p.accumulated_cached_input_tokens, 0); + assert!( + p.accumulated_cached_input_tokens.is_none(), + "absent field must be None, not Some(0)" + ); + } + + /// A harness that explicitly reports zero cache hits must produce Some(0), + /// not None — so downstream analytics can distinguish "confirmed zero" from + /// "not reported". + #[test] + fn a_payload_with_explicit_zero_cache_field_deserializes_as_some_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + "accumulatedCachedInputTokens": 0, + })) + .expect("payload must deserialize with zero cache field"); + assert_eq!( + p.accumulated_cached_input_tokens, + Some(0), + "explicit zero must be Some(0), not None" + ); } fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { @@ -402,7 +465,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -415,7 +478,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -913,7 +976,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), @@ -977,7 +1040,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, @@ -1132,4 +1195,320 @@ mod tests { ); assert_eq!(usage.cumulative_total_tokens, Some(250)); } + + // ── cache-read token threading ────────────────────────────────────────── + + fn payload_with_cache( + input: u64, + output: u64, + cached_input: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + } + } + + #[test] + fn cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through() { + // First turn has no baseline → turn cache delta must be None, but + // cumulative_cache_read_tokens must carry the reported value through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c1"); + tracker.record("sess-c1", &payload_with_cache(1000, 200, Some(500))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "first turn: no baseline → cache delta must be None" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative cache read passes through on first turn" + ); + assert!(!usage.delta_reliable, "first turn is unreliable"); + } + + #[test] + fn cache_read_second_turn_delta_computed_correctly() { + // Second turn: cumulative cached 500 → 1200, delta = 700. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(1000, 200, Some(500))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(2000, 350, Some(1200))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(700), + "cache delta = 1200 - 500 = 700" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(1200), + "cumulative cache passes through" + ); + } + + #[test] + fn cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable() { + // Cache counter decrease → cache delta None (field-local taint), but + // delta_reliable and input/output deltas are NOT affected. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c3"); + tracker.record("sess-c3", &payload_with_cache(1000, 200, Some(800))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c3"); + // Cache counter decreased: 800 → 50. + tracker.record("sess-c3", &payload_with_cache(1500, 300, Some(50))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "cache decrease must NOT flip delta_reliable — field-local" + ); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "input/output delta unaffected by cache decrease" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + assert!( + usage.turn_cache_read_tokens.is_none(), + "cache counter decrease → turn_cache_read_tokens None (field-local taint)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(50), + "cumulative still passes through from payload even on decrease" + ); + } + + #[test] + fn cache_read_explicit_zero_payload_after_explicit_zero_baseline_produces_some_zero_delta() { + // When both baseline and current are Some(0), turn_cache_read_tokens must + // be Some(0) — confirmed zero, not absent. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1000, 200, Some(0))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1500, 300, Some(0))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(0), + "explicit zero on both sides → Some(0), not None" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(0)); + } + + #[test] + fn cache_read_threads_through_setup_notification_baseline() { + // A setup notification (before begin_turn) with a nonzero cache count + // must update the committed baseline so the first real turn gets a + // correct delta from that starting point. + let mut tracker = UsageTracker::default(); + + // Setup notification: cumulative cache = 300. + tracker.record("sess-c5", &payload_with_cache(1000, 200, Some(300))); + + tracker.begin_turn("sess-c5"); + tracker.record("sess-c5", &payload_with_cache(1500, 350, Some(700))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "baseline from setup: reliable"); + assert_eq!( + usage.turn_cache_read_tokens, + Some(400), + "cache delta from setup baseline: 700 - 300 = 400" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(700)); + } + + #[test] + fn cache_read_omitted_field_produces_none_cumulative_and_no_turn_delta() { + // A harness that omits accumulatedCachedInputTokens (e.g. goose) must + // produce None cumulative_cache_read_tokens — not Some(0) — and the + // turn delta must also be None even on the second turn. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c6"); + // payload() uses None for accumulated_cached_input_tokens. + tracker.record("sess-c6", &payload(1000, 200, None)); + let t1 = tracker.take().expect("turn 1"); + + assert!( + t1.cumulative_cache_read_tokens.is_none(), + "goose-shaped payload: cumulative must be None, not Some(0)" + ); + assert!( + t1.turn_cache_read_tokens.is_none(), + "first turn always has no turn delta" + ); + + tracker.begin_turn("sess-c6"); + tracker.record("sess-c6", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + + assert!( + t2.cumulative_cache_read_tokens.is_none(), + "continued goose session: cumulative must remain None" + ); + assert!( + t2.turn_cache_read_tokens.is_none(), + "absent field on both sides → no turn delta invented" + ); + assert!( + t2.delta_reliable, + "input/output reliability unaffected by absent cache field" + ); + } + + #[test] + fn cache_read_baseline_absent_then_present_produces_no_delta() { + // If the first turn omits the cache field (baseline stored as None) and + // the second turn reports a value, no delta can be computed — we have no + // baseline to subtract from. The cumulative value should still pass through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload(1000, 200, None)); // no cache field + let _ = tracker.take(); + + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload_with_cache(1500, 300, Some(400))); + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent baseline → no turn delta even when current has a value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(400), + "cumulative from current payload passes through" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn cache_read_baseline_present_then_absent_produces_no_delta() { + // If the first turn reports the cache field but the second omits it + // (harness switched), no delta should be produced and cumulative is None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload_with_cache(1000, 200, Some(300))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload(1500, 300, None)); // no cache field + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent current → no turn delta" + ); + assert!( + usage.cumulative_cache_read_tokens.is_none(), + "absent field: cumulative must be None" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn pool_omitted_cache_field_publishes_no_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent or goose payload that omits the cache field + // must NOT publish cacheReadTokens in the kind:44200 event — neither + // in turn nor cumulative counts. + // + // This is the core acceptance test for Thufir's finding: the old code + // would publish cacheReadTokens: 0 for every harness regardless of + // whether the field was reported. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-none".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, // harness did not report the field + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts must be present (delta reliable)"); + assert!( + turn.cache_read_tokens.is_none(), + "omitted cache field: turn cacheReadTokens must be absent from kind:44200" + ); + + let cumulative = cumulative_counts.expect("cumulative counts always present"); + assert!( + cumulative.cache_read_tokens.is_none(), + "omitted cache field: cumulative cacheReadTokens must be absent from kind:44200" + ); + } + + #[test] + fn pool_reported_cache_field_publishes_nonzero_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent payload with a nonzero cache count must + // publish cacheReadTokens in both turn and cumulative counts. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-some".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: Some(300), + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: Some(600), + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts present"); + assert_eq!( + turn.cache_read_tokens, + Some(300), + "nonzero turn cache: must appear in kind:44200 turn counts" + ); + + let cumulative = cumulative_counts.expect("cumulative counts present"); + assert_eq!( + cumulative.cache_read_tokens, + Some(600), + "nonzero cumulative cache: must appear in kind:44200 cumulative counts" + ); + } } diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index e094e94283..2f96ce4bce 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -609,6 +609,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; /// NIP-34: Status — Draft. pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; +/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository +/// announcements (parameterized replaceable, d=project slug). +/// +/// Members are `a` tags holding `30617::` coordinates, so one +/// project may span repositories owned by different pubkeys. The signer gains no +/// authority over any member: push policy reads the repository's own +/// announcement, never a project. See `docs/nips/NIP-MP.md`. +pub const KIND_PROJECT: u32 = 30621; + // FORK-LOCAL PATCH (adrienlacombe/buzz): fork-reserved kinds, 30900–30999. // // Kept here rather than beside upstream's 30174–30178 cluster on purpose. That @@ -766,6 +775,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_PROJECT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -863,6 +873,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 // FORK-LOCAL PATCH (adrienlacombe/buzz): keeps the fork's reserved block addressable. diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 6c84950a2c..a670a13402 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -789,7 +789,8 @@ pub async fn soft_delete_event( } /// Soft-delete the live row for an addressable coordinate -/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key. +/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not +/// newer than the deletion request. /// /// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any /// parameterized-replaceable kind. The WHERE clause mirrors @@ -797,23 +798,45 @@ pub async fn soft_delete_event( /// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global /// per the spec — `channel_id` is stored for query scoping, not identity). /// +/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09 +/// scopes an `a`-tag deletion to versions at or before that instant, so a +/// delayed or replayed tombstone signed between two versions must not erase the +/// newer replacement. `events.created_at` is immutable per row, so the predicate +/// guarantees a tombstone can never erase a version newer than itself — the UPDATE +/// re-evaluates its WHERE clause after any lock wait, so a replacement that races +/// the deletion and lands with a later `created_at` is always spared. +/// +/// This does NOT guarantee deletion completeness when a same-coordinate +/// replacement races the deletion: the deletion may evaluate its predicate before +/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That +/// outcome is state-identical to the deletion having arrived first (old head +/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes +/// the order of concurrent writes from different signers, and even same-signer +/// ordering is advisory. The return value feeds only a debug log, not a +/// correctness gate. +/// /// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched -/// (already deleted, or never existed). +/// (already deleted, never existed, or strictly newer than the deletion). pub async fn soft_delete_by_coordinate( pool: &PgPool, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { + let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + AND created_at <= $5", ) .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) + .bind(deletion_created_at) .execute(pool) .await?; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 50aac1cbaf..245e49bb2d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1813,16 +1813,27 @@ impl Db { event::soft_delete_event(&self.pool, community_id, event_id).await } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. pub async fn soft_delete_by_coordinate( &self, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await } /// Atomically soft-delete an event and decrement thread reply counters. @@ -5227,6 +5238,75 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn coordinate_delete_spares_head_newer_than_the_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } + + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) + .await + .expect("stale coordinate delete"); + assert!( + !stale_deleted, + "a tombstone older than the live head must delete nothing" + ); + + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" + ); + + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) + .await + .expect("current coordinate delete"); + assert!( + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 11c4f6d35b..f8e0300277 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2795,10 +2795,18 @@ mod sec005_read_gate_tests { ); let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + // Tombstone timestamped after the announcement, per NIP-09's + // at-or-before scoping in `soft_delete_by_coordinate`. let deleted = - f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) - .await - .expect("soft delete 30617"); + f.db.soft_delete_by_coordinate( + f.community, + 30617, + &owner_pk, + &f.repo, + chrono::Utc::now().timestamp() + 60, + ) + .await + .expect("soft delete 30617"); assert!(deleted, "precondition: a live announcement row was deleted"); assert!( diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 201179e6de..735ae331d6 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,7 +28,7 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STARKNET_WALLET_BINDING, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, @@ -302,6 +302,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), + // NIP-MP: a project is repository metadata — grouping repositories needs + // the same scope as announcing them. + KIND_PROJECT => Ok(Scope::ReposWrite), KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -441,6 +444,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_GIT_STATUS_MERGED | KIND_GIT_STATUS_CLOSED | KIND_GIT_STATUS_DRAFT + // NIP-MP: projects are addressed by (pubkey, kind, d_tag). The + // `buzz-channel` tag is a metadata reference, not a routing directive, + // so a project's state is never channel-scoped. + | KIND_PROJECT // Community moderation commands (9040–9044): community-global // direct commands, same model as the NIP-43 9030-series. A stray // `h` tag must never channel-scope them (pinned contract — @@ -1164,6 +1171,284 @@ fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Maximum number of member `a` tags on a kind:30621 project. +/// +/// Counted over raw tags, not distinct coordinates: a duplicate-heavy event +/// naming one coordinate thousands of times would otherwise be bounded only by +/// the relay frame limit (`config.rs`), so the cap must be checked before any +/// set proportional to the tag list is built. +const PROJECT_MEMBER_CAP: usize = 64; + +/// Maximum byte length of a project `name` tag value. +const PROJECT_NAME_MAX_LEN: usize = 256; + +/// Maximum byte length of a project `description` tag value. +const PROJECT_DESCRIPTION_MAX_LEN: usize = 2048; + +/// Maximum byte length of `buzz-channel` and `buzz-visibility` tag values. +/// +/// Both are opaque strings at the relay layer; the bound exists only so an +/// unbounded value cannot ride into storage on a tag ingest does not interpret. +const PROJECT_METADATA_TAG_MAX_LEN: usize = 256; + +/// Metadata tags a project may carry at most once each. +/// +/// Duplicates would make the effective value reader-dependent — one client +/// taking the first, another the last. +const PROJECT_SINGLETON_METADATA_TAGS: [&str; 4] = + ["name", "description", "buzz-channel", "buzz-visibility"]; + +/// The kind segment every project member coordinate must carry: a project groups +/// repository *announcements*, so a coordinate naming any other kind (notably +/// kind:30618 repository state) is malformed. +const PROJECT_MEMBER_KIND_SEGMENT: &str = "30617"; +const _: () = assert!(KIND_GIT_REPO_ANNOUNCEMENT == 30617); + +/// A validation failure from [`validate_project_envelope`] or +/// [`parse_project_member_coordinate`]. +/// +/// Carries the stable NIP-MP rule identifier alongside the human-readable +/// rejection message. The rule ID allows the fixture oracle and any future +/// cross-implementation conformance test to assert *which* rule fired, not just +/// that rejection occurred — an implementation cannot pass a reject fixture by +/// refusing for an unrelated reason. +/// +/// The eight IDs match the `reject_rules` strings in `NIP-MP.fixtures.json` +/// exactly: `d-cardinality`, `d-empty`, `member-cap`, `member-tag-arity`, +/// `member-coordinate-malformed`, `member-duplicate`, `metadata-cardinality`, +/// `metadata-length`. +#[derive(Debug)] +struct ProjectRejection { + /// Stable rule identifier matching the fixture file's `reject_rules` set. + rule: &'static str, + /// Human-readable explanation forwarded to the client's NOTICE/OK message. + message: String, +} + +impl std::fmt::Display for ProjectRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) + } +} + +impl ProjectRejection { + fn new(rule: &'static str, message: impl Into) -> Self { + Self { + rule, + message: message.into(), + } + } +} + +/// Validate the envelope of a kind:30621 NIP-MP project event. +/// +/// Enforces the structural contract in `docs/nips/NIP-MP.md` — exactly one +/// non-empty `d` tag, at most [`PROJECT_MEMBER_CAP`] member `a` tags each +/// holding a canonical `30617::` +/// coordinate with no duplicates, and bounded metadata. +/// +/// Deliberately absent: any membership authorization. The signer may reference +/// any repository coordinate, including another owner's, because membership +/// grants nothing — push policy reads the repository's own kind:30617 +/// (`api/git/policy.rs`) and never a project. Owner-only replacement comes free +/// from NIP-33 addressing. +/// +/// Duplicates are rejected rather than deduped: a relay cannot rewrite tags +/// inside a signed event without invalidating its id and signature, so the +/// choice is reject or force every consumer to apply a first-wins rule. +fn validate_project_envelope(event: &Event) -> Result<(), ProjectRejection> { + let mut d_tags: Vec<&str> = Vec::new(); + let mut members: Vec<&str> = Vec::new(); + let mut name: Option<&str> = None; + let mut description: Option<&str> = None; + let mut buzz_channel: Option<&str> = None; + let mut buzz_visibility: Option<&str> = None; + let mut singleton_counts = [0usize; PROJECT_SINGLETON_METADATA_TAGS.len()]; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(tag_name) = parts.first().map(|s| s.as_str()) else { + continue; + }; + let value = parts.get(1).map(|s| s.as_str()).unwrap_or(""); + match tag_name { + "d" => d_tags.push(value), + "a" => members.push(value), + _ => { + if let Some(i) = PROJECT_SINGLETON_METADATA_TAGS + .iter() + .position(|k| *k == tag_name) + { + singleton_counts[i] += 1; + match tag_name { + "name" => name = Some(value), + "description" => description = Some(value), + "buzz-channel" => buzz_channel = Some(value), + "buzz-visibility" => buzz_visibility = Some(value), + _ => {} + } + } + } + } + } + + // `d-cardinality` / `d-empty`: under NIP-33 a missing `d` is treated as + // empty, which collapses every such project into the `(pubkey, 30621, "")` + // slot where unrelated projects silently overwrite each other. Several `d` + // tags make the address reader-dependent. Length is bounded by the generic + // `D_TAG_MAX_LEN` check the ingest pipeline already applies. + if d_tags.len() != 1 { + return Err(ProjectRejection::new( + "d-cardinality", + format!( + "project event must have exactly one `d` tag (got {})", + d_tags.len() + ), + )); + } + if d_tags[0].is_empty() { + return Err(ProjectRejection::new( + "d-empty", + "project event `d` tag must not be empty", + )); + } + + // `member-cap` before `member-coordinate-malformed` and `member-duplicate`: + // refuse on count before doing per-tag work. + if members.len() > PROJECT_MEMBER_CAP { + return Err(ProjectRejection::new( + "member-cap", + format!( + "project event must have at most {PROJECT_MEMBER_CAP} member `a` tags (got {})", + members.len() + ), + )); + } + // `member-tag-arity`: every member `a` tag has exactly 2 or 3 elements per + // NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth + // element has no defined meaning, and accepting it would let a writer park + // unbounded unvalidated data in a position no consumer reads. + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("a") && !(2..=3).contains(&parts.len()) { + return Err(ProjectRejection::new( + "member-tag-arity", + format!( + "project event member `a` tag must have exactly 2 or 3 elements (got {})", + parts.len() + ), + )); + } + } + let mut seen = std::collections::HashSet::with_capacity(members.len()); + for member in &members { + parse_project_member_coordinate(member)?; + if !seen.insert(*member) { + return Err(ProjectRejection::new( + "member-duplicate", + format!("project event has duplicate member coordinate {member:?}"), + )); + } + } + + for (i, count) in singleton_counts.iter().enumerate() { + if *count > 1 { + return Err(ProjectRejection::new( + "metadata-cardinality", + format!( + "project event must have at most one `{}` tag (got {count})", + PROJECT_SINGLETON_METADATA_TAGS[i] + ), + )); + } + } + if let Some(name) = name { + if name.len() > PROJECT_NAME_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `name` tag too long ({} bytes, max {PROJECT_NAME_MAX_LEN})", + name.len() + ), + )); + } + } + if let Some(description) = description { + if description.len() > PROJECT_DESCRIPTION_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `description` tag too long ({} bytes, max {PROJECT_DESCRIPTION_MAX_LEN})", + description.len() + ), + )); + } + } + if let Some(buzz_channel) = buzz_channel { + if buzz_channel.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-channel` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_channel.len() + ), + )); + } + } + if let Some(buzz_visibility) = buzz_visibility { + if buzz_visibility.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-visibility` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_visibility.len() + ), + )); + } + } + Ok(()) +} + +/// Check that `coordinate` is a canonical repository-announcement address. +/// +/// Splits on the first two colons only, matching how NIP-09 deletion handling +/// parses coordinates (`side_effects.rs`), so a repository whose `d` tag +/// contains a colon stays addressable and a project can never disagree with a +/// deletion about where the `d` value begins. +fn parse_project_member_coordinate(coordinate: &str) -> Result<(), ProjectRejection> { + let malformed = || { + ProjectRejection::new( + "member-coordinate-malformed", + format!( + "project event member `a` tag must be \ + `{PROJECT_MEMBER_KIND_SEGMENT}::` (got {coordinate:?})" + ), + ) + }; + let mut segments = coordinate.splitn(3, ':'); + let (Some(kind), Some(owner), Some(repo_d)) = + (segments.next(), segments.next(), segments.next()) + else { + return Err(malformed()); + }; + if kind != PROJECT_MEMBER_KIND_SEGMENT { + return Err(malformed()); + } + // Lowercase-only: `#a` filter matching is byte-exact, so an uppercase-owner + // head would be invisible to the lowercase-coordinate queries readers issue. + if owner.len() != 64 + || !owner + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(malformed()); + } + if repo_d.is_empty() { + return Err(malformed()); + } + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2169,6 +2454,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PROJECT { + validate_project_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3985,6 +4275,407 @@ mod tests { assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); } + // ─── project (NIP-MP kind:30621) envelope tests ────────────────────────── + + const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn make_project(tags: &[&[&str]]) -> Event { + make_event_with_tags(KIND_PROJECT, "", tags) + } + + fn member_coord(owner: &str, repo_d: &str) -> String { + format!("30617:{owner}:{repo_d}") + } + + #[test] + fn project_envelope_accepts_minimal() { + let ev = make_project(&[&["d", "platform"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_full_cross_owner_membership() { + // The motivating case: one project spanning two owners' repositories. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz-infra"); + let ev = make_project(&[ + &["d", "platform"], + &["name", "Platform"], + &["description", "Relay, desktop, and mobile."], + &["a", &a], + &["a", &b], + &["buzz-channel", "3580ca9b-47b4-4af9-b22a-1068778f26c6"], + &["buzz-visibility", "listed"], + ]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_zero_members() { + // Legal at the protocol layer: the natural state after removing a final + // member. The create UI requires >= 1; the relay must not. + let ev = make_project(&[&["d", "empty"], &["name", "Empty"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_same_repo_d_under_two_owners() { + // The NIP-34 fork case. Identity is the whole coordinate, so these are + // two distinct members, not a duplicate. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz"); + let ev = make_project(&[&["d", "forks"], &["a", &a], &["a", &b]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_repo_d_containing_colon() { + // Coordinates split on the first two colons only, matching NIP-09 + // deletion parsing, so a colon-bearing repository `d` stays addressable. + let coord = member_coord(OWNER_A, "group:repo"); + let ev = make_project(&[&["d", "external"], &["a", &coord]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_cap_boundary() { + let coords: Vec = (0..PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + assert!( + validate_project_envelope(&ev).is_ok(), + "exactly {PROJECT_MEMBER_CAP} members must be accepted" + ); + } + + #[test] + fn project_envelope_ignores_unknown_tags() { + // Forward compatibility: a newer writer's extra metadata must not + // invalidate the event for this relay. + let ev = make_project(&[&["d", "platform"], &["future-field", "whatever"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_missing_d_tag() { + let ev = make_project(&[&["name", "No Identity"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_multiple_d_tags() { + let ev = make_project(&[&["d", "one"], &["d", "two"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_empty_d_tag() { + // An empty `d` collapses every such project into the (pubkey, 30621, "") + // slot, where unrelated projects silently overwrite each other. + let ev = make_project(&[&["d", ""]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_valueless_d_tag() { + // `["d"]` with no value is treated as empty, not as absent. + let ev = make_project(&[&["d"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_member_coordinate() { + let coord = member_coord(OWNER_A, "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("duplicate member coordinate"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_cap_exceeded() { + let coords: Vec = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("at most 64 member"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_heavy_list_on_cap_not_duplicate() { + // The cap counts raw `a` tags, so a duplicate-heavy list is refused on + // count — parse volume is never bounded only by the frame limit. + let coord = member_coord(OWNER_A, "buzz"); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + for _ in 0..=PROJECT_MEMBER_CAP { + tags.push(vec!["a", coord.as_str()]); + } + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("at most 64 member"), + "cap must be evaluated before the duplicate set is built, got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_wrong_kind_prefix() { + // kind:30618 is repository *state*; a project groups announcements. + let coord = format!("30618:{OWNER_A}:buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_not_hex() { + let coord = member_coord(&"z".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_uppercase_hex() { + // `#a` filter matching is byte-exact: an uppercase-owner head would be + // invisible to the lowercase-coordinate queries every reader issues. + let coord = member_coord(&"A".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_wrong_length() { + let coord = member_coord(&"a".repeat(63), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_empty_repo_d() { + let coord = member_coord(OWNER_A, ""); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_missing_segment() { + let coord = format!("30617:{OWNER_A}"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_valueless_member_tag() { + // A one-element `a` tag names no coordinate — caught by the arity check + // (rule 4) before the coordinate parse (rule 5) even runs. + let ev = make_project(&[&["d", "platform"], &["a"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly 2 or 3 elements"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_duplicate_metadata_tags() { + // Every singleton metadata tag is bounded: a duplicate would make the + // effective value reader-dependent. + for tag_name in PROJECT_SINGLETON_METADATA_TAGS { + let ev = make_project(&[&["d", "platform"], &[tag_name, "x"], &[tag_name, "y"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string() + .contains(&format!("at most one `{tag_name}` tag")), + "duplicate `{tag_name}` must be rejected, got: {err}" + ); + } + } + + #[test] + fn project_envelope_rejects_name_too_long() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`name` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_name_at_max_length() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_description_too_long() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`description` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_description_at_max_length() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + /// Membership is an assertion, not a permission grant: the relay must accept + /// a project naming a repository the signer does not own. Cross-owner + /// grouping is the entire point of the kind, and it is safe precisely because + /// membership confers nothing. + #[test] + fn project_envelope_accepts_member_owned_by_another_pubkey() { + let stranger = member_coord(OWNER_B, "not-mine"); + let ev = make_project(&[&["d", "collection"], &["a", &stranger]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PROJECT, &dummy).unwrap(), + Scope::ReposWrite, + "a project is repository metadata — same scope as announcing a repo" + ); + } + + #[test] + fn project_is_global_only() { + // `buzz-channel` is a metadata reference, not a routing directive. + assert!(is_global_only_kind(KIND_PROJECT)); + assert!(!requires_h_channel_scope(KIND_PROJECT)); + } + + #[test] + fn project_is_parameterized_replaceable() { + // Owner-only editing comes free from NIP-33 addressing: replacement is + // keyed by (pubkey, kind, d), so one signer can never overwrite another's + // project. No relay-side permission check exists or is needed. + assert!(is_parameterized_replaceable(KIND_PROJECT)); + } + + /// Drive every case in the shared NIP-MP fixture file against + /// `validate_project_envelope`. All 11 accept cases must pass; all 20 + /// reject cases must return an error whose rule is in the case's allowed + /// `reject_rules` set — an implementation cannot pass by rejecting for an + /// unrelated reason. This is the machine-readable oracle the spec promises. + #[test] + fn project_envelope_validates_all_shared_fixtures() { + #[derive(serde::Deserialize)] + struct FixtureFile { + cases: Vec, + } + #[derive(serde::Deserialize)] + struct Case { + name: String, + expect: String, + #[serde(default)] + reject_rules: Vec, + template: Template, + } + #[derive(serde::Deserialize)] + struct Template { + content: String, + tags: Vec>, + } + + let raw = include_str!("../../../../docs/nips/NIP-MP.fixtures.json"); + let file: FixtureFile = serde_json::from_str(raw).expect("fixture file must parse"); + + for case in &file.cases { + let tag_strs: Vec> = case + .template + .tags + .iter() + .map(|t| t.iter().map(|s| s.as_str()).collect()) + .collect(); + let tag_refs: Vec<&[&str]> = tag_strs.iter().map(|t| t.as_slice()).collect(); + let ev = make_event_with_tags(KIND_PROJECT, &case.template.content, &tag_refs); + let result = validate_project_envelope(&ev); + match case.expect.as_str() { + "accept" => assert!( + result.is_ok(), + "fixture {:?} expected accept, got err: {:?}", + case.name, + result.unwrap_err() + ), + "reject" => { + let rejection = match result { + Err(r) => r, + Ok(()) => { + panic!("fixture {:?} expected reject, but was accepted", case.name) + } + }; + assert!( + case.reject_rules.iter().any(|r| r == rejection.rule), + "fixture {:?} fired rule {:?}, which is not in allowed set {:?}", + case.name, + rejection.rule, + case.reject_rules, + ); + } + other => panic!( + "unknown expect value {:?} in fixture {:?}", + other, case.name + ), + } + } + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 65d04ef0ba..660a55fef3 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2167,9 +2167,18 @@ async fn handle_a_tag_deletion( }; // Safe cast: NIP-33 kinds are 30000–39999, well within i32. let kind_i32 = k as i32; + // NIP-09 scopes an a-tag deletion to versions at or before the + // deletion's own created_at, so a stale/replayed tombstone can never + // erase a newer replacement head. let deleted = state .db - .soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag) + .soft_delete_by_coordinate( + tenant.community(), + kind_i32, + &pubkey_bytes, + d_tag, + event.created_at.as_secs() as i64, + ) .await .map_err(|e| { anyhow::anyhow!( diff --git a/crates/buzz-test-client/tests/e2e_project.rs b/crates/buzz-test-client/tests/e2e_project.rs new file mode 100644 index 0000000000..c0a05e4674 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_project.rs @@ -0,0 +1,491 @@ +//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP). +//! +//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation. +//! These tests cover the three behaviors that only exist once an event reaches +//! storage, plus proof that the envelope validator is actually wired into the +//! live write path: +//! - a valid cross-owner project round-trips through its NIP-33 coordinate; +//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author, +//! and two authors sharing a `d` hold two independent projects (this is what +//! makes owner-only editing free rather than a relay permission check); +//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every +//! referenced kind:30617 announcement untouched, because membership is an +//! assertion about repositories and never authority over them; +//! - malformed envelopes are refused by the relay, not merely by the validator. +//! +//! See `docs/nips/NIP-MP.md` for the normative contract. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const PROJECT_KIND: u16 = 30621; +const REPO_ANNOUNCEMENT_KIND: u16 = 30617; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-project-{name}-{}", uuid::Uuid::new_v4()) +} + +/// A short unique suffix so concurrent runs never collide on a `d` tag. +fn unique(prefix: &str) -> String { + format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8]) +} + +fn member_coord(owner: &Keys, repo_d: &str) -> String { + format!( + "{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}", + owner.public_key().to_hex() + ) +} + +/// Build a project event. `members` are canonical `30617::` +/// coordinates; `created_at` defaults to now when `None`. +fn project_event( + keys: &Keys, + d_tag: &str, + name: &str, + members: &[String], + created_at: Option, +) -> nostr::Event { + let mut tags = vec![ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["name", name]).unwrap(), + ]; + tags.extend( + members + .iter() + .map(|m| Tag::parse(["a", m.as_str()]).unwrap()), + ); + let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +/// Announce a repository so a project has a real coordinate to reference. +fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "") + .tags(vec![ + Tag::parse(["d", repo_d]).unwrap(), + Tag::parse(["name", repo_d]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() +} + +/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the +/// relay takes the coordinate-delete path rather than the event-id path. +/// `created_at` defaults to now when `None`. +fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option) -> nostr::Event { + let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex()); + let builder = + EventBuilder::new(Kind::Custom(5), "") + .tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(kind)) + .author(author.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +/// Subscribe with `filter` and drain to EOSE. +async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec { + let sid = sub_id(name); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect events") +} + +#[tokio::test] +#[ignore] +async fn test_project_publish_and_query_returns_cross_owner_members() { + let url = relay_url(); + let owner = Keys::generate(); + let other = Keys::generate(); + let d_tag = unique("project"); + + let members = vec![ + member_coord(&owner, "buzz"), + member_coord(&other, "buzz-infra"), + ]; + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let event = project_event(&owner, &d_tag, "Platform", &members, None); + let ok = client.send_event(event).await.expect("send project"); + assert!(ok.accepted, "relay rejected project event: {}", ok.message); + + let events = query( + &mut client, + "query", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!(events.len(), 1, "expected exactly one project event"); + let stored: Vec<&str> = events[0] + .tags + .iter() + .filter_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str()) + }) + .collect(); + assert_eq!( + stored, members, + "both members must survive the round trip, including the one owned by another pubkey" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() { + let url = relay_url(); + let owner = Keys::generate(); + let d_tag = unique("project-replace"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100)); + let ok = client.send_event(first).await.expect("send old"); + assert!(ok.accepted, "relay rejected old project: {}", ok.message); + + let members = vec![member_coord(&owner, "buzz")]; + let second = project_event(&owner, &d_tag, "New", &members, Some(now)); + let ok = client.send_event(second).await.expect("send new"); + assert!(ok.accepted, "relay rejected new project: {}", ok.message); + + let events = query( + &mut client, + "replace", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!( + events.len(), + 1, + "NIP-33: only the newest head should remain" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, "New", "the newer head must win"); + + client.disconnect().await.expect("disconnect"); +} + +/// Owner-only editing is a property of the addressable model, not a relay +/// permission check: two authors publishing the same `d` occupy two coordinates, +/// so neither can overwrite the other. This is the test that would fail if the +/// kind were ever classified as plain-replaceable or keyed on `d` alone. +#[tokio::test] +#[ignore] +async fn test_project_same_d_under_two_authors_are_independent() { + let url = relay_url(); + let alice = Keys::generate(); + let bob = Keys::generate(); + let d_tag = unique("project-shared-d"); + + let mut alice_client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let ok = alice_client + .send_event(project_event(&alice, &d_tag, "Alice", &[], None)) + .await + .expect("send alice"); + assert!( + ok.accepted, + "relay rejected alice's project: {}", + ok.message + ); + + let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect"); + let ok = bob_client + .send_event(project_event(&bob, &d_tag, "Bob", &[], None)) + .await + .expect("send bob"); + assert!(ok.accepted, "relay rejected bob's project: {}", ok.message); + + for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] { + let events = query( + &mut alice_client, + label, + addressable_filter(PROJECT_KIND, keys, &d_tag), + ) + .await; + assert_eq!( + events.len(), + 1, + "{label} should still hold their own project at the shared `d`" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, expected_name, "{label}'s project was overwritten"); + } + + alice_client.disconnect().await.expect("disconnect"); + bob_client.disconnect().await.expect("disconnect"); +} + +/// Deleting a project must delete only the grouping. A project is metadata about +/// repositories; if a tombstone at the project coordinate cascaded to the +/// referenced kind:30617s, adding a repo to someone's project would become a way +/// to destroy it. +#[tokio::test] +#[ignore] +async fn test_project_tombstone_deletes_coordinate_and_spares_members() { + let url = relay_url(); + let owner = Keys::generate(); + let repo_d = unique("repo"); + let project_d = unique("project-tombstone"); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(repo_announcement(&owner, &repo_d)) + .await + .expect("send announcement"); + assert!(ok.accepted, "relay rejected announcement: {}", ok.message); + + let members = vec![member_coord(&owner, &repo_d)]; + let ok = client + .send_event(project_event(&owner, &project_d, "Doomed", &members, None)) + .await + .expect("send project"); + assert!(ok.accepted, "relay rejected project: {}", ok.message); + + let before = query( + &mut client, + "tombstone-pre", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert_eq!(before.len(), 1, "project should be live before deletion"); + + let ok = client + .send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None)) + .await + .expect("send tombstone"); + assert!(ok.accepted, "relay rejected tombstone: {}", ok.message); + + let after = query( + &mut client, + "tombstone-post", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert!( + after.is_empty(), + "tombstone should remove the project coordinate, got {} event(s)", + after.len() + ); + + let repo = query( + &mut client, + "member-after", + addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d), + ) + .await; + assert_eq!( + repo.len(), + 1, + "deleting a project must not touch the repositories it referenced" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own +/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or +/// replayed by a third party — must therefore retire V1 only and leave the newer +/// V2 head live. Before the timestamp predicate landed in +/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and +/// this sequence silently destroyed V2. +#[tokio::test] +#[ignore] +async fn test_stale_tombstone_between_versions_leaves_newer_project_live() { + let url = relay_url(); + let owner = Keys::generate(); + let project_d = unique("project-stale-tombstone"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(project_event( + &owner, + &project_d, + "V1", + &[], + Some(now - 100), + )) + .await + .expect("send v1"); + assert!(ok.accepted, "relay rejected V1: {}", ok.message); + + let ok = client + .send_event(project_event(&owner, &project_d, "V2", &[], Some(now))) + .await + .expect("send v2"); + assert!(ok.accepted, "relay rejected V2: {}", ok.message); + + // Timestamped strictly between V1 and V2: valid for V1, stale for V2. + let ok = client + .send_event(coordinate_delete( + &owner, + PROJECT_KIND, + &project_d, + Some(now - 50), + )) + .await + .expect("send stale tombstone"); + assert!( + ok.accepted, + "a well-formed tombstone is still an acceptable event: {}", + ok.message + ); + + let after = query( + &mut client, + "stale-tombstone", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + + assert_eq!( + after.len(), + 1, + "a tombstone older than the live head must not delete it, got {} event(s)", + after.len() + ); + let name = after[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("surviving head must carry its name tag"); + assert_eq!(name, "V2", "the surviving head must be the newer version"); + + client.disconnect().await.expect("disconnect"); +} + +/// Proves the envelope validator is reachable from the live write path — a unit +/// test of `validate_project_envelope` cannot show that ingest calls it. +#[tokio::test] +#[ignore] +async fn test_project_malformed_envelope_rejected_by_relay() { + let url = relay_url(); + let owner = Keys::generate(); + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let duplicate = member_coord(&owner, "buzz"); + // Each case pairs a malformed event with the substring its rejection must + // carry, so a refusal for an unrelated reason cannot satisfy the assertion. + let cases: Vec<(&str, nostr::Event, &str)> = vec![ + ( + "duplicate member coordinate", + project_event( + &owner, + &unique("project-dup"), + "Dup", + &[duplicate.clone(), duplicate], + None, + ), + "duplicate member coordinate", + ), + ( + "member coordinate naming the wrong kind", + project_event( + &owner, + &unique("project-badkind"), + "Bad kind", + &[format!("30618:{}:buzz", owner.public_key().to_hex())], + None, + ), + "member `a` tag must be", + ), + ( + "member coordinate with an uppercase-hex owner", + project_event( + &owner, + &unique("project-upper"), + "Uppercase", + &[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))], + None, + ), + "member `a` tag must be", + ), + ]; + + for (label, event, expected) in cases { + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay must reject a project with a {label}, got OK: {}", + ok.message + ); + assert!( + ok.message.contains(expected), + "rejection for {label} must name the rule that fired, got: {}", + ok.message + ); + } + + client.disconnect().await.expect("disconnect"); +} diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..e8145f5468 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c58a587c15..9d4c709b45 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 39aaf0dead..b80684f955 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/assets/card_template.png b/desktop/src-tauri/assets/card_template.png new file mode 100644 index 0000000000..2225d1d442 Binary files /dev/null and b/desktop/src-tauri/assets/card_template.png differ diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index d3b1a9499d..7bc94da25d 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -8,7 +8,8 @@ use crate::commands::export_util::save_bytes_with_dialog; use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; use crate::commands::{ personas::{ - decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, + parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + PNG_MAGIC, }, team_snapshot::{ decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, @@ -505,10 +506,12 @@ pub async fn fetch_snapshot_bytes( // 4. Bytes must parse as the snapshot type selected by the filename. // Team parsing rejects retired flat JSON and persona-pack ZIP inputs - // before anything reaches the frontend. + // before anything reaches the frontend. Agent kinds accept both plain + // manifests and structurally valid locked (encrypted) card envelopes — + // transit validation never decrypts; unlock happens at import time. match kind { SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => { - decode_snapshot_from_bytes(&bytes) + parse_snapshot_payload_from_bytes(&bytes) .map_err(|e| format!("invalid agent snapshot: {e}"))?; } SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 998bc6e7d2..528ca38767 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -364,8 +364,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C } // This is restoration of a previously inference-ready serving node. Keep // the enabled checkpoint armed while restoring so a transient startup - // failure does not silently turn Share Compute off. New starts remain - // disarmed in `mesh_start_node` until their first inference probe passes. + // failure does not silently turn Share Compute off. let request = mesh_llm::StartMeshNodeRequest { mode: mesh_llm::MeshNodeMode::Serve, model_id: Some(config.model_id.clone()), @@ -378,20 +377,26 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; - if let Err(error) = wait_for_mesh_inference(&config.model_id).await { - let cleanup = started.stop().await; - if let Err(cleanup_error) = cleanup { - eprintln!( - "buzz-mesh: restored node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" - ); - } - return Err(format!("failed to restore Share Compute: {error}")); - } + // Install the restored runtime immediately: it is tracked by AppState from + // here on, so it can never be orphaned. Restoring a previously + // inference-ready node still has to load ~tens of GB of weights and may + // download package layers after the ports bind, and the readiness probe + // itself serializes behind any first inference. None of that is a failed + // restore — stopping the node and reporting failure (the old behaviour) + // tore down a node that was simply still warming up. The checkpoint stays + // armed (`enabled`), so a genuinely broken restore is retried next launch + // rather than silently turning Share Compute off. *runtime = Some(started); config.enabled = true; config.start_on_next_launch = false; save_mesh_sharing_config(app, &config)?; drop(runtime); + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + eprintln!( + "buzz-mesh: restored node is not inference-ready yet ({error}); \ + leaving it to warm up without tearing it down" + ); + } mesh_llm::publish_current_status_once(app, "restore").await; Ok(()) } @@ -467,9 +472,11 @@ pub async fn mesh_start_node( } if let Some(config) = sharing_config.as_ref() { - // Do not arm launch restoration until the exact inference path used by - // agents succeeds. Mesh may bind its ports after primary weights load - // while package layers are still downloading. + // Persist a DISARMED checkpoint to cover the window of the potentially + // long `start()` below: if Buzz exits before the runtime is installed + // and tracked, the next launch stays stopped rather than trying to + // restore a node that never came up. The enabled config is armed right + // after install succeeds. save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; } @@ -496,25 +503,28 @@ pub async fn mesh_start_node( )); } }; - if let Some(config) = sharing_config.as_ref() { - if let Err(error) = wait_for_mesh_inference(&config.model_id).await { - let cleanup = started.stop().await; - if let Err(cleanup_error) = &cleanup { - eprintln!( - "buzz-mesh: started node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" - ); - } - drop(runtime); - app.request_restart(); - return Err(format!( - "mesh node started but inference never became ready: {error}; Buzz is restarting to guarantee cleanup" - )); - } - } + // Install (track) the runtime BEFORE probing readiness so it can never be + // orphaned. A readiness timeout is not death: mesh binds its ports before + // weights finish loading / layers finish downloading, and serializes all + // ingress HTTP (this probe included) behind any in-flight turn — a cold + // start can take minutes. The old code stopped the node and restarted the + // app on that timeout, turning startup latency into a restart loop. *runtime = Some(started); drop(runtime); if let Some(config) = sharing_config.as_ref() { + // Installed + tracked == Share Compute is on, so persist the enabled + // config now (mirroring restore), not gated on the probe. Gating it + // meant a slow first start served fine but came back OFF next launch. + // Safe: neither the watchdog (evicts only a closed port) nor restore + // (leaves a warming node alone) can loop a slow-but-alive node, and an + // unstartable config fails earlier in `start()`. Probe is informational. save_mesh_sharing_config(&app, config)?; + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + eprintln!( + "buzz-mesh: node started but inference is not ready yet ({error}); \ + leaving it to warm up (Share Compute stays armed for next launch)" + ); + } } mesh_llm::publish_current_status_once(&app, "start").await; Ok(status) diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs new file mode 100644 index 0000000000..c516db1736 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -0,0 +1,972 @@ +//! `mint_agent_card` / `save_agent_card` Tauri commands — Agent Trading Cards. +//! +//! Mints a collectible trading-card PNG for an agent via one OpenAI Responses +//! API call (designer model + native `image_generation` tool), then embeds the +//! agent's `buzz_agent_snapshot` manifest through the existing snapshot +//! encoder so the card IS an importable `.agent.png`. +//! +//! Boundary rules (agreed with Wren, buzz-agent-trading-cards thread): +//! - Snapshot construction/injection reuses `agent_snapshot.rs` — cards +//! inherit manifest-v1 behavior, exclusions, and size checks. No card-only +//! wire format exists. +//! - Memory inclusion is opt-in and shares the export flow's semantics: the +//! same three levels (`none`/`core`/`everything`), the same owner-gated +//! `get_agent_memory` fetch, and a memory source DERIVED from the resolved +//! instance (never caller-supplied), so cross-agent memory pairing is +//! structurally impossible. The default is `none`; the encoder still +//! rejects `none` + entries. +//! - The 10 MiB `.agent.png` ceiling is enforced on the FINAL bytes (after +//! resize + chunk injection) via `validate_snapshot_encode_size`. +//! - Round-trip verification decodes the final bytes and compares the logical +//! manifest before anything is returned to the frontend. +//! - The API key is resolved through the same env layering the agent runtime +//! uses (global config < persona < agent record) and never leaves Rust. +//! It is never logged. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use super::super::export_util::save_bytes_with_dialog; +use super::snapshot::{ + memory_entries_from_listing, parse_memory_level, resolve_from_lists, + validate_snapshot_encode_size, +}; +use crate::{ + app_state::AppState, + commands::engrams::get_agent_memory, + managed_agents::{ + agent_snapshot::{ + build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png, + extract_chunk_payload_png, MemoryLevel, + }, + agent_snapshot_envelope::{ + decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, + }, + load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, + save_global_agent_config, validate_global_config, + }, +}; + +/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation +/// input only: it never participates in the snapshot manifest, PNG chunk, +/// import decoder, or attachment validation. Embedded at compile time for +/// deterministic packaging (see `card_template_decodes` test). +const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png"); + +/// Designer model driving copy + art direction. +const DESIGNER_MODEL: &str = "gpt-5.6-sol"; +/// Image model invoked natively via the Responses `image_generation` tool. +const IMAGE_MODEL: &str = "gpt-image-2"; +/// Final card width in pixels (2:3 portrait → 1500x2250). +const CARD_WIDTH: u32 = 1500; +/// Longest edge for the real avatar inlined into an unlocked card's manifest. +/// Kind:0 pictures render small; 512px keeps the doubly-base64-encoded +/// manifest chunk modest next to the 1500-wide card body. +const MANIFEST_AVATAR_MAX_DIM: u32 = 512; +/// Upper bound for a fetched avatar (pre-resize input to the model). +const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024; +/// One mint is a single long API call (~2–3 minutes observed). +const MINT_TIMEOUT_SECS: u64 = 600; + +/// Error prefix the frontend matches to route the user to provider settings +/// instead of showing a raw failure. +pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:"; + +/// Wire shape returned by `mint_agent_card`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MintedCard { + /// Final `.agent.png` bytes (chunk-injected, round-trip verified), + /// base64-encoded for the IPC boundary. + pub card_png_base64: String, + /// Suggested filename, e.g. `eva.agent.png`. + pub file_name: String, + /// Designer commentary emitted alongside the image (may be empty). + pub designer_notes: String, + /// True when the embedded snapshot is NIP-44-encrypted to the + /// (owner, agent) pair — only their nsecs can import this card. + pub locked: bool, + /// How much memory is embedded in the card's snapshot ("none"/"core"/ + /// "everything"). The viewer's import disclosure depends on this. + pub memory_level: MemoryLevel, +} + +// ── Card archive ────────────────────────────────────────────────────────────── + +/// Sidecar metadata for one archived card PNG. Stored as `.json` next +/// to `.agent.png` in the cards dir — two plain files per mint, no +/// shared index to corrupt. Listing scans sidecars; a card whose PNG is +/// missing is skipped rather than failing the whole list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedCardMeta { + /// Unique on-disk PNG file name within the cards dir. + pub stored_file_name: String, + /// Suggested save-as name, e.g. `eva.agent.png`. + pub file_name: String, + /// The id the card was minted for (instance pubkey or definition slug). + pub agent_id: String, + pub agent_name: String, + pub designer_notes: String, + pub locked: bool, + /// Memory embedded in this card's snapshot. Defaults to `None` when the + /// sidecar predates the field — every pre-field mint was minted with + /// `MemoryLevel::None` (it was structural), so the default is honest. + #[serde(default)] + pub memory_level: MemoryLevel, + /// ISO-8601 mint timestamp. + pub minted_at: String, + /// Small JPEG preview for gallery grids, base64. Populated by + /// `list_agent_cards` from the sidecar thumb file — never stored in the + /// JSON sidecar itself. + #[serde(default, skip_deserializing)] + pub thumb_jpeg_base64: Option, +} + +fn cards_dir(app: &AppHandle) -> Result { + let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards"); + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?; + Ok(dir) +} + +/// Persist a freshly minted card to the archive. Failures are surfaced to the +/// caller (which logs and continues) — an archive write must never fail a +/// mint the user already paid for. +fn archive_minted_card( + app: &AppHandle, + agent_id: &str, + agent_name: &str, + card: &MintedCard, + bytes: &[u8], +) -> Result { + let dir = cards_dir(app)?; + let stem = format!( + "{}-{}", + crate::util::slugify(agent_name, "agent", 50), + uuid::Uuid::new_v4() + ); + let stored_file_name = format!("{stem}.agent.png"); + let meta = ArchivedCardMeta { + stored_file_name: stored_file_name.clone(), + file_name: card.file_name.clone(), + agent_id: agent_id.to_string(), + agent_name: agent_name.to_string(), + designer_notes: card.designer_notes.clone(), + locked: card.locked, + memory_level: card.memory_level, + minted_at: crate::util::now_iso(), + thumb_jpeg_base64: None, + }; + // PNG first, sidecar second: a crash between the two leaves an orphaned + // PNG (invisible to the list), never a sidecar pointing at nothing. + std::fs::write(dir.join(&stored_file_name), bytes) + .map_err(|e| format!("failed to write archived card: {e}"))?; + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("failed to serialize card metadata: {e}"))?; + std::fs::write(dir.join(format!("{stem}.json")), meta_json) + .map_err(|e| format!("failed to write card metadata: {e}"))?; + // Thumb last and best-effort: the gallery grid falls back to lazy + // full-card loading for a card whose thumb is missing. + if let Ok(thumb) = encode_card_thumb(bytes) { + let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb); + } + Ok(meta) +} + +/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card +/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to +/// draw a grid tile is waste. +fn encode_card_thumb(bytes: &[u8]) -> Result, String> { + const THUMB_WIDTH: u32 = 300; + let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?; + let scale = THUMB_WIDTH as f64 / img.width() as f64; + let thumb = img.resize( + THUMB_WIDTH, + (img.height() as f64 * scale).round().max(1.0) as u32, + image::imageops::FilterType::Triangle, + ); + let mut out = Vec::new(); + // JPEG has no alpha; cards are opaque, so flatten unconditionally. + let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8()); + rgb.write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .map_err(|e| format!("thumb encode: {e}"))?; + Ok(out) +} + +/// Reject any archive file name that could escape the cards dir or name a +/// non-archive file. Archive names are generated by `archive_minted_card` +/// (slug + UUID), so a strict shape check loses nothing legitimate. +fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> { + let valid = stored_file_name.ends_with(".agent.png") + && !stored_file_name.contains(['/', '\\']) + && !stored_file_name.contains(".."); + if !valid { + return Err("Invalid archived card file name.".to_string()); + } + Ok(()) +} + +/// List all archived cards, newest first. +#[tauri::command] +pub fn list_agent_cards(app: AppHandle) -> Result, String> { + let dir = cards_dir(&app)?; + let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?; + let mut cards = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(meta) = serde_json::from_str::(&content) else { + // A malformed sidecar hides one card, never the archive. + eprintln!( + "buzz-desktop: card-archive: skipping malformed sidecar {}", + path.display() + ); + continue; + }; + let mut meta = meta; + if validate_archive_file_name(&meta.stored_file_name).is_ok() + && dir.join(&meta.stored_file_name).is_file() + { + // Attach the pre-rendered grid thumb when present (best-effort). + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg"))) + .ok() + .map(|b| STANDARD.encode(&b)); + } + cards.push(meta); + } + } + // ISO-8601 sorts lexicographically; newest first. + cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at)); + Ok(cards) +} + +/// Load one archived card's PNG bytes as base64, keyed by its stored file +/// name (as returned by `list_agent_cards`). +#[tauri::command] +pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result { + validate_archive_file_name(&stored_file_name)?; + let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name)) + .map_err(|e| format!("failed to read archived card: {e}"))?; + Ok(STANDARD.encode(&bytes)) +} + +// ── Key resolution ──────────────────────────────────────────────────────────── + +/// Pure layering: global env < persona env < agent record env, then the +/// process environment as a development fallback. Returns the first +/// non-empty value for `key`. +pub(crate) fn resolve_env_from_layers( + key: &str, + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> Option { + for layer in [record_env, persona_env, global_env] { + if let Some(v) = layer.get(key) { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + process_value.filter(|k| !k.trim().is_empty()) +} + +/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env +/// layering as the key) overrides the default host, supporting endpoints and +/// proxies that speak the OpenAI Responses shape with Bearer auth. Azure +/// OpenAI is NOT covered by this override alone — it uses its own URL scheme +/// and `api-key` auth header, which would need a real driver. +pub(crate) fn responses_url(base_url: Option) -> String { + let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + format!("{}/responses", base.trim_end_matches('/')) +} + +// ── Prompt construction ─────────────────────────────────────────────────────── + +/// Build the designer instructions. Pure so tests can pin the contract: +/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND +/// card text) take primacy over those style defaults, but never over the +/// fixed contract (frame identity, geometry, text fidelity). +pub(crate) fn build_card_instructions( + agent_name: &str, + persona_notes: &str, + style_notes: &str, +) -> String { + let owner_directions = if style_notes.trim().is_empty() { + String::new() + } else { + format!( + "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \ + below wherever they conflict (they cannot change the frame, layout, or \ + text-fidelity requirements). The owner may direct the art, the card text \ + (type line, ability, flavor), or both:\n{style_notes}\n" + ) + }; + format!( + r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}". + +Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs. + +Persona notes for the card copy: +{persona_notes} +{owner_directions} +First, write professional trading-card copy at Magic: The Gathering editorial quality: +- a type line (e.g. "Legendary Agent — Team Lead"), +- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff), +- ONE italic flavor-text line, evocative and short, the kind that gets quoted. +Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open. +Keep total text-box copy under 220 characters so it renders cleanly. + +Then generate the finished card with the image tool, exactly 1024x1536 portrait: +- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail. +- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict. +- Name banner: "{agent_name}" plus the type line beneath it in smaller type. +- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing. +- Top-right hex badge: one small emblem of your choice, no text. +Render all text with perfect fidelity."# + ) +} + +/// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to +/// `max_dim` on the longest edge so request payloads stay small. +fn image_data_url(bytes: &[u8], max_dim: u32) -> Result { + Ok(format!( + "data:image/png;base64,{}", + STANDARD.encode(png_bytes_resized(bytes, max_dim)?) + )) +} + +/// Re-encode an image as PNG, downscaling so neither side exceeds `max_dim`. +fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result, String> { + let img = image::load_from_memory(bytes).map_err(|e| format!("Failed to decode image: {e}"))?; + let img = if img.width().max(img.height()) > max_dim { + img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode image: {e}"))?; + Ok(png) +} + +// ── Response parsing ────────────────────────────────────────────────────────── + +/// Extract the generated image (base64) and any designer text from a +/// Responses API payload. Pure for testability. +pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> { + let output = resp + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| "Responses payload has no output array".to_string())?; + + let mut image_b64 = None; + let mut notes = Vec::new(); + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("image_generation_call") => { + if let Some(result) = item.get("result").and_then(|r| r.as_str()) { + image_b64 = Some(result.to_string()); + } + } + Some("message") => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for c in content { + if c.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = c.get("text").and_then(|t| t.as_str()) { + notes.push(text.to_string()); + } + } + } + } + } + _ => {} + } + } + + let image_b64 = image_b64.ok_or_else(|| { + let types: Vec<&str> = output + .iter() + .filter_map(|i| i.get("type").and_then(|t| t.as_str())) + .collect(); + format!("No image in Responses output (item types: {types:?})") + })?; + Ok((image_b64, notes.join("\n"))) +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card +/// minting — a narrow seam with deliberately different semantics from the +/// general `set_global_agent_config`: +/// +/// - **No agent restarts.** The general command stops/restarts every running +/// local agent whose effective env changes, because agent env is baked at +/// spawn time. The mint command re-reads the config from disk on every +/// mint, so minting needs no restart — and a card setup must never disrupt +/// running agents as a side effect. Agents pick the key up naturally on +/// their next (re)start. +/// - **Read-modify-write of the latest on-disk config.** The config is +/// re-read immediately before the single-key insert + write (under the +/// managed-agents store lock, which serializes it against the other card +/// and agent-store commands), so a settings save that landed after this +/// dialog opened is not clobbered with a stale dialog-open snapshot. +/// (The general settings editor performs its own whole-config write; as +/// today, the last writer wins between the two surfaces.) +/// +/// Standard global-config validation still applies (POSIX key shape, +/// reserved-key reject, size caps) — this is not a validation bypass. +#[tauri::command] +pub fn card_mint_save_openai_key( + key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let key = key.trim().to_string(); + if key.is_empty() { + return Err("API key cannot be empty.".to_string()); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let mut config = load_global_agent_config(&app)?; + config.env_vars.insert("OPENAI_API_KEY".to_string(), key); + validate_global_config(&config)?; + save_global_agent_config(&app, &config) +} + +/// Report whether an OpenAI key would resolve for a card mint of agent `id`, +/// using exactly the same env layering as `mint_agent_card`. Lets the mint +/// dialog offer inline key setup BEFORE the user commits to a mint, instead +/// of failing after the fact. Never returns the key itself. +#[tauri::command] +pub fn card_mint_key_status( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + Ok(resolve_env_from_layers( + "OPENAI_API_KEY", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .is_some()) +} + +/// Mint a trading card for the agent identified by `id` (instance pubkey, +/// instance slug, or definition slug — same resolution as snapshot export). +/// +/// When `lock` is true the embedded manifest is NIP-44-encrypted to the +/// (owner, agent) pair per the locked-envelope contract — this requires a +/// linked agent instance (the second key endpoint); bare definitions cannot +/// be locked. +/// +/// When `memory_level` is `"core"` or `"everything"`, the owner's decrypted +/// memory for the agent is embedded in the manifest — same levels and fetch +/// as snapshot export. The memory source is always the resolved instance +/// itself (derived, never caller-supplied), so it requires a linked instance; +/// bare definitions can only mint `"none"` (the default). +/// +/// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes. +/// Reroll = call again; the command holds no session state. +#[tauri::command] +pub async fn mint_agent_card( + id: String, + style_notes: Option, + lock: Option, + memory_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let lock = lock.unwrap_or(false); + let memory_level = parse_memory_level(memory_level.as_deref().unwrap_or(""))?; + // ── Resolve the record + API key under lock ────────────────────────────── + let (mut record, is_definition, api_key, base_url) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, is_definition) = + resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + let api_key = resolve_env_from_layers( + "OPENAI_API_KEY", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .ok_or_else(|| { + format!( + "{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \ + environment variables or global agent settings to mint cards." + ) + })?; + let base_url = resolve_env_from_layers( + "OPENAI_BASE_URL", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_BASE_URL").ok(), + ); + + (record, is_definition, api_key, base_url) + }; + + // ── Locking needs its two exact key endpoints up front, BEFORE the + // API spend: the owner identity secret and the agent instance pubkey. + let lock_keys = if lock { + if is_definition { + return Err( + "Locked cards need a linked agent instance — this persona has never been \ + started, so there is no agent key to lock to." + .to_string(), + ); + } + let owner_keys = state.signing_keys()?; + // Same canonical check the envelope decoder enforces (incl. curve + // validation) — a non-point record pubkey must fail BEFORE the API + // spend, not at post-mint encryption. + let agent_pubkey = crate::managed_agents::agent_snapshot_envelope::parse_canonical_pubkey( + "agentPubkey", + &record.pubkey, + ) + .map_err(|_| { + "Agent record has an invalid pubkey (not a canonical x-only key).".to_string() + })?; + if owner_keys.public_key() == agent_pubkey { + return Err("Cannot lock a card to itself: owner and agent keys match.".to_string()); + } + Some((owner_keys, agent_pubkey)) + } else { + None + }; + + // ── Memory needs a keyed instance, resolved up front BEFORE the API + // spend — the memory source is always the resolved instance itself + // (derived, never caller-supplied), so cross-agent pairing cannot be + // expressed. A failed fetch fails the mint here, not after payment. + let memory_entries = if memory_level == MemoryLevel::None { + Vec::new() + } else { + if is_definition { + return Err( + "Cards with memory need a linked agent instance — this persona has never \ + been started, so there is no agent memory to include." + .to_string(), + ); + } + let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?; + memory_entries_from_listing(listing, memory_level) + }; + + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); + + // ── Prefer the agent's own kind:0 profile picture ──────────────────────── + // The record's `avatar_url` is a stale presentation snapshot: with + // agent-managed profiles the agent updates its own kind:0 `picture` and + // desktop reconciliation is disabled (`agent_settings.rs`), so the relay + // profile — not the local record — is the live source of truth for how the + // agent looks. Definitions have no keypair and thus no kind:0; they keep + // the record's avatar. A relay error fails the mint here, BEFORE the API + // spend (same fail-early rule as the key/memory guards above) — minting + // with the wrong face wastes the spend it was supposed to protect. + if !is_definition { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ); + let profile = crate::relay::query_agent_profile(&state, &relay_url, &record.pubkey) + .await + .map_err(|e| format!("Could not read the agent's profile for its avatar: {e}"))?; + record.avatar_url = preferred_avatar_url( + profile.and_then(|info| info.picture), + record.avatar_url.take(), + ); + } + + // ── Resolve avatar bytes (data URL, else fetch) ────────────────────────── + let avatar_bytes = match record.avatar_url.as_deref() { + Some(url) if url.starts_with("data:") => decode_avatar_data_url(url) + .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, + Some(url) if url.starts_with("http://") || url.starts_with("https://") => { + // Relay-hosted avatars (kind:0 pictures under the relay's /media/) + // may require Blossom get-auth (`require_media_get_auth`). Mint the + // header ONLY for same-origin URLs so the token never leaves the + // relay (same contract as `media_download.rs`). + let relay_base = crate::relay::relay_api_base_url_with_override(&state); + let auth = is_same_origin(url, &relay_base) + .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) + .flatten(); + fetch_avatar(url, auth.as_deref()).await? + } + _ => { + return Err( + "Agent has no avatar image. Set an avatar before minting a card.".to_string(), + ) + } + }; + + // ── Build the manifest now (with any requested memory) so a broken agent + // fails before we spend minutes on the API call. ─────────────────────── + let manifest_avatar = manifest_avatar_bytes( + lock_keys.is_some(), + &avatar_bytes, + record.avatar_url.as_deref(), + )?; + let snapshot = build_snapshot( + &record, + memory_level, + memory_entries, + manifest_avatar.as_deref(), + ); + + // ── One Responses API call ─────────────────────────────────────────────── + // For locked mints, prove the manifest (including any embedded memory) + // fits the NIP-44 plaintext cap BEFORE spending minutes on the API call + // (same fail-early rule as the memory guard above). + if lock_keys.is_some() { + let json_len = + crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot)?.len(); + if json_len > buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX { + let hint = if memory_level == MemoryLevel::None { + "Reduce the avatar size or mint an unlocked card." + } else { + "Include less memory, reduce the avatar size, or mint an unlocked card." + }; + return Err(format!( + "Agent manifest is too large to lock ({json_len} bytes; the encrypted \ + format caps at {}). {hint}", + buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX + )); + } + } + let instructions = build_card_instructions( + &display_name, + snapshot.definition.system_prompt.as_deref().unwrap_or(""), + style_notes.as_deref().unwrap_or(""), + ); + let body = serde_json::json!({ + "model": DESIGNER_MODEL, + "reasoning": {"effort": "high"}, + "instructions": "You are a senior TCG card designer and MTG rules editor.", + "input": [{ + "role": "user", + "content": [ + {"type": "input_text", "text": instructions}, + {"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?}, + {"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?}, + ], + }], + "tools": [{ + "type": "image_generation", + "model": IMAGE_MODEL, + "quality": "high", + "size": "1024x1536", + "output_format": "png", + }], + "tool_choice": "required", + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let resp = client + .post(responses_url(base_url)) + .bearer_auth(&api_key) + .json(&body) + .send() + .await + .map_err(|e| format!("Card mint request failed: {e}"))?; + + let status = resp.status(); + let payload: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Card mint response was not JSON: {e}"))?; + if !status.is_success() { + // Never echo the request (it embeds nothing secret, but keep the + // failure surface small); the OpenAI error body is safe to surface. + let detail = payload + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!("Card mint failed (HTTP {status}): {detail}")); + } + + let (image_b64, designer_notes) = extract_card_output(&payload)?; + let raw_card = STANDARD + .decode(image_b64.as_bytes()) + .map_err(|e| format!("Generated image was not valid base64: {e}"))?; + + // ── Resize to 1500-wide, inject chunk via the existing encoder ────────── + let card_img = image::load_from_memory(&raw_card) + .map_err(|e| format!("Generated image could not be decoded: {e}"))?; + let scale = CARD_WIDTH as f64 / card_img.width() as f64; + let card_img = card_img.resize( + CARD_WIDTH, + (card_img.height() as f64 * scale).round() as u32, + image::imageops::FilterType::Lanczos3, + ); + let mut card_png = Vec::new(); + card_img + .write_to( + &mut std::io::Cursor::new(&mut card_png), + image::ImageFormat::Png, + ) + .map_err(|e| format!("Failed to encode card PNG: {e}"))?; + + let final_bytes = match &lock_keys { + None => encode_snapshot_png(&snapshot, Some(&card_png)) + .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?, + Some((owner_keys, agent_pubkey)) => { + encode_locked_snapshot_png(&snapshot, owner_keys, agent_pubkey, Some(&card_png)) + .map_err(|e| format!("Failed to embed locked agent snapshot in card: {e}"))? + } + }; + + // ── Verify: size ceiling + round-trip on the FINAL bytes ──────────────── + // Locked cards: extract the actual chunk, parse the envelope, decrypt + // with the owner key, then compare the logical manifest (ciphertext is + // nondeterministic — never compare bytes). + validate_snapshot_encode_size(final_bytes.len(), true)?; + let decoded = match &lock_keys { + None => decode_snapshot_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?, + Some((owner_keys, _)) => { + let payload = extract_chunk_payload_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?; + match parse_chunk_payload(&payload) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + { + ChunkPayload::Locked(envelope) => { + decrypt_envelope(&envelope, owner_keys.secret_key()) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + } + ChunkPayload::Plain(_) => { + return Err( + "Card round-trip verification failed: expected a locked envelope." + .to_string(), + ) + } + } + } + }; + if decoded != snapshot { + return Err("Card round-trip verification failed: manifest mismatch.".to_string()); + } + + let slug = crate::util::slugify(&display_name, "agent", 50); + let minted = MintedCard { + card_png_base64: STANDARD.encode(&final_bytes), + file_name: format!("{slug}.agent.png"), + designer_notes, + locked: lock_keys.is_some(), + memory_level, + }; + + // Archive best-effort: the mint is already paid for and verified, so a + // failed archive write logs and continues — it never fails the mint. + if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) { + eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}"); + } + + Ok(minted) +} + +/// The avatar the mint should use: the agent's kind:0 `picture` when one is +/// published and non-blank, else the local record's `avatar_url`. +/// +/// Pure so the precedence is unit-testable without a relay: a blank or +/// whitespace-only `picture` must NOT shadow a real record avatar. +fn preferred_avatar_url( + kind0_picture: Option, + record_avatar_url: Option, +) -> Option { + kind0_picture + .filter(|p| !p.trim().is_empty()) + .or(record_avatar_url) +} + +/// The avatar bytes the card manifest should inline. +/// +/// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is +/// the generated card artwork, and the importer only adopts the body as the +/// avatar when the manifest carries no inline bytes (`import.rs`) — without +/// these bytes an imported agent would wear the card as its face. Downscaled +/// to [`MANIFEST_AVATAR_MAX_DIM`] so the manifest tEXt chunk stays small. +/// +/// Locked cards keep the data-URL-only behavior: the whole manifest must fit +/// the NIP-44 plaintext cap (65 KB), which cannot carry inline pixels, and a +/// locked envelope never reaches the import body override anyway. +fn manifest_avatar_bytes( + locked: bool, + avatar_bytes: &[u8], + record_avatar_url: Option<&str>, +) -> Result>, String> { + if locked { + return Ok(decode_avatar_data_url(record_avatar_url.unwrap_or(""))); + } + png_bytes_resized(avatar_bytes, MANIFEST_AVATAR_MAX_DIM) + .map(Some) + .map_err(|e| format!("Failed to inline the agent avatar into the card manifest: {e}")) +} + +/// True when `url` shares an origin (scheme, host, port) with `relay_base`. +/// +/// Gate for attaching the minted media get-auth header — the token must never +/// be sent to a non-relay origin (same contract as `validate_download_url` in +/// `media_download.rs`, but non-fatal: a foreign origin just fetches +/// unauthenticated instead of failing the mint). +fn is_same_origin(url: &str, relay_base: &str) -> bool { + match (url::Url::parse(url), url::Url::parse(relay_base)) { + (Ok(u), Ok(b)) => u.origin() == b.origin(), + _ => false, + } +} + +/// Fetch an avatar over HTTP with a hard size cap. +/// +/// `auth` is an optional pre-minted Blossom get-auth header value, attached +/// verbatim — the caller is responsible for only supplying it for +/// relay-origin URLs. Redirects are not followed when auth is present +/// (redirect-hop guard, same rule as `media_download.rs`). +/// +/// The cap bounds network and memory, not just the final buffer: the +/// Content-Length header is checked before any body bytes are read, and the +/// body is streamed with a running count so a missing or dishonest header +/// still cannot exceed the cap (same contract as `media_download.rs`). +async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result, String> { + use futures_util::StreamExt; + + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + if auth.is_some() { + // Never let a relay 3xx forward the auth header across origins. + builder = builder.redirect(reqwest::redirect::Policy::none()); + } + let client = builder + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let mut req = client.get(url); + if let Some(auth) = auth { + req = req.header("authorization", auth); + } + let resp = req + .send() + .await + .map_err(|e| format!("Failed to fetch agent avatar: {e}"))?; + if !resp.status().is_success() { + return Err(format!("Avatar fetch failed: HTTP {}", resp.status())); + } + + if let Some(content_length) = resp.content_length() { + if content_length > MAX_AVATAR_FETCH_BYTES as u64 { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + } + + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to read avatar bytes: {e}"))?; + append_within_avatar_cap(&mut bytes, &chunk)?; + } + Ok(bytes) +} + +/// Append a body chunk to the avatar buffer, rejecting before the append if +/// the total would cross `MAX_AVATAR_FETCH_BYTES`. Split out so the cap +/// boundary is unit-testable without an HTTP server. +fn append_within_avatar_cap(buf: &mut Vec, chunk: &[u8]) -> Result<(), String> { + if buf.len() + chunk.len() > MAX_AVATAR_FETCH_BYTES { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + buf.extend_from_slice(chunk); + Ok(()) +} + +/// Save previously minted card bytes to disk via the OS save dialog. +/// +/// Re-validates the bytes (chunk parses as a plain manifest or a +/// structurally valid locked envelope, size within the import ceiling) so a +/// corrupted preview can never be written as a `.agent.png`. No decryption +/// happens here — the mint already round-trip-verified with the real key. +#[tauri::command] +pub async fn save_agent_card( + card_png_base64: String, + file_name: String, + app: AppHandle, +) -> Result { + let bytes = STANDARD + .decode(card_png_base64.as_bytes()) + .map_err(|e| format!("Card bytes were not valid base64: {e}"))?; + validate_snapshot_encode_size(bytes.len(), true)?; + let payload = extract_chunk_payload_png(&bytes) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + parse_chunk_payload(&payload) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + + let safe_name = if file_name.ends_with(".agent.png") && !file_name.contains(['/', '\\']) { + file_name + } else { + "card.agent.png".to_string() + }; + save_bytes_with_dialog(&app, &safe_name, "Agent card", &["png"], &bytes).await +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs new file mode 100644 index 0000000000..ca69c43866 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -0,0 +1,324 @@ +//! Unit tests for `card.rs` — split into a child module file so the parent +//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`). + +use super::*; +use std::collections::BTreeMap; + +#[test] +fn archive_file_name_validation_rejects_escapes() { + assert!(validate_archive_file_name("eva-1234.agent.png").is_ok()); + for bad in [ + "../escape.agent.png", + "sub/dir.agent.png", + "sub\\dir.agent.png", + "not-a-card.png", + "plain.json", + "", + ] { + assert!( + validate_archive_file_name(bad).is_err(), + "expected rejection: {bad:?}" + ); + } +} + +#[test] +fn card_template_decodes_with_expected_shape() { + // The embedded template is generation input only, but a corrupt or + // accidentally swapped asset should fail the build's test gate, not a + // user's first mint. + let img = image::load_from_memory(CARD_TEMPLATE_PNG).expect("template must decode"); + // 2:3-ish portrait frame. + assert!(img.height() > img.width(), "template must be portrait"); + assert!(img.width() >= 512, "template unexpectedly small"); +} + +#[test] +fn key_resolution_layering_record_wins() { + let mut global = BTreeMap::new(); + global.insert("OPENAI_API_KEY".to_string(), "global".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), "record".to_string()); + + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("record") + ); + record.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("persona") + ); + persona.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("global") + ); + global.clear(); + assert_eq!( + resolve_env_from_layers( + "OPENAI_API_KEY", + &global, + &persona, + &record, + Some("process".to_string()) + ) + .as_deref(), + Some("process") + ); + assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none()); +} + +#[test] +fn key_resolution_skips_blank_values() { + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), " ".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None) + .as_deref(), + Some("persona") + ); +} + +#[test] +fn responses_url_default_and_override() { + assert_eq!(responses_url(None), "https://api.openai.com/v1/responses"); + // Trailing slashes must not produce a double-slash path. + assert_eq!( + responses_url(Some("https://proxy.example/v1/".to_string())), + "https://proxy.example/v1/responses" + ); + assert_eq!( + responses_url(Some("https://proxy.example/v1".to_string())), + "https://proxy.example/v1/responses" + ); +} + +#[test] +fn instructions_pin_style_match_default_and_owner_primacy() { + let base = build_card_instructions("Eva", "leads the team", ""); + assert!(base.contains("match input image 2's art style EXACTLY")); + assert!(base.contains("\"Eva\"")); + assert!(!base.contains("OWNER'S DIRECTIONS")); + + let directed = build_card_instructions("Eva", "leads the team", "make it stormy"); + // Owner directions take primacy over style defaults... + assert!(directed.contains("OWNER'S DIRECTIONS")); + assert!(directed.contains("make it stormy")); + assert!(directed.contains("override the default art-style and copy guidance")); + // ...but the fixed contract survives: frame, style anchor (as an + // overridable default), and text-fidelity requirements stay present. + assert!(directed.contains("match input image 2's art style EXACTLY")); + assert!(directed.contains("cannot change the frame, layout, or")); + assert!(directed.contains("Render all text with perfect fidelity")); + // Card-text direction is an explicitly named capability, and the + // owner-wording rule acknowledges the fixed 220-char text-box limit + // (no mutually impossible "verbatim" vs "under 220 chars" pair). + assert!(directed.contains("card text")); + assert!(directed.contains("use their wording within the 220-character text-box limit")); +} + +#[test] +fn extract_card_output_happy_path_and_missing_image() { + let ok = serde_json::json!({ + "output": [ + {"type": "reasoning"}, + {"type": "image_generation_call", "result": "aW1n"}, + {"type": "message", "content": [ + {"type": "output_text", "text": "notes here"} + ]} + ] + }); + let (img, notes) = extract_card_output(&ok).unwrap(); + assert_eq!(img, "aW1n"); + assert_eq!(notes, "notes here"); + + let missing = serde_json::json!({"output": [{"type": "message", "content": []}]}); + let err = extract_card_output(&missing).unwrap_err(); + assert!(err.contains("No image"), "{err}"); + + let no_output = serde_json::json!({}); + assert!(extract_card_output(&no_output).is_err()); +} + +#[test] +fn kind0_picture_wins_over_record_avatar_unless_blank() { + let some = |s: &str| Some(s.to_string()); + // Published picture wins. + assert_eq!( + preferred_avatar_url(some("https://relay/media/k0.png"), some("data:image/png;x")), + some("https://relay/media/k0.png") + ); + // No profile / no picture: record avatar survives. + assert_eq!( + preferred_avatar_url(None, some("data:image/png;x")), + some("data:image/png;x") + ); + // Blank or whitespace picture must not shadow a real avatar. + assert_eq!( + preferred_avatar_url(some(""), some("data:image/png;x")), + some("data:image/png;x") + ); + assert_eq!( + preferred_avatar_url(some(" "), some("data:image/png;x")), + some("data:image/png;x") + ); + // Nothing anywhere: None (caller surfaces the "no avatar" error). + assert_eq!(preferred_avatar_url(None, None), None); +} + +#[test] +fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() { + // 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color. + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 700, + 700, + image::Rgba([9, 120, 33, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + + let inlined = manifest_avatar_bytes(false, avatar_png.get_ref(), None) + .unwrap() + .expect("unlocked mints must inline the real avatar"); + let img = image::load_from_memory(&inlined).unwrap(); + assert_eq!( + (img.width(), img.height()), + (MANIFEST_AVATAR_MAX_DIM, MANIFEST_AVATAR_MAX_DIM) + ); + assert_eq!(img.to_rgba8().get_pixel(0, 0).0, [9, 120, 33, 255]); + + // Undecodable avatar bytes fail the mint (pre-spend), never silently + // produce a card whose import would wear the artwork as a face. + assert!(manifest_avatar_bytes(false, b"not a png", None).is_err()); +} + +#[test] +fn locked_manifest_keeps_data_url_only_avatar() { + // Locked mints must not inline fetched bytes (NIP-44 cap): only a record + // data URL carries over, exactly as before. + let unused = [0u8; 4]; + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("data:image/png;base64,aGk=")) + .unwrap() + .as_deref(), + Some(b"hi".as_slice()) + ); + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("https://relay/media/a.png")).unwrap(), + None + ); + assert_eq!(manifest_avatar_bytes(true, &unused, None).unwrap(), None); +} + +#[test] +fn media_get_auth_gate_is_same_origin_only() { + // The minted Blossom get-auth header may only travel to the relay's own + // origin — scheme, host, and port all count (same contract as + // `validate_download_url` in `media_download.rs`). + let relay = "https://relay.example.com"; + assert!(is_same_origin( + "https://relay.example.com/media/abc.png", + relay + )); + // Different host, scheme, or port: no auth. + assert!(!is_same_origin( + "https://evil.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "http://relay.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "https://relay.example.com:8443/media/abc.png", + relay + )); + // Unparseable inputs fail closed. + assert!(!is_same_origin("not a url", relay)); + assert!(!is_same_origin( + "https://relay.example.com/x", + "also not a url" + )); + // Explicit port on both sides matches. + assert!(is_same_origin( + "http://localhost:3100/media/abc.png", + "http://localhost:3100" + )); +} + +#[test] +fn avatar_cap_rejects_before_appending_crossing_chunk() { + // The streaming accumulator must reject a chunk that would cross the + // cap BEFORE buffering it — this is what bounds memory when + // Content-Length is missing or dishonest. + let mut buf = vec![0u8; MAX_AVATAR_FETCH_BYTES - 1]; + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_ok()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + // Exactly at the cap: one more byte must fail and not grow the buffer. + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_err()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + + // A single oversized chunk is rejected outright. + let mut fresh = Vec::new(); + let oversized = vec![0u8; MAX_AVATAR_FETCH_BYTES + 1]; + assert!(append_within_avatar_cap(&mut fresh, &oversized).is_err()); + assert!(fresh.is_empty()); +} + +#[test] +fn save_rejects_plain_png_without_snapshot_chunk() { + // A plain PNG (no buzz_agent_snapshot chunk) must not be saveable as + // a card. Exercise the same validation the command runs. + let img = image::DynamicImage::new_rgba8(4, 4); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + assert!(decode_snapshot_png(&png).is_err()); +} + +#[test] +fn archived_sidecar_without_memory_level_defaults_to_none() { + // Every mint before the memory option existed embedded MemoryLevel::None + // structurally, so old sidecars (no memoryLevel field) must deserialize + // to None — the gallery's disclosure depends on this being honest. + let legacy = r#"{ + "storedFileName": "eva-1234.agent.png", + "fileName": "eva.agent.png", + "agentId": "abc", + "agentName": "Eva", + "designerNotes": "", + "locked": false, + "mintedAt": "2026-07-28T00:00:00Z" + }"#; + let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::None); + + let with_level = legacy.replace( + "\"locked\": false,", + "\"locked\": false, \"memoryLevel\": \"everything\",", + ); + let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::Everything); +} + +#[test] +fn minted_card_serializes_memory_level_snake_case_value() { + // The TS layer narrows on the exact wire strings "none"/"core"/ + // "everything" — pin the serde representation the frontend will see. + let minted = MintedCard { + card_png_base64: String::new(), + file_name: "eva.agent.png".to_string(), + designer_notes: String::new(), + locked: false, + memory_level: MemoryLevel::Core, + }; + let json = serde_json::to_value(&minted).unwrap(); + assert_eq!(json["memoryLevel"], "core"); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..0cd7ad0324 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -306,11 +306,14 @@ pub async fn set_persona_active( } pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; +mod card; mod snapshot; -pub use snapshot::encode_agent_snapshot_for_send; -pub use snapshot::export_agent_snapshot; +pub use card::*; +#[cfg(test)] +pub(crate) use snapshot::import::decode_snapshot_from_bytes; pub(crate) use snapshot::import::{ - decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + parse_snapshot_payload_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; +pub use snapshot::{encode_agent_snapshot_for_send, export_agent_snapshot}; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 583296dac0..e7bd1597e6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -142,7 +142,7 @@ pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> R } /// Parse a `memory_level` string to `MemoryLevel`. -fn parse_memory_level(s: &str) -> Result { +pub(crate) fn parse_memory_level(s: &str) -> Result { match s { "none" | "" => Ok(MemoryLevel::None), "core" => Ok(MemoryLevel::Core), @@ -153,6 +153,32 @@ fn parse_memory_level(s: &str) -> Result { } } +/// Flatten an owner-decrypted memory listing into manifest entries for +/// `memory_level`: `Core` takes the core entry only; `Everything` appends all +/// `mem/*` entries after it. Pure so both the export and card-mint paths share +/// (and tests can pin) the level → entries selection. +pub(crate) fn memory_entries_from_listing( + listing: crate::commands::engrams::AgentMemoryListing, + memory_level: MemoryLevel, +) -> Vec { + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + entries +} + /// Parse a `format` string to a PNG flag. fn parse_format_is_png(s: &str) -> Result { match s { @@ -267,22 +293,7 @@ pub(crate) async fn materialize_snapshot_bytes( // ── Fetch memory ───────────────────────────────────────────────────────── let memory_entries: Vec = if let Some(pubkey) = memory_pubkey { let listing = get_agent_memory(pubkey, app.clone(), state).await?; - let mut entries = Vec::new(); - if let Some(core) = listing.core { - entries.push(AgentSnapshotMemoryEntry { - slug: core.slug, - body: core.body, - }); - } - if memory_level == MemoryLevel::Everything { - for mem in listing.memories { - entries.push(AgentSnapshotMemoryEntry { - slug: mem.slug, - body: mem.body, - }); - } - } - entries + memory_entries_from_listing(listing, memory_level) } else { Vec::new() }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 00a1457393..b769d74d7b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -208,3 +208,64 @@ fn import_png_placeholder_keeps_manifest_avatar_fallback() { assert!(decoded.profile.avatar_data_url.is_none()); assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); } + +/// An unlocked trading card imports the agent's REAL avatar, never the card. +/// +/// Mint-shaped input: the PNG body is the generated card artwork, while the +/// manifest inlines the source avatar (`manifest_avatar_bytes` in `card.rs`). +/// The #3578 body-wins override must not fire when the manifest already +/// carries inline avatar bytes — otherwise the imported agent publishes the +/// 1500-wide card as its kind:0 picture. +#[test] +fn import_unlocked_card_uses_manifest_avatar_not_card_artwork() { + use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + // The real avatar: 4×3 solid blue, inlined in the manifest at mint time. + let real_avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 3, + image::Rgba([23, 91, 177, 255]), + )); + let mut real_avatar_png = std::io::Cursor::new(Vec::new()); + real_avatar + .write_to(&mut real_avatar_png, image::ImageFormat::Png) + .unwrap(); + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(real_avatar_png.get_ref()) + )); + snapshot.profile.avatar_url = Some("https://relay.example/media/live-kind0.png".to_string()); + + // The card artwork: a distinct 1500×2250 solid red "trading card" as the + // PNG body — the exact dimensions the minter encodes for unlocked cards. + // Size matters: 2250px exceeds `snapshot_avatar`'s 2048px decode limit, + // so reaching the body override here wouldn't just import the wrong + // face — it would fail the import outright. + let card_art = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1500, + 2250, + image::Rgba([200, 16, 16, 255]), + )); + let mut card_png = std::io::Cursor::new(Vec::new()); + card_art + .write_to(&mut card_png, image::ImageFormat::Png) + .unwrap(); + let file_bytes = encode_snapshot_png(&snapshot, Some(card_png.get_ref())).unwrap(); + + // Production import decode: the effective avatar must be the real one. + let decoded = decode_snapshot_from_bytes(&file_bytes).unwrap(); + let avatar_bytes = + decode_avatar_data_url(decoded.profile.avatar_data_url.as_deref().unwrap()).unwrap(); + let imported = image::load_from_memory(&avatar_bytes).unwrap(); + assert_eq!( + (imported.width(), imported.height()), + (4, 3), + "imported avatar must be the source avatar, not the card artwork" + ); + assert_eq!(imported.to_rgba8().get_pixel(0, 0).0, [23, 91, 177, 255]); + // The live kind:0 URL fallback survives untouched. + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eccf8ee601..d7f0323304 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, + agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, MemoryLevel}, + agent_snapshot_envelope::{ + decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, + LOCKED_CARD_REFUSAL, + }, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -72,6 +76,16 @@ pub struct AgentSnapshotImportPreview { pub has_source_allowlist: bool, /// Number of source allowlist entries. pub source_allowlist_count: usize, + /// Full source allowlist entries, surfaced before import so hidden access + /// configuration is never reduced to a count. + pub source_allowlist: Vec, + /// Pretty-printed, validated manifest exactly as decoded from the file. + /// The UI makes this available before confirmation for full payload review. + pub manifest_json: String, + /// True when the snapshot came from a locked (encrypted) card that this + /// machine successfully unlocked. Cards that cannot be unlocked never + /// reach a preview — they fail closed with the locked-card refusal. + pub locked: bool, } /// The confirmation request sent from the UI after the user reviews the preview. @@ -210,50 +224,112 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; /// /// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected /// before allocation to avoid avoidable large-input work. -pub(crate) fn decode_snapshot_from_bytes( - file_bytes: &[u8], -) -> Result { - if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { +/// +/// **Locked cards:** a structurally valid locked envelope parses successfully +/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can +/// unlock go through [`decode_snapshot_for_import`]; callers that only need +/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is. +pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result { + let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { return Err(format!( "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", file_bytes.len() / (1024 * 1024) )); } - let mut snapshot = decode_snapshot_png(file_bytes)?; + let chunk_json = extract_chunk_payload_png(file_bytes)?; + let mut payload = parse_chunk_payload(&chunk_json)?; // The PNG image body is the portable avatar. It deliberately wins over - // manifest avatar fields, whose URL may only be reachable by the - // sender. A 1×1 export placeholder leaves the manifest fallback intact. - if let Some(avatar_data_url) = - crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)? - { - snapshot.profile.avatar_data_url = Some(avatar_data_url); + // a manifest avatar *URL*, which may only be reachable by the sender. + // A 1×1 export placeholder leaves the manifest fallback intact. + // Inline manifest avatar *bytes* are authoritative and never + // overridden: trading cards supply the generated card artwork as the + // PNG body and carry the agent's real avatar inline — adopting the + // body there would import the card as the agent's face. + // Locked envelopes stay opaque here — there is no manifest to override + // until the unlock path decrypts one. + if let ChunkPayload::Plain(snapshot) = &mut payload { + if snapshot.profile.avatar_data_url.is_none() { + if let Some(avatar_data_url) = + crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url( + file_bytes, + )? + { + snapshot.profile.avatar_data_url = Some(avatar_data_url); + } + } } - if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { - return Err( - "Snapshot is malformed: memory.level is 'none' but entries are present." - .to_string(), - ); + payload + } else { + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); } - return Ok(snapshot); - } - // JSON path — apply size cap before serde allocation. - if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { - return Err(format!( - "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", - file_bytes.len() / (1024 * 1024) - )); - } - let snapshot = decode_snapshot_json(file_bytes)?; + parse_chunk_payload(file_bytes)? + }; // Consistency check: none + non-empty entries is always malformed, - // regardless of format. Mirrors the PNG path above so the rule is - // enforced at decode time for both formats. - if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None { + // regardless of enclosing format. Enforced at decode time for plain + // payloads here, and after decryption for locked ones (see + // `enforce_memory_consistency` callers). + if let ChunkPayload::Plain(snapshot) = &payload { + enforce_memory_consistency(snapshot)?; + } + Ok(payload) +} + +/// The shared malformed-memory guard: `memory.level == none` with non-empty +/// entries is always rejected before any write. +fn enforce_memory_consistency( + snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot, +) -> Result<(), String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), ); } - Ok(snapshot) + Ok(()) +} + +/// Decode a plain snapshot from raw bytes, refusing locked cards. +/// +/// Test-only convenience: production call sites either unlock through +/// [`decode_snapshot_for_import`] or validate structurally through +/// [`parse_snapshot_payload_from_bytes`]. +#[cfg(test)] +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok(*snapshot), + ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()), + } +} + +/// Decode a snapshot for import, unlocking locked cards when — and only +/// when — this machine holds one of the envelope's two exact key endpoints +/// (the owner identity or the named local agent record). +/// +/// Returns the decoded manifest and whether it came from a locked envelope. +/// When neither endpoint exists, fails closed with the locked-card refusal — +/// never partial plaintext, never crypto details. +pub(crate) fn decode_snapshot_for_import( + file_bytes: &[u8], + owner_keys: Option<&nostr::Keys>, + records: &[ManagedAgentRecord], +) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)), + ChunkPayload::Locked(envelope) => { + let secret = resolve_unlock_secret(&envelope, owner_keys, records) + .ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?; + let snapshot = decrypt_envelope(&envelope, &secret)?; + enforce_memory_consistency(&snapshot)?; + Ok((snapshot, true)) + } + } } async fn materialize_import_avatar( @@ -283,19 +359,38 @@ where /// `.agent.png` file. The format is sniffed from the content, not the /// extension, so an incorrectly-named file is handled correctly. /// +/// Locked cards are unlocked here when this machine holds one of the +/// envelope's two exact key endpoints; a card that cannot be unlocked fails +/// with the locked-card refusal (shown directly to the user), never a +/// partial preview. Identity-recovery mode is tolerated: owner keys are +/// simply unavailable, so only the agent-record endpoint can unlock. +/// /// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors -/// represent irrecoverable failures (corrupt / unsupported file) and are -/// shown directly to the user. +/// represent irrecoverable failures (corrupt / unsupported / locked-to- +/// someone-else file) and are shown directly to the user. #[tauri::command] pub async fn preview_agent_snapshot_import( file_bytes: Vec, file_name: String, + app: AppHandle, + state: State<'_, AppState>, ) -> Result { + // Key material + records are gathered up front (cheap, lock-scoped) so + // the blocking decode below owns plain data. + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; tokio::task::spawn_blocking(move || { reject_legacy_persona_filename(&file_name)?; - let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + let (snapshot, locked) = + decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?; - Ok(build_agent_snapshot_import_preview(&snapshot)) + build_agent_snapshot_import_preview(&snapshot, locked) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -303,7 +398,8 @@ pub async fn preview_agent_snapshot_import( pub(crate) fn build_agent_snapshot_import_preview( snapshot: &AgentSnapshot, -) -> AgentSnapshotImportPreview { + locked: bool, +) -> Result { let memory_level = match snapshot.memory.level { MemoryLevel::None => "none", MemoryLevel::Core => "core", @@ -311,7 +407,11 @@ pub(crate) fn build_agent_snapshot_import_preview( } .to_string(); - AgentSnapshotImportPreview { + let manifest_json = serde_json::to_string_pretty(snapshot) + .map_err(|e| format!("failed to render snapshot manifest: {e}"))?; + let source_allowlist = snapshot.definition.respond_to_allowlist.clone(); + + Ok(AgentSnapshotImportPreview { display_name: snapshot.profile.display_name.clone(), is_builtin: snapshot.definition.source_is_builtin, model: snapshot.definition.model.clone(), @@ -325,9 +425,12 @@ pub(crate) fn build_agent_snapshot_import_preview( .or_else(|| snapshot.profile.avatar_url.clone()), memory_level, memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - } + source_allowlist_count: source_allowlist.len(), + has_source_allowlist: !source_allowlist.is_empty(), + source_allowlist, + manifest_json, + locked, + }) } // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── @@ -355,8 +458,20 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── - let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + // ── Phase 1: validate (no writes) ──────────────────────────────────────── + // Locked cards unlock only via this machine's exact key endpoints; + // anything else fails closed here, before key generation. + let snapshot = { + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; + decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 + }; let display_name = snapshot.profile.display_name.trim().to_string(); if display_name.is_empty() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 4289310280..c453b09a9d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -564,7 +564,7 @@ fn import_preview_includes_exported_definition_metadata() { let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); - let preview = build_agent_snapshot_import_preview(&decoded); + let preview = build_agent_snapshot_import_preview(&decoded, false).unwrap(); assert!(preview.is_builtin); assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); @@ -949,51 +949,14 @@ fn test_parse_format_is_png_invalid_returns_error() { } // ── Export: validate_snapshot_encode_size ──────────────────────────────────── -// -// Tests call `validate_snapshot_encode_size` directly so they prove the exact -// production guard — not a manual reconstruction. Removing or reversing the -// check in production code will cause these tests to fail. -/// JSON: boundary-1 passes, boundary is the last legal byte count. -#[test] -fn validate_encode_size_json_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); -} +#[path = "tests_memory_entries.rs"] +mod memory_entries; -/// JSON: exactly at the boundary is the last accepted size. -#[test] -fn validate_encode_size_json_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); -} +#[path = "tests_encode_size.rs"] +mod encode_size; -/// JSON: boundary+1 is rejected. -#[test] -fn validate_encode_size_json_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +// ── Import: decode_snapshot_for_import (locked cards) ───────────────────── -/// PNG: boundary-1 passes. -#[test] -fn validate_encode_size_png_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); -} - -/// PNG: exactly at the boundary passes. -#[test] -fn validate_encode_size_png_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); -} - -/// PNG: boundary+1 is rejected. -#[test] -fn validate_encode_size_png_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +#[path = "tests_locked.rs"] +mod locked_import; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs new file mode 100644 index 0000000000..36eaa99716 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -0,0 +1,55 @@ +//! Export-size guard tests for `validate_snapshot_encode_size`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test imports. +//! +//! Tests call `validate_snapshot_encode_size` directly so they prove the +//! exact production guard — not a manual reconstruction. Removing or +//! reversing the check in production code will cause these tests to fail. + +use super::*; + +/// JSON: boundary-1 passes, boundary is the last legal byte count. +#[test] +fn validate_encode_size_json_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); +} + +/// JSON: exactly at the boundary is the last accepted size. +#[test] +fn validate_encode_size_json_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); +} + +/// JSON: boundary+1 is rejected. +#[test] +fn validate_encode_size_json_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} + +/// PNG: boundary-1 passes. +#[test] +fn validate_encode_size_png_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); +} + +/// PNG: exactly at the boundary passes. +#[test] +fn validate_encode_size_png_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); +} + +/// PNG: boundary+1 is rejected. +#[test] +fn validate_encode_size_png_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs new file mode 100644 index 0000000000..296444f78d --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -0,0 +1,129 @@ +//! Locked-card import tests for `decode_snapshot_for_import`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. + +use super::*; +use crate::commands::personas::snapshot::import::{ + decode_snapshot_for_import, parse_snapshot_payload_from_bytes, +}; +use crate::managed_agents::agent_snapshot_envelope::{ + encode_locked_snapshot_png, encrypt_snapshot_envelope, ChunkPayload, LOCKED_CARD_REFUSAL, +}; + +/// Build a keyed instance record holding real key material, so the +/// agent-endpoint unlock path resolves exactly as production does. +fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: agent.public_key().to_hex(), + slug: None, + persona_id: Some("locked-test".to_string()), + private_key_nsec: nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(), + ..make_definition("") + } +} + +fn locked_png(owner: &nostr::Keys, agent: &nostr::Keys) -> (AgentSnapshot, Vec) { + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_locked_snapshot_png(&snapshot, owner, &agent.public_key(), None).unwrap(); + (snapshot, png) +} + +/// Owner identity key unlocks a locked card; `locked` is reported true. +#[test] +fn owner_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// A local managed-agent record holding the agent nsec unlocks the card +/// even when the owner identity does not match (e.g. re-import on the +/// agent's own machine under a different owner identity). +#[test] +fn agent_record_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let other_identity = nostr::Keys::generate(); + let records = vec![record_for(&agent)]; + let (decoded, locked) = + decode_snapshot_for_import(&png, Some(&other_identity), &records).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// No matching endpoint → only the locked-card refusal, nothing else. +#[test] +fn stranger_fails_closed_with_refusal_only() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let stranger = nostr::Keys::generate(); + let unrelated_record = record_for(&nostr::Keys::generate()); + let err = decode_snapshot_for_import(&png, Some(&stranger), &[unrelated_record]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + // And with no key material at all. + let err = decode_snapshot_for_import(&png, None, &[]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} + +/// Plain snapshots pass through unchanged with `locked == false`, with or +/// without key material in scope. +#[test] +fn plain_snapshot_passes_through_unlocked() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + let owner = nostr::Keys::generate(); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); + let (decoded, locked) = decode_snapshot_for_import(&png, None, &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); +} + +/// The memory-consistency guard fires AFTER decryption too: a locked +/// envelope whose plaintext declares level none + non-empty entries is +/// rejected even for a legitimate endpoint. +#[test] +fn decrypted_manifest_memory_consistency_enforced() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let malformed = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + ); + // encrypt_snapshot_envelope does not guard memory consistency (the + // PNG encoder does), so this constructs the malicious payload. + let envelope = encrypt_snapshot_envelope(&malformed, &owner, &agent.public_key()).unwrap(); + let json = serde_json::to_vec(&envelope).unwrap(); + let err = decode_snapshot_for_import(&json, Some(&owner), &[]).unwrap_err(); + assert!( + err.contains("'none' but entries are present"), + "post-decrypt consistency guard must fire, got: {err}" + ); +} + +/// Transit validation (`fetch_snapshot_bytes` path) accepts a locked PNG +/// without any key material — structural validation only, no decryption. +#[test] +fn transit_validation_accepts_locked_png_without_keys() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let payload = parse_snapshot_payload_from_bytes(&png).unwrap(); + assert!(matches!(payload, ChunkPayload::Locked(_))); +} + +/// The keyless plain decoder refuses locked cards with the refusal. +#[test] +fn plain_decoder_refuses_locked_cards() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let err = decode_snapshot_from_bytes(&png).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs new file mode 100644 index 0000000000..b17efa1ad1 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -0,0 +1,55 @@ +//! Tests for `memory_entries_from_listing` — the shared level → entries +//! selection used by both snapshot export and card minting. Split from +//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included +//! from there as a child module, so `super::*` resolves to `tests`'s parent +//! scope re-exports. + +use super::*; + +fn listing_fixture() -> crate::commands::engrams::AgentMemoryListing { + let entry = |slug: &str, body: &str| crate::commands::engrams::EngramEntry { + slug: slug.to_string(), + body: body.to_string(), + event_id: "e".repeat(64), + created_at: 1, + outgoing_refs: vec![], + }; + crate::commands::engrams::AgentMemoryListing { + core: Some(entry("core", "core body")), + memories: vec![entry("mem/a", "a body"), entry("mem/b", "b body")], + truncated: false, + fetched_at: 1, + } +} + +#[test] +fn memory_entries_core_takes_core_only() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Core); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "core"); + assert_eq!(entries[0].body, "core body"); +} + +#[test] +fn memory_entries_everything_appends_mem_entries_after_core() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["core", "mem/a", "mem/b"] + ); +} + +#[test] +fn memory_entries_missing_core_still_yields_mem_entries_for_everything() { + let mut listing = listing_fixture(); + listing.core = None; + let entries = memory_entries_from_listing(listing, MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["mem/a", "mem/b"] + ); + + let mut core_only = listing_fixture(); + core_only.core = None; + assert!(memory_entries_from_listing(core_only, MemoryLevel::Core).is_empty()); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5cbfddb73f..c4b733e3e0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -28,8 +28,6 @@ mod prevent_sleep; mod ptt_shortcut; mod relay; mod relay_admission; -// FORK-LOCAL PATCH (adrienlacombe/buzz): single-relay host allowlist. -mod relay_allowlist; mod reset; mod secret_store; mod shutdown; @@ -67,10 +65,7 @@ use mesh_llm_stubs::*; #[cfg(all(feature = "mesh-llm", target_os = "macos"))] use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; +use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] use tauri::{Listener, WindowEvent}; @@ -853,6 +848,12 @@ pub fn run() { update_team, delete_team, export_agent_snapshot, + card_mint_key_status, + card_mint_save_openai_key, + mint_agent_card, + save_agent_card, + list_agent_cards, + load_agent_card, preview_agent_snapshot_import, confirm_agent_snapshot_import, encode_agent_snapshot_for_send, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 16a0d35b23..7c08e7095f 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -306,9 +306,22 @@ pub fn encode_snapshot_png( ); } - // Manifest → JSON → base64 for the tEXt chunk payload. + // Manifest → JSON for the tEXt chunk payload. The payload/PNG composition + // is shared with the locked-card encoder in `agent_snapshot_envelope`; + // plain cards remain byte-identical to the pre-envelope encoder. let json_bytes = encode_snapshot_json(snapshot)?; - let chunk_text = STANDARD.encode(&json_bytes); + encode_chunk_payload_png(&json_bytes, avatar_bytes) +} + +/// Encode arbitrary chunk-payload JSON (plain manifest or locked envelope) +/// into a PNG carrying it base64-encoded in the `buzz_agent_snapshot` tEXt +/// chunk. Shared by the plain encoder above and +/// `agent_snapshot_envelope::encode_locked_snapshot_png`. +pub(crate) fn encode_chunk_payload_png( + json_bytes: &[u8], + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + let chunk_text = STANDARD.encode(json_bytes); // Use the avatar as the PNG image body, transcoding decodable non-PNG // avatars. Fall back to a minimal 1×1 transparent placeholder only when @@ -334,8 +347,11 @@ pub fn encode_snapshot_png( Ok(png_bytes) } -/// Decode a manifest from a `.agent.png` tEXt chunk. -pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { +/// Extract and base64-decode the raw `buzz_agent_snapshot` chunk payload +/// (JSON bytes) from a PNG, without interpreting it. The payload may be a +/// plain manifest or a locked envelope — callers dispatch on the parsed +/// `format` via `agent_snapshot_envelope::parse_chunk_payload`. +pub(crate) fn extract_chunk_payload_png(png_bytes: &[u8]) -> Result, String> { let decoder = Decoder::new(Cursor::new(png_bytes)); let reader = decoder .read_info() @@ -349,10 +365,18 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { .map(|c| c.text.as_str()) .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?; - let json_bytes = STANDARD + STANDARD .decode(chunk_text.trim()) - .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}")) +} +/// Decode a manifest from a `.agent.png` tEXt chunk. +/// +/// Plain snapshots only — a locked (encrypted) chunk payload fails here with +/// the manifest format error. Import paths that must handle locked cards go +/// through `agent_snapshot_envelope::parse_chunk_payload` instead. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let json_bytes = extract_chunk_payload_png(png_bytes)?; decode_snapshot_json(&json_bytes) } @@ -473,527 +497,5 @@ fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "deadbeef".to_string(), - name: "Test Agent".to_string(), - display_name: Some("Test Agent Display".to_string()), - persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot - team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot - private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot - auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot - relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot - avatar_url: Some("https://example.com/avatar.png".to_string()), - acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot - agent_command: "goose".to_string(), // MUST NOT appear in snapshot - agent_command_override: Some("goose-override".to_string()), // MUST NOT appear - agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot - mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot - turn_timeout_seconds: 120, // deprecated, MUST NOT appear - idle_timeout_seconds: Some(30), - max_turn_duration_seconds: Some(600), - parallelism: 2, - system_prompt: Some("You are a test agent.".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - persona_source_version: Some("v1.0".to_string()), // MUST NOT appear - env_vars: { - let mut m = BTreeMap::new(); - m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear - m - }, - start_on_app_launch: true, - auto_restart_on_config_change: true, - runtime_pid: Some(12345), // MUST NOT appear - backend: BackendKind::Provider { - // MUST NOT appear — carries a provider secret - id: "SENTINEL_BACKEND_ID".to_string(), - config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), - }, - backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear - provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear - persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear - persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear - last_stopped_at: None, - last_exit_code: Some(0), // MUST NOT appear - last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear - last_error_code: Some(42), // MUST NOT appear - respond_to: RespondTo::default(), - respond_to_allowlist: vec!["pubkey1hex".to_string()], - slug: Some("test-agent".to_string()), - runtime: Some("goose".to_string()), - name_pool: vec!["Alice".to_string(), "Bob".to_string()], - is_builtin: false, - is_active: true, - shared: false, - source_team: Some("team-id-123".to_string()), // MUST NOT appear - source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear - definition_respond_to: Some("allowlist".to_string()), - catalog_source: None, - definition_respond_to_allowlist: vec!["abc123def".to_string()], - definition_parallelism: Some(4), - relay_mesh: None, - } - } - - // ── Round-trip tests ────────────────────────────────────────────────────── - - #[test] - fn json_round_trip_config_only() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn json_round_trip_with_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "I am a test agent.".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/research".to_string(), - body: "Some research notes.".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn png_round_trip_no_memory() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); - assert_eq!(parsed.memory.level, MemoryLevel::None); - } - - #[test] - fn png_round_trip_with_avatar_png() { - // Build a minimal PNG avatar. - let avatar = make_png_with_text("dummy", "value").unwrap(); - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); - // Avatar should be inlined as a data URL. - assert!(snapshot - .profile - .avatar_data_url - .as_deref() - .unwrap_or("") - .starts_with("data:image/png;base64,")); - - let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - } - - #[test] - fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { - let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 3, - 2, - image::Rgb([0x12, 0x34, 0x56]), - )); - let mut jpeg_bytes = Vec::new(); - avatar - .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) - .unwrap(); - - let snapshot = build_snapshot( - &minimal_record(), - MemoryLevel::None, - vec![], - Some(&jpeg_bytes), - ); - let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); - let decoder = Decoder::new(Cursor::new(png_bytes)); - let reader = decoder.read_info().unwrap(); - - assert_eq!((reader.info().width, reader.info().height), (3, 2)); - } - - // ── PNG memory parity ───────────────────────────────────────────────────── - - #[test] - fn png_round_trip_with_core_memory() { - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }]; - let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_round_trip_with_everything_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/notes".to_string(), - body: "private notes".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_export_with_no_memory_succeeds() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert!(encode_snapshot_png(&snapshot, None).is_ok()); - } - - #[test] - fn png_export_rejects_none_level_with_nonempty_entries() { - // Inconsistent state: level == None but entries is non-empty. - // The encoder must reject this to prevent a memory-leak bypass. - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "leaked memory".to_string(), - }]; - // Build with entries, then override level to None in the struct. - let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - snapshot.memory.level = MemoryLevel::None; // force inconsistency - let result = encode_snapshot_png(&snapshot, None); - assert!( - result.is_err(), - "PNG encoder must reject level=None with non-empty entries" - ); - assert!( - result - .unwrap_err() - .contains("memory.level 'none' and non-empty memory entries"), - "Error must explain the malformed memory state" - ); - } - - // ── Secret exclusion tests ──────────────────────────────────────────────── - // - // These tests assert that every field in the exclusion list is absent from - // the serialized snapshot. We serialize to JSON and assert the key is NOT - // present. - - fn snapshot_json_string(record: &ManagedAgentRecord) -> String { - let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - String::from_utf8(bytes).unwrap() - } - - #[test] - fn secret_exclusion_private_key_nsec_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("nsec1secret"), - "nsec must not appear in snapshot" - ); - assert!( - !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), - "privateKeyNsec field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_auth_tag_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("auth-tag-secret"), - "auth_tag value must not appear in snapshot" - ); - assert!( - !json.contains("authTag") && !json.contains("auth_tag"), - "authTag field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_env_vars_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("API_KEY") && !json.contains("secret123"), - "env_vars content must not appear in snapshot" - ); - assert!( - !json.contains("envVars") && !json.contains("env_vars"), - "envVars field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_relay_url_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("wss://relay.example.com"), - "relay_url value must not appear in snapshot" - ); - assert!( - !json.contains("relayUrl") && !json.contains("relay_url"), - "relayUrl field must not appear in snapshot" - ); - } - - #[test] - fn snapshot_omits_removed_mcp_toolsets_config() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), - "removed MCP toolsets config must not re-enter snapshots" - ); - } - - #[test] - fn secret_exclusion_machine_commands_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - // acp_command / agent_command / agent_command_override / agent_args / mcp_command - assert!( - !json.contains("/usr/local/bin/acp"), - "acp_command path must not appear" - ); - assert!( - !json.contains("acpCommand") && !json.contains("acp_command"), - "acpCommand field must not appear" - ); - assert!( - !json.contains("agentCommand") && !json.contains("agent_command"), - "agentCommand field must not appear" - ); - assert!( - !json.contains("mcpCommand") && !json.contains("mcp_command"), - "mcpCommand field must not appear" - ); - } - - #[test] - fn secret_exclusion_runtime_state_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("runtimePid") && !json.contains("runtime_pid"), - "runtimePid must not appear" - ); - assert!( - !json.contains("backendAgentId") && !json.contains("backend_agent_id"), - "backendAgentId must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_AGENT_ID"), - "backendAgentId value must not appear" - ); - assert!( - !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), - "providerBinaryPath must not appear" - ); - assert!( - !json.contains("SENTINEL_PROVIDER_BINARY"), - "providerBinaryPath value must not appear" - ); - assert!( - !json.contains("lastStartedAt") && !json.contains("last_started_at"), - "lastStartedAt must not appear" - ); - assert!( - !json.contains("lastExitCode") && !json.contains("last_exit_code"), - "lastExitCode must not appear" - ); - // backend blob — neither the type tag nor provider secret must leak. - assert!( - !json.contains("\"backend\"") && !json.contains("backend"), - "backend field must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), - "backend config values must not appear" - ); - // last_error / last_error_code - assert!( - !json.contains("lastError") && !json.contains("last_error"), - "lastError must not appear" - ); - assert!( - !json.contains("SENTINEL_LAST_ERROR"), - "lastError value must not appear" - ); - assert!( - !json.contains("lastErrorCode") && !json.contains("last_error_code"), - "lastErrorCode must not appear" - ); - } - - #[test] - fn secret_exclusion_lineage_ids_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("team-id-123"), - "source_team value must not appear" - ); - assert!( - !json.contains("sourceTeam") && !json.contains("source_team"), - "sourceTeam field must not appear" - ); - assert!( - !json.contains("sourceTeamPersonaSlug"), - "sourceTeamPersonaSlug must not appear" - ); - assert!( - !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), - "personaSourceVersion must not appear" - ); - // personaId - assert!( - !json.contains("personaId") && !json.contains("persona_id"), - "personaId field must not appear" - ); - assert!( - !json.contains("SENTINEL_PERSONA_ID"), - "personaId value must not appear" - ); - // teamId - assert!( - !json.contains("teamId") && !json.contains("team_id"), - "teamId field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_ID"), - "teamId value must not appear" - ); - // personaTeamDir - assert!( - !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), - "personaTeamDir field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_DIR"), - "personaTeamDir value must not appear" - ); - // personaNameInTeam - assert!( - !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), - "personaNameInTeam field must not appear" - ); - assert!( - !json.contains("SENTINEL_NAME_IN_TEAM"), - "personaNameInTeam value must not appear" - ); - } - - // ── Definition field presence tests ────────────────────────────────────── - - #[test] - fn definition_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - - assert_eq!(snapshot.definition.name, "Test Agent Display"); - assert!(!snapshot.definition.source_is_builtin); - assert_eq!( - snapshot.definition.system_prompt.as_deref(), - Some("You are a test agent.") - ); - assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); - assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); - assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); - assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); - // definition_respond_to maps to respond_to in the snapshot definition - assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); - // definition_respond_to_allowlist should be included - assert!(!snapshot.definition.respond_to_allowlist.is_empty()); - } - - #[test] - fn profile_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert_eq!(snapshot.profile.display_name, "Test Agent Display"); - // No bytes → should fall back to avatar_url - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/avatar.png") - ); - assert!(snapshot.profile.avatar_data_url.is_none()); - } - - #[test] - fn avatar_inlined_when_under_size_limit() { - let record = minimal_record(); - let small_png = make_png_with_text("k", "v").unwrap(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); - assert!(snapshot.profile.avatar_data_url.is_some()); - assert!(snapshot.profile.avatar_url.is_none()); - } - - #[test] - fn avatar_url_fallback_when_over_size_limit() { - let mut record = minimal_record(); - record.avatar_url = Some("https://example.com/big.png".to_string()); - // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. - let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); - assert!(snapshot.profile.avatar_data_url.is_none()); - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/big.png") - ); - } - - // ── Format/version validation ───────────────────────────────────────────── - - #[test] - fn invalid_format_discriminator_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.format = "not-a-buzz-snapshot".to_string(); - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot format")); - } - - #[test] - fn unsupported_version_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.version = 99; - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot version")); - } -} +#[path = "agent_snapshot_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs new file mode 100644 index 0000000000..8508c27073 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -0,0 +1,638 @@ +//! Locked (encrypted) agent-card envelope — NIP-44 v2 over the snapshot manifest. +//! +//! A locked card carries the same `buzz_agent_snapshot` tEXt chunk as a plain +//! card, but the chunk JSON is a typed outer envelope whose ciphertext +//! decrypts to the ordinary manifest. The NIP-44 v2 conversation key is +//! symmetric over the (owner, agent) pair, so BOTH the owner's and the +//! agent's nsec decrypt the card — nobody else's does (NIP-AE's scheme). +//! +//! Wire contract (agreed with Wren, buzz-agent-trading-cards thread): +//! - Plain cards keep today's exact bytes; detection dispatches once on the +//! exact `format` discriminator and rejects unknown versions/schemes +//! rather than falling through to manifest parsing. +//! - Key lookup is exact-endpoint only: the owner identity key when its +//! pubkey equals `ownerPubkey`, or a hydrated local managed-agent record +//! whose record pubkey AND derived-secret pubkey equal `agentPubkey`. +//! No trial decryption; anything else fails closed as locked. +//! - Caps beyond the outer 10 MiB PNG gate: 65,535-byte NIP-44 plaintext +//! limit on the serialized manifest BEFORE encryption; envelope JSON and +//! ciphertext are capped before serde/base64/decrypt work; decrypted bytes +//! are capped before snapshot parsing. +//! - Decrypt/auth failures return only the locked-card refusal — never +//! partial plaintext or crypto details. + +use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX; +use nostr::nips::nip44::{self, Version}; +use nostr::{Keys, PublicKey, SecretKey}; +use serde::{Deserialize, Serialize}; + +use super::agent_snapshot::{ + decode_snapshot_json, encode_chunk_payload_png, encode_snapshot_json, AgentSnapshot, + MemoryLevel, FORMAT_DISCRIMINATOR, +}; +use super::types::ManagedAgentRecord; + +/// Discriminator for the locked envelope. Distinct from the plain manifest's +/// `buzz-agent-snapshot` so detection never guesses. +pub const LOCKED_FORMAT: &str = "buzz-agent-snapshot-encrypted"; +/// Envelope schema version this module produces and accepts. +pub const LOCKED_VERSION: u32 = 1; +/// Encryption scheme identifier this module produces and accepts. +pub const LOCKED_SCHEME: &str = "nip44-v2"; + +/// A max-size NIP-44 v2 payload (1 version + 32 nonce + 2 len + 65,536 +/// padded + 32 MAC = 65,603 bytes) base64-encodes to 87,472 chars. +/// Anything larger is rejected before base64/decrypt work. +pub const MAX_LOCKED_CIPHERTEXT_BYTES: usize = 90_000; +/// Envelope JSON = ciphertext + two pubkeys + fixed keys. Rejected before +/// typed deserialization. +pub const MAX_LOCKED_ENVELOPE_JSON_BYTES: usize = MAX_LOCKED_CIPHERTEXT_BYTES + 1024; + +/// The only error a failed unlock may surface. Deliberately says nothing +/// about which key was tried or why decryption failed. +pub const LOCKED_CARD_REFUSAL: &str = + "This card is locked to its owner and agent. Only they can import it."; + +// ── Envelope types ──────────────────────────────────────────────────────────── + +/// Typed outer envelope stored (base64 JSON) in the `buzz_agent_snapshot` +/// chunk of a locked card. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedSnapshotEnvelope { + /// Always [`LOCKED_FORMAT`]. + pub format: String, + /// Always [`LOCKED_VERSION`]. + pub version: u32, + pub encryption: LockedEncryption, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedEncryption { + /// Always [`LOCKED_SCHEME`]. + pub scheme: String, + /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor + /// knows which counterparty to pair with. + pub owner_pubkey: String, + /// Agent instance pubkey (64 lowercase hex). + pub agent_pubkey: String, + /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON. + pub ciphertext: String, +} + +/// Result of parsing a chunk payload: either today's plain manifest or a +/// validated locked envelope. The plain manifest is boxed because it may +/// inline a multi-KB avatar data URL, dwarfing the envelope variant. +#[derive(Debug)] +pub enum ChunkPayload { + Plain(Box), + Locked(LockedSnapshotEnvelope), +} + +/// Minimal probe used to read the `format` discriminator without building a +/// full JSON tree for large plain manifests. +#[derive(Deserialize)] +struct FormatProbe { + #[serde(default)] + format: Option, +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a +/// valid x-only pubkey. Lowercase is required so string comparisons against +/// record pubkeys (always `to_hex()` output) stay sound. Curve validation is +/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers +/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would +/// otherwise pass structurally and fail only at decrypt time. +pub(crate) fn parse_canonical_pubkey(field: &str, value: &str) -> Result { + if value.len() != 64 + || !value + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)." + )); + } + let pubkey = PublicKey::from_hex(value) + .map_err(|_| format!("Locked card envelope has an invalid {field}."))?; + pubkey + .xonly() + .map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?; + Ok(pubkey) +} + +/// Structural validation of a locked envelope: exact version + scheme, +/// canonical pubkeys, distinct endpoints, bounded ciphertext. Does no +/// key lookup or crypto. +pub fn validate_envelope( + envelope: &LockedSnapshotEnvelope, +) -> Result<(PublicKey, PublicKey), String> { + if envelope.format != LOCKED_FORMAT { + return Err(format!( + "Unsupported locked card format: {:?} (expected {LOCKED_FORMAT:?})", + envelope.format + )); + } + if envelope.version != LOCKED_VERSION { + return Err(format!( + "Unsupported locked card envelope version: {} (expected {LOCKED_VERSION})", + envelope.version + )); + } + if envelope.encryption.scheme != LOCKED_SCHEME { + return Err(format!( + "Unsupported locked card encryption scheme: {:?} (expected {LOCKED_SCHEME:?})", + envelope.encryption.scheme + )); + } + let owner = parse_canonical_pubkey("ownerPubkey", &envelope.encryption.owner_pubkey)?; + let agent = parse_canonical_pubkey("agentPubkey", &envelope.encryption.agent_pubkey)?; + if owner == agent { + return Err("Locked card envelope owner and agent pubkeys must differ.".to_string()); + } + if envelope.encryption.ciphertext.len() > MAX_LOCKED_CIPHERTEXT_BYTES { + return Err("Locked card ciphertext exceeds the maximum size.".to_string()); + } + if envelope.encryption.ciphertext.is_empty() { + return Err("Locked card ciphertext is empty.".to_string()); + } + Ok((owner, agent)) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +/// Parse a raw chunk payload (JSON bytes from `extract_chunk_payload_png` or +/// an `.agent.json` file) and dispatch on the exact `format` discriminator. +/// +/// - `buzz-agent-snapshot` → full plain-manifest decode + validation. +/// - `buzz-agent-snapshot-encrypted` → size caps, typed envelope parse, +/// structural validation. No decryption happens here. +/// - anything else (including missing `format`) → error, never a fall-through. +pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result { + let probe: FormatProbe = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + match probe.format.as_deref() { + Some(f) if f == FORMAT_DISCRIMINATOR => Ok(ChunkPayload::Plain(Box::new( + decode_snapshot_json(json_bytes)?, + ))), + Some(f) if f == LOCKED_FORMAT => { + // Cap the envelope JSON before typed deserialization; a locked + // envelope is small by construction (unlike plain manifests, + // which may inline a multi-MB avatar). + if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES { + return Err("Locked card envelope exceeds the maximum size.".to_string()); + } + let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) + .map_err(|e| format!("Invalid locked card envelope: {e}"))?; + validate_envelope(&envelope)?; + Ok(ChunkPayload::Locked(envelope)) + } + Some(other) => Err(format!("Unsupported snapshot format: {other:?}")), + None => Err("Snapshot payload has no format discriminator.".to_string()), + } +} + +// ── Encrypt ─────────────────────────────────────────────────────────────────── + +/// Encrypt a snapshot manifest into a locked envelope under the NIP-44 v2 +/// conversation key for (owner secret, agent pubkey). +/// +/// Fails clearly (never silently truncates) when the serialized manifest +/// exceeds the NIP-44 plaintext limit. +pub fn encrypt_snapshot_envelope( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, +) -> Result { + let json_bytes = encode_snapshot_json(snapshot)?; + if json_bytes.len() > NIP44_PLAINTEXT_MAX { + return Err(format!( + "Agent manifest is too large to lock ({} bytes; the encrypted \ + format caps at {NIP44_PLAINTEXT_MAX}). Reduce the avatar size \ + or mint an unlocked card.", + json_bytes.len() + )); + } + let plaintext = std::str::from_utf8(&json_bytes) + .map_err(|e| format!("Manifest JSON was not UTF-8: {e}"))?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + agent_pubkey, + plaintext, + Version::V2, + ) + .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + + Ok(LockedSnapshotEnvelope { + format: LOCKED_FORMAT.to_string(), + version: LOCKED_VERSION, + encryption: LockedEncryption { + scheme: LOCKED_SCHEME.to_string(), + owner_pubkey: owner_keys.public_key().to_hex(), + agent_pubkey: agent_pubkey.to_hex(), + ciphertext, + }, + }) +} + +/// Encode a snapshot into a LOCKED `.agent.png`: encrypt the manifest into +/// the envelope, then compose the PNG through the same chunk encoder plain +/// cards use. Mirrors `encode_snapshot_png`'s structural memory guard. +pub fn encode_locked_snapshot_png( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Cannot write a snapshot with memory.level 'none' and non-empty memory entries." + .to_string(), + ); + } + let envelope = encrypt_snapshot_envelope(snapshot, owner_keys, agent_pubkey)?; + let envelope_json = serde_json::to_vec(&envelope) + .map_err(|e| format!("Failed to serialize locked card envelope: {e}"))?; + encode_chunk_payload_png(&envelope_json, avatar_bytes) +} + +// ── Decrypt ─────────────────────────────────────────────────────────────────── + +/// Exact-endpoint key resolution (no trial decryption): +/// - the owner identity secret, only when its pubkey equals `ownerPubkey`; +/// - a hydrated local managed-agent record whose record pubkey AND +/// derived-secret pubkey both equal `agentPubkey`. +/// +/// Returns `None` when neither exact endpoint exists — callers fail closed +/// with [`LOCKED_CARD_REFUSAL`]. +pub fn resolve_unlock_secret( + envelope: &LockedSnapshotEnvelope, + owner_keys: Option<&Keys>, + records: &[ManagedAgentRecord], +) -> Option { + if let Some(keys) = owner_keys { + if keys.public_key().to_hex() == envelope.encryption.owner_pubkey { + return Some(keys.secret_key().clone()); + } + } + let record = records + .iter() + .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?; + let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?; + if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey { + return None; + } + Some(agent_keys.secret_key().clone()) +} + +/// Decrypt a validated envelope with `my_secret`, which must be one of the +/// envelope's two exact endpoints (its derived pubkey selects the +/// counterparty). Returns the decoded, validated snapshot manifest. +/// +/// Every auth/crypto failure maps to [`LOCKED_CARD_REFUSAL`] — nothing about +/// the failure mode leaks. Manifest decode errors after a successful decrypt +/// are surfaced normally (the caller proved key possession). +pub fn decrypt_envelope( + envelope: &LockedSnapshotEnvelope, + my_secret: &SecretKey, +) -> Result { + let (owner_pub, agent_pub) = validate_envelope(envelope)?; + let my_pub = Keys::new(my_secret.clone()).public_key(); + let counterparty = if my_pub == owner_pub { + agent_pub + } else if my_pub == agent_pub { + owner_pub + } else { + return Err(LOCKED_CARD_REFUSAL.to_string()); + }; + + let plaintext = nip44::decrypt(my_secret, &counterparty, &envelope.encryption.ciphertext) + .map_err(|_| LOCKED_CARD_REFUSAL.to_string())?; + if plaintext.len() > NIP44_PLAINTEXT_MAX { + return Err(LOCKED_CARD_REFUSAL.to_string()); + } + decode_snapshot_json(plaintext.as_bytes()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::agent_snapshot::{ + extract_chunk_payload_png, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_VERSION, + }; + + fn sample_snapshot() -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Locked Test".to_string(), + system_prompt: Some("You are a locked test agent.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: Vec::new(), + name_pool: Vec::new(), + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + source_is_builtin: false, + }, + profile: AgentSnapshotProfile { + display_name: "Locked Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: Vec::new(), + }, + } + } + + fn owner_agent_keys() -> (Keys, Keys) { + (Keys::generate(), Keys::generate()) + } + + /// Minimal hydrated record for endpoint-resolution tests. Only the + /// pubkey/nsec pair matters here. + fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey, + name: "Locked Test".to_string(), + persona_id: None, + private_key_nsec, + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } + } + + fn locked_envelope() -> (LockedSnapshotEnvelope, Keys, Keys) { + let (owner, agent) = owner_agent_keys(); + let env = + encrypt_snapshot_envelope(&sample_snapshot(), &owner, &agent.public_key()).unwrap(); + (env, owner, agent) + } + + #[test] + fn owner_secret_decrypts() { + let (env, owner, _agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, owner.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn agent_secret_decrypts() { + let (env, _owner, agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, agent.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn unrelated_key_fails_closed_with_refusal_only() { + let (env, _owner, _agent) = locked_envelope(); + let stranger = Keys::generate(); + let err = decrypt_envelope(&env, stranger.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn tampered_ciphertext_fails_with_refusal_only() { + let (mut env, owner, _agent) = locked_envelope(); + // Flip a character mid-ciphertext (keep valid base64 alphabet). + let mid = env.encryption.ciphertext.len() / 2; + let mut bytes = env.encryption.ciphertext.into_bytes(); + bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' }; + env.encryption.ciphertext = String::from_utf8(bytes).unwrap(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn swapped_pubkeys_fail_closed_at_endpoint_resolution() { + let (mut env, owner, agent) = locked_envelope(); + std::mem::swap( + &mut env.encryption.owner_pubkey, + &mut env.encryption.agent_pubkey, + ); + // The NIP-44 conversation key is symmetric over the pair, so a swap + // cannot grant a stranger anything — but it desyncs the routing + // hints, and exact-endpoint resolution fails closed rather than + // guessing: the owner identity no longer matches `ownerPubkey`, and + // no local record holds the pubkey now in `agentPubkey`. + assert!(resolve_unlock_secret(&env, Some(&owner), &[]).is_none()); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + let record = record_with_keys(agent.public_key().to_hex(), nsec); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).is_none()); + } + + #[test] + fn mislabeled_pubkey_fails_decryption_with_refusal_only() { + // Replacing `agentPubkey` with a third party's key makes the owner + // derive the wrong conversation key — the NIP-44 MAC fails and only + // the refusal surfaces. + let (mut env, owner, _agent) = locked_envelope(); + env.encryption.agent_pubkey = Keys::generate().public_key().to_hex(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn malformed_pubkeys_rejected_structurally() { + let (env, _owner, _agent) = locked_envelope(); + + let mut short = env.clone(); + short.encryption.owner_pubkey = "abc123".to_string(); + assert!(validate_envelope(&short).unwrap_err().contains("malformed")); + + let mut upper = env.clone(); + upper.encryption.agent_pubkey = upper.encryption.agent_pubkey.to_uppercase(); + assert!(validate_envelope(&upper).unwrap_err().contains("malformed")); + + // A 64-hex string that is not a curve point (lift-x fails for + // x = p-1... all-f) must be rejected STRUCTURALLY — before any key + // lookup or decrypt work — per the wire contract. + let mut not_a_point = env.clone(); + not_a_point.encryption.agent_pubkey = "f".repeat(64); + assert!(validate_envelope(¬_a_point) + .unwrap_err() + .contains("not a curve point")); + + let mut same = env; + same.encryption.agent_pubkey = same.encryption.owner_pubkey.clone(); + assert!(validate_envelope(&same).unwrap_err().contains("differ")); + } + + #[test] + fn unknown_format_version_scheme_rejected() { + let (env, ..) = locked_envelope(); + + let mut bad_version = env.clone(); + bad_version.version = 2; + assert!(validate_envelope(&bad_version) + .unwrap_err() + .contains("version")); + + let mut bad_scheme = env.clone(); + bad_scheme.encryption.scheme = "nip44-v3".to_string(); + assert!(validate_envelope(&bad_scheme) + .unwrap_err() + .contains("scheme")); + + // Unknown top-level format never falls through to manifest parsing. + let unknown = serde_json::json!({"format": "buzz-agent-snapshot-v9", "version": 1}); + let err = parse_chunk_payload(unknown.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("Unsupported snapshot format"), "{err}"); + + let missing = serde_json::json!({"version": 1}); + let err = parse_chunk_payload(missing.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("no format discriminator"), "{err}"); + } + + #[test] + fn plaintext_cap_enforced_before_encryption() { + let (owner, agent) = owner_agent_keys(); + let mut snapshot = sample_snapshot(); + // Inflate the manifest beyond the NIP-44 plaintext limit. + snapshot.definition.system_prompt = Some("x".repeat(NIP44_PLAINTEXT_MAX)); + let err = encrypt_snapshot_envelope(&snapshot, &owner, &agent.public_key()).unwrap_err(); + assert!(err.contains("too large to lock"), "{err}"); + } + + #[test] + fn ciphertext_and_envelope_caps_enforced_before_crypto() { + let (mut env, ..) = locked_envelope(); + env.encryption.ciphertext = "A".repeat(MAX_LOCKED_CIPHERTEXT_BYTES + 1); + assert!(validate_envelope(&env) + .unwrap_err() + .contains("maximum size")); + + // Oversized envelope JSON is rejected before typed deserialization. + let huge = format!( + r#"{{"format":"{LOCKED_FORMAT}","version":1,"pad":"{}","encryption":{{}}}}"#, + "p".repeat(MAX_LOCKED_ENVELOPE_JSON_BYTES) + ); + let err = parse_chunk_payload(huge.as_bytes()).unwrap_err(); + assert!(err.contains("maximum size"), "{err}"); + } + + #[test] + fn locked_png_round_trips_through_chunk_and_decrypt() { + let (owner, agent) = owner_agent_keys(); + let snapshot = sample_snapshot(); + let png = encode_locked_snapshot_png(&snapshot, &owner, &agent.public_key(), None).unwrap(); + + let payload = extract_chunk_payload_png(&png).unwrap(); + let ChunkPayload::Locked(env) = parse_chunk_payload(&payload).unwrap() else { + panic!("locked PNG must parse as a locked envelope"); + }; + // Both endpoints decrypt to the same logical manifest (compare + // manifests, never ciphertext — the NIP-44 nonce is random). + assert_eq!( + decrypt_envelope(&env, owner.secret_key()).unwrap(), + snapshot + ); + assert_eq!( + decrypt_envelope(&env, agent.secret_key()).unwrap(), + snapshot + ); + } + + #[test] + fn plain_manifest_dispatches_to_plain() { + let json = encode_snapshot_json(&sample_snapshot()).unwrap(); + let ChunkPayload::Plain(decoded) = parse_chunk_payload(&json).unwrap() else { + panic!("plain manifest must parse as Plain"); + }; + assert_eq!(*decoded, sample_snapshot()); + } + + #[test] + fn resolve_unlock_secret_owner_exact_endpoint() { + let (env, owner, _agent) = locked_envelope(); + let secret = resolve_unlock_secret(&env, Some(&owner), &[]).unwrap(); + assert_eq!(&secret, owner.secret_key()); + + // A different identity key is NOT tried. + let other = Keys::generate(); + assert!(resolve_unlock_secret(&env, Some(&other), &[]).is_none()); + assert!(resolve_unlock_secret(&env, None, &[]).is_none()); + } + + #[test] + fn resolve_unlock_secret_agent_requires_record_and_derived_match() { + let (env, _owner, agent) = locked_envelope(); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + + let record = record_with_keys(agent.public_key().to_hex(), nsec); + let secret = resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).unwrap(); + assert_eq!(&secret, agent.secret_key()); + + // Record pubkey matches but the stored secret derives a DIFFERENT + // pubkey → refused (no trial decryption on mismatched material). + let mut forged = record.clone(); + forged.private_key_nsec = + nostr::ToBech32::to_bech32(Keys::generate().secret_key()).unwrap(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&forged)).is_none()); + + // Record for some other agent → not an endpoint. + let mut unrelated = record; + unrelated.pubkey = Keys::generate().public_key().to_hex(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&unrelated)).is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs new file mode 100644 index 0000000000..b4492418e5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -0,0 +1,599 @@ +//! Unit tests for `managed_agents/agent_snapshot.rs`. +//! +//! Kept in a sibling file so `agent_snapshot.rs` stays under the +//! 1000-line gate; `#[path]`-included from there. + +use super::*; +use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; +use std::collections::BTreeMap; + +/// Build a minimal `ManagedAgentRecord` for testing. Only the fields +/// relevant to snapshot export are filled; the rest use defaults. +fn minimal_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "deadbeef".to_string(), + name: "Test Agent".to_string(), + display_name: Some("Test Agent Display".to_string()), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot + team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + avatar_url: Some("https://example.com/avatar.png".to_string()), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot + agent_command: "goose".to_string(), // MUST NOT appear in snapshot + agent_command_override: Some("goose-override".to_string()), // MUST NOT appear + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 2, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("v1.0".to_string()), // MUST NOT appear + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(12345), // MUST NOT appear + backend: BackendKind::Provider { + // MUST NOT appear — carries a provider secret + id: "SENTINEL_BACKEND_ID".to_string(), + config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), + }, + backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear + persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear + persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear + last_stopped_at: None, + last_exit_code: Some(0), // MUST NOT appear + last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear + last_error_code: Some(42), // MUST NOT appear + respond_to: RespondTo::default(), + respond_to_allowlist: vec!["pubkey1hex".to_string()], + slug: Some("test-agent".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["Alice".to_string(), "Bob".to_string()], + is_builtin: false, + is_active: true, + shared: false, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + catalog_source: None, + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_parallelism: Some(4), + relay_mesh: None, + } +} + +// ── Round-trip tests ────────────────────────────────────────────────────── + +#[test] +fn json_round_trip_config_only() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn json_round_trip_with_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research notes.".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn png_round_trip_no_memory() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); + assert_eq!(parsed.memory.level, MemoryLevel::None); +} + +#[test] +fn png_round_trip_with_avatar_png() { + // Build a minimal PNG avatar. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + // Avatar should be inlined as a data URL. + assert!(snapshot + .profile + .avatar_data_url + .as_deref() + .unwrap_or("") + .starts_with("data:image/png;base64,")); + + let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); +} + +/// Plain-card byte compatibility: `encode_snapshot_png` was refactored +/// through the shared `encode_chunk_payload_png` when locked cards were +/// added. Plain cards must emit byte-identical PNGs to the pre-envelope +/// encoder. This vector reimplements the legacy encoder body verbatim and +/// asserts equality on all three composition paths: placeholder (no avatar), +/// PNG-avatar (where tEXt chunk injection ordering matters), and +/// JPEG-avatar transcode. +#[test] +fn plain_encoder_bytes_identical_to_pre_envelope_encoder() { + // Verbatim pre-refactor `encode_snapshot_png` body (post memory guard). + fn legacy_encode( + snapshot: &AgentSnapshot, + avatar_bytes: Option<&[u8]>, + ) -> Result, String> { + let json_bytes = encode_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { + Some(bytes) => { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }) + } else { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }; + match encoded_avatar { + Ok(png_bytes) => png_bytes, + Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + } + } + None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + }; + Ok(png_bytes) + } + + let record = minimal_record(); + + // Placeholder path (no avatar). + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + encode_snapshot_png(&snapshot, None).unwrap(), + legacy_encode(&snapshot, None).unwrap(), + "placeholder-path plain PNG bytes must match the pre-envelope encoder" + ); + + // PNG-avatar path: chunk injected into the avatar image body. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(), + legacy_encode(&snapshot, Some(&avatar)).unwrap(), + "avatar-path plain PNG bytes must match the pre-envelope encoder" + ); + + // JPEG-avatar path: transcode-to-PNG composition. + let jpeg_avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 4, + 4, + image::Rgb([0x10, 0x20, 0x30]), + )); + let mut jpeg_bytes = Vec::new(); + jpeg_avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&jpeg_bytes)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(), + legacy_encode(&snapshot, Some(&jpeg_bytes)).unwrap(), + "transcode-path plain PNG bytes must match the pre-envelope encoder" + ); +} + +#[test] +fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut jpeg_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&jpeg_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); +} + +// ── PNG memory parity ───────────────────────────────────────────────────── + +#[test] +fn png_round_trip_with_core_memory() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_round_trip_with_everything_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_export_with_no_memory_succeeds() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert!(encode_snapshot_png(&snapshot, None).is_ok()); +} + +#[test] +fn png_export_rejects_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries is non-empty. + // The encoder must reject this to prevent a memory-leak bypass. + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked memory".to_string(), + }]; + // Build with entries, then override level to None in the struct. + let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + snapshot.memory.level = MemoryLevel::None; // force inconsistency + let result = encode_snapshot_png(&snapshot, None); + assert!( + result.is_err(), + "PNG encoder must reject level=None with non-empty entries" + ); + assert!( + result + .unwrap_err() + .contains("memory.level 'none' and non-empty memory entries"), + "Error must explain the malformed memory state" + ); +} + +// ── Secret exclusion tests ──────────────────────────────────────────────── +// +// These tests assert that every field in the exclusion list is absent from +// the serialized snapshot. We serialize to JSON and assert the key is NOT +// present. + +fn snapshot_json_string(record: &ManagedAgentRecord) -> String { + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + String::from_utf8(bytes).unwrap() +} + +#[test] +fn secret_exclusion_private_key_nsec_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("nsec1secret"), + "nsec must not appear in snapshot" + ); + assert!( + !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), + "privateKeyNsec field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_auth_tag_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear in snapshot" + ); + assert!( + !json.contains("authTag") && !json.contains("auth_tag"), + "authTag field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_env_vars_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("API_KEY") && !json.contains("secret123"), + "env_vars content must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_relay_url_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url value must not appear in snapshot" + ); + assert!( + !json.contains("relayUrl") && !json.contains("relay_url"), + "relayUrl field must not appear in snapshot" + ); +} + +#[test] +fn snapshot_omits_removed_mcp_toolsets_config() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), + "removed MCP toolsets config must not re-enter snapshots" + ); +} + +#[test] +fn secret_exclusion_machine_commands_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + // acp_command / agent_command / agent_command_override / agent_args / mcp_command + assert!( + !json.contains("/usr/local/bin/acp"), + "acp_command path must not appear" + ); + assert!( + !json.contains("acpCommand") && !json.contains("acp_command"), + "acpCommand field must not appear" + ); + assert!( + !json.contains("agentCommand") && !json.contains("agent_command"), + "agentCommand field must not appear" + ); + assert!( + !json.contains("mcpCommand") && !json.contains("mcp_command"), + "mcpCommand field must not appear" + ); +} + +#[test] +fn secret_exclusion_runtime_state_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("runtimePid") && !json.contains("runtime_pid"), + "runtimePid must not appear" + ); + assert!( + !json.contains("backendAgentId") && !json.contains("backend_agent_id"), + "backendAgentId must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_AGENT_ID"), + "backendAgentId value must not appear" + ); + assert!( + !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), + "providerBinaryPath must not appear" + ); + assert!( + !json.contains("SENTINEL_PROVIDER_BINARY"), + "providerBinaryPath value must not appear" + ); + assert!( + !json.contains("lastStartedAt") && !json.contains("last_started_at"), + "lastStartedAt must not appear" + ); + assert!( + !json.contains("lastExitCode") && !json.contains("last_exit_code"), + "lastExitCode must not appear" + ); + // backend blob — neither the type tag nor provider secret must leak. + assert!( + !json.contains("\"backend\"") && !json.contains("backend"), + "backend field must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), + "backend config values must not appear" + ); + // last_error / last_error_code + assert!( + !json.contains("lastError") && !json.contains("last_error"), + "lastError must not appear" + ); + assert!( + !json.contains("SENTINEL_LAST_ERROR"), + "lastError value must not appear" + ); + assert!( + !json.contains("lastErrorCode") && !json.contains("last_error_code"), + "lastErrorCode must not appear" + ); +} + +#[test] +fn secret_exclusion_lineage_ids_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("team-id-123"), + "source_team value must not appear" + ); + assert!( + !json.contains("sourceTeam") && !json.contains("source_team"), + "sourceTeam field must not appear" + ); + assert!( + !json.contains("sourceTeamPersonaSlug"), + "sourceTeamPersonaSlug must not appear" + ); + assert!( + !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), + "personaSourceVersion must not appear" + ); + // personaId + assert!( + !json.contains("personaId") && !json.contains("persona_id"), + "personaId field must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "personaId value must not appear" + ); + // teamId + assert!( + !json.contains("teamId") && !json.contains("team_id"), + "teamId field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_ID"), + "teamId value must not appear" + ); + // personaTeamDir + assert!( + !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), + "personaTeamDir field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_DIR"), + "personaTeamDir value must not appear" + ); + // personaNameInTeam + assert!( + !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), + "personaNameInTeam field must not appear" + ); + assert!( + !json.contains("SENTINEL_NAME_IN_TEAM"), + "personaNameInTeam value must not appear" + ); +} + +// ── Definition field presence tests ────────────────────────────────────── + +#[test] +fn definition_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + + assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); +} + +#[test] +fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); +} + +#[test] +fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); +} + +#[test] +fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); +} + +// ── Format/version validation ───────────────────────────────────────────── + +#[test] +fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); +} + +#[test] +fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 72c4657dc7..3622b21c4a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -106,7 +106,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ label: "Oh My Pi", command: "omp", args: &["acp"], - install_instructions_url: "https://github.com/can1357/oh-my-pi", + install_instructions_url: "https://omp.sh/", install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).", underlying_cli: None, }, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..772d707f27 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 89ca6396e9..7933fd291e 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -149,8 +149,25 @@ fn should_evict_after_probe( probe: MeshIngressProbe, consecutive: u32, ) -> bool { - urgency == MeshRecoveryUrgency::Foreground && probe == MeshIngressProbe::PortClosed - || consecutive >= DEAD_PROBE_EVICT_THRESHOLD + // Only a CLOSED port is evidence of death. A bound-but-HTTP-unresponsive + // port ("Unhealthy") is a BUSY node, not a dead one: mesh serializes all + // HTTP on the ingress — including the `/v1/models` liveness probe — behind + // in-flight inference, so a large-prompt turn on a big model leaves the + // control plane unresponsive for the whole turn (measured ~27s on a + // gemma-4-26B node) while TCP-connect keeps answering in ~0ms. Model load + // and package-layer download are unresponsive in exactly the same way. + // Evicting on any of those turns ordinary backpressure into a destructive + // whole-app restart loop, which is the regression this fixes. A genuinely + // wedged bound port cannot be distinguished from a busy one without a + // lock-free health endpoint on the ingress (tracked upstream in mesh-llm); + // until that exists we never evict a bound port and rely solely on the + // unambiguous closed-port signal. + match probe { + MeshIngressProbe::Live | MeshIngressProbe::Unhealthy => false, + MeshIngressProbe::PortClosed => { + urgency == MeshRecoveryUrgency::Foreground || consecutive >= DEAD_PROBE_EVICT_THRESHOLD + } + } } fn requires_process_restart( @@ -160,10 +177,11 @@ fn requires_process_restart( startup_in_progress || mode == crate::mesh_llm::MeshNodeMode::Serve } -/// Probe and, when justified, remove one stale runtime. A closed port is -/// decisive for a foreground agent start; watchdog and ambiguous/unhealthy -/// ports require consecutive failures to avoid restarting on a transient load -/// spike. +/// Probe and, when justified, remove one stale runtime. Only a CLOSED port is +/// treated as death: a foreground agent start evicts immediately, the watchdog +/// after a short consecutive-failure streak. A bound-but-unresponsive +/// ("Unhealthy") port is never evicted — it is a busy or still-loading node, +/// not a dead one (see `should_evict_after_probe`). pub(crate) async fn recover_stale_mesh_runtime( state: &AppState, urgency: MeshRecoveryUrgency, @@ -490,6 +508,89 @@ mod tests { )); } + #[test] + fn watchdog_closed_port_still_evicts_after_consecutive_streak() { + // A genuinely dead listener (crashed / released its port) must still be + // reclaimed — the closed-port signal is unchanged by this fix. + assert!(!should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + 1 + )); + assert!(should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + DEAD_PROBE_EVICT_THRESHOLD + )); + } + + #[test] + fn busy_or_loading_bound_port_is_never_evicted() { + // The regression this fixes: mesh serializes all ingress HTTP (incl. + // the `/v1/models` liveness probe) behind in-flight inference, so a + // large-prompt turn, a model load, or a layer download leaves the port + // bound-but-unresponsive ("Unhealthy"). No probe streak, and no + // urgency, may evict such a node — doing so restarts a node that is + // alive and working. + for consecutive in [1, 2, 5, 100] { + for urgency in [ + MeshRecoveryUrgency::Watchdog, + MeshRecoveryUrgency::Foreground, + ] { + assert!( + !should_evict_after_probe(urgency, MeshIngressProbe::Unhealthy, consecutive), + "a bound-but-busy port must never evict (urgency={urgency:?}, \ + consecutive={consecutive})" + ); + } + } + } + + #[test] + fn long_model_load_never_reaches_the_restart_path() { + // Pins the exact false positive this fix removes. A big model stays + // bound-but-unresponsive for MINUTES while it loads weights and + // downloads package layers, so the watchdog sees an unbroken run of + // `Unhealthy` probes. Walk ~5 minutes of watchdog passes at its 15s + // base interval and assert the eviction gate stays shut the whole way + // — for a serve node, one `true` here is a whole-app restart. + let state = MeshRecoveryState::default(); + let runtime_id = 42; + let passes = (5 * 60) / 15; + + for pass in 1..=passes { + let consecutive = state.record_dead_probe(runtime_id); + // The streak really does climb — the non-eviction below is the + // rule refusing to act, not the counter quietly resetting. + assert_eq!( + consecutive, pass, + "probe streak should keep climbing across a long load" + ); + for urgency in [ + MeshRecoveryUrgency::Watchdog, + MeshRecoveryUrgency::Foreground, + ] { + assert!( + !should_evict_after_probe(urgency, MeshIngressProbe::Unhealthy, consecutive), + "a still-loading node must never be evicted \ + (urgency={urgency:?}, minute={}, streak={consecutive})", + pass * 15 / 60 + ); + } + } + + // Sanity: the streak blew far past the threshold that used to evict, + // so the old logic WOULD have restarted this healthy loading node. + assert!( + passes >= DEAD_PROBE_EVICT_THRESHOLD, + "test must exceed the old eviction threshold to be meaningful" + ); + + // Once the load finishes and the ingress answers, the streak clears. + state.reset_probe_streak(); + assert_eq!(state.record_dead_probe(runtime_id), 1); + } + #[test] fn probe_streak_is_scoped_to_runtime_identity() { let state = MeshRecoveryState::default(); @@ -570,4 +671,39 @@ mod tests { ); assert!(!"user note: shared compute config".starts_with(MESH_REARM_ERROR_SENTINEL)); } + + // Black-box proof of the classification the eviction rule stands on. A + // mesh node busy in inference (or loading, or downloading) keeps its + // ingress TCP port accepting connections in ~0ms while HTTP does not answer + // within the probe timeout — measured directly against a gemma-4-26B node: + // a concurrent `/v1/models` took ~27s, queued behind one in-flight turn. + // This stands up exactly that shape — a listener that accepts then never + // replies — and asserts the probe reads `Unhealthy` (busy), the verdict + // `should_evict_after_probe` now refuses to evict on. If this regressed to + // `PortClosed`, a busy node would again be misread as dead and restarted. + #[tokio::test] + async fn bound_but_stalled_http_classifies_as_unhealthy_not_closed() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().unwrap().port(); + // Accept and hold connections open without ever writing a response — + // the wire-level equivalent of a node serializing HTTP behind a turn. + let accept_task = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + held.push(stream); // keep the socket open, never respond + } + }); + + let probe = probe_mesh_ingress_at(&format!("http://127.0.0.1:{port}/v1")).await; + accept_task.abort(); + + assert_eq!( + probe, + MeshIngressProbe::Unhealthy, + "a TCP-bound port that stalls HTTP (a busy/loading node) must read \ + Unhealthy, never PortClosed — the eviction fix depends on this" + ); + } } diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 6292a3cbad..5c1a3f78f1 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -130,8 +130,8 @@ async fn open_connection( // client. Every relay session — community add, stored communities, deep // links, the read-only observer, reconnects — funnels through here, so this // is the one place a host restriction cannot be bypassed from the UI. - // See relay_allowlist.rs for why it is a config lock, not a security control. - crate::relay_allowlist::ensure_relay_allowed(url)?; + // See relay/allowlist.rs for why it is a config lock, not a security control. + crate::relay::allowlist::ensure_relay_allowed(url)?; let connect_cancel = manager.connect_cancel.lock().await.clone(); let (socket, _) = tokio::select! { diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 49d17942b5..99345627f7 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String { // defaults to ws://localhost:3000, which the allowlist then rejects, // producing a client that cannot connect at all. Returns None in debug so // local development keeps the loopback default below. - .or_else(crate::relay_allowlist::default_relay_url) + .or_else(allowlist::default_relay_url) .unwrap_or_else(|| DEFAULT_RELAY_WS_URL.to_string()) } @@ -538,6 +538,14 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── +// FORK-LOCAL PATCH (adrienlacombe/buzz): single-relay host allowlist, declared +// as a submodule of `relay` rather than at the crate root. Upstream's lib.rs +// sits at exactly the 1000-line desktop file-size ratchet limit, so a fork-local +// `mod` line there fails `just desktop-check` the moment upstream adds anything. +// Keeping the declaration here costs lib.rs nothing and removes a permanent +// conflict site from its sorted module list. +pub mod allowlist; + mod submit; pub use submit::{ submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, diff --git a/desktop/src-tauri/src/relay_allowlist.rs b/desktop/src-tauri/src/relay/allowlist.rs similarity index 100% rename from desktop/src-tauri/src/relay_allowlist.rs rename to desktop/src-tauri/src/relay/allowlist.rs diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a0fa04f392..de5d94ec23 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BitcoinMarkets", - "version": "0.5.2", + "version": "0.5.3", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs new file mode 100644 index 0000000000..eb9c0f07b0 --- /dev/null +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +// cardMintStore drives the non-blocking mint flow: the dialog dispatches a +// job and closes; the composer chip, completion toast, and viewer all read +// this store. These tests exercise the job lifecycle with an injected mintFn +// (never the real Tauri command). +// +// The store imports sonner (toast); calling toast outside a mounted +// only queues, so no DOM is required. + +import { + dismissCardMintJob, + getCardGalleryOpen, + getCardMintJobs, + getCardViewerState, + closeCardViewer, + openCardViewer, + resetCardMintStore, + runCardMintJob, + setCardGalleryOpen, + subscribeCardMintStore, + viewMintedCardJob, +} from "./cardMintStore.ts"; + +const CARD = { + cardPngBase64: "aGVsbG8=", + fileName: "eva.agent.png", + designerNotes: "notes", + locked: false, + memoryLevel: "none", +}; + +const INPUT = { agentId: "agent-1", agentName: "Eva" }; + +describe("cardMintStore", () => { + beforeEach(() => { + resetCardMintStore(); + }); + + it("forwards the mint input — including memoryLevel — to mintFn", async () => { + const seen = []; + await runCardMintJob( + { ...INPUT, styleNotes: "stormy", lock: true, memoryLevel: "core" }, + (...args) => { + seen.push(args); + return Promise.resolve({ ...CARD, memoryLevel: "core" }); + }, + ); + assert.deepEqual(seen, [["agent-1", "stormy", true, "core"]]); + + // Omitted memoryLevel stays undefined so Rust applies its "none" default. + await runCardMintJob(INPUT, (...args) => { + seen.push(args); + return Promise.resolve(CARD); + }); + assert.deepEqual(seen[1], ["agent-1", undefined, undefined, undefined]); + }); + + it("tracks a successful mint through minting → done", async () => { + let resolveMint; + const pending = new Promise((resolve) => { + resolveMint = resolve; + }); + const run = runCardMintJob(INPUT, () => pending); + + let jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "minting"); + assert.equal(jobs[0].input.agentName, "Eva"); + assert.equal(jobs[0].card, null); + + resolveMint(CARD); + await run; + + jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "done"); + assert.deepEqual(jobs[0].card, CARD); + assert.equal(jobs[0].error, null); + }); + + it("records the error message on a failed mint", async () => { + await runCardMintJob(INPUT, () => Promise.reject(new Error("boom"))); + const jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "error"); + assert.equal(jobs[0].error, "boom"); + assert.equal(jobs[0].card, null); + }); + + it("strips the NO_OPENAI_KEY wire prefix from mint errors", async () => { + await runCardMintJob(INPUT, () => + Promise.reject(new Error("NO_OPENAI_KEY: No OPENAI_API_KEY found.")), + ); + assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found."); + }); + + it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => { + await runCardMintJob(INPUT, () => Promise.resolve(CARD)); + const jobId = getCardMintJobs()[0].jobId; + + viewMintedCardJob(jobId); + + assert.equal(getCardMintJobs().length, 0); + const viewer = getCardViewerState(); + assert.ok(viewer); + assert.equal(viewer.agentName, "Eva"); + assert.deepEqual(viewer.card, CARD); + // Fresh mints keep their input so the viewer can reroll. + assert.deepEqual(viewer.remint, INPUT); + }); + + it("viewMintedCardJob ignores jobs that are still minting", () => { + let resolveMint; + void runCardMintJob( + INPUT, + () => new Promise((resolve) => (resolveMint = resolve)), + ); + const jobId = getCardMintJobs()[0].jobId; + + viewMintedCardJob(jobId); + + assert.equal(getCardMintJobs().length, 1); + assert.equal(getCardViewerState(), null); + resolveMint(CARD); // avoid a dangling promise + }); + + it("dismissCardMintJob removes only the named job", async () => { + await runCardMintJob(INPUT, () => Promise.reject(new Error("a"))); + await runCardMintJob({ agentId: "agent-2", agentName: "Wren" }, () => + Promise.reject(new Error("b")), + ); + const [first, second] = getCardMintJobs(); + + dismissCardMintJob(first.jobId); + + const jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].jobId, second.jobId); + }); + + it("openCardViewer/closeCardViewer manage archive views without remint", () => { + openCardViewer({ card: CARD, agentName: "Eva", remint: null }); + assert.equal(getCardViewerState()?.remint, null); + closeCardViewer(); + assert.equal(getCardViewerState(), null); + }); + + it("gallery open flag toggles and notifies subscribers", () => { + let notified = 0; + const unsubscribe = subscribeCardMintStore(() => { + notified += 1; + }); + setCardGalleryOpen(true); + assert.equal(getCardGalleryOpen(), true); + setCardGalleryOpen(true); // no-op must not notify + setCardGalleryOpen(false); + assert.equal(getCardGalleryOpen(), false); + assert.equal(notified, 2); + unsubscribe(); + }); + + it("concurrent jobs keep distinct snapshots (referential updates)", async () => { + const before = getCardMintJobs(); + await runCardMintJob(INPUT, () => Promise.resolve(CARD)); + const after = getCardMintJobs(); + assert.notEqual(before, after, "snapshot identity must change on update"); + }); +}); diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts new file mode 100644 index 0000000000..0e4746d445 --- /dev/null +++ b/desktop/src/features/agents/cardMintStore.ts @@ -0,0 +1,227 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + mintAgentCard, + NO_OPENAI_KEY_PREFIX, + type MintedAgentCard, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; + +/** + * Module store for agent-card mints (`useSyncExternalStore` pattern, same as + * `avatarPresentationStore`). + * + * A mint is one stateless ~2–3 minute Rust call. Owning the in-flight promise + * here — instead of inside the mint dialog — is what makes the dialog + * non-blocking: it dispatches and closes, the composer activity rail shows a + * live "Minting card…" chip, and completion lands as a clickable toast plus a + * persistent "card ready" chip, none of which need the dialog mounted. + */ + +/** Everything needed to run (or re-run) one mint. */ +export type CardMintInput = { + agentId: string; + agentName: string; + styleNotes?: string; + lock?: boolean; + /** Memory to embed in the card's snapshot. Omitted = "none". */ + memoryLevel?: SnapshotMemoryLevel; +}; + +export type CardMintJob = { + jobId: string; + input: CardMintInput; + phase: "minting" | "done" | "error"; + /** Populated when phase is "done". */ + card: MintedAgentCard | null; + /** Populated when phase is "error". */ + error: string | null; + startedAt: number; +}; + +/** Card content shown by the global viewer dialog. */ +export type CardViewerState = { + card: MintedAgentCard; + agentName: string; + /** + * Present when the card can be rerolled (fresh mints carry their input; + * archive views do not — the original style notes are gone). + */ + remint: CardMintInput | null; + /** + * Monotonic per-open sequence, assigned by the store. The viewer keys its + * content on this so switching cards remounts (resetting recipients/menu + * state) — card bytes can't serve as the key because every card PNG shares + * the same header prefix and dimensions. + */ + viewerSeq: number; +}; + +let jobs: CardMintJob[] = []; +let viewer: CardViewerState | null = null; +let galleryOpen = false; +const listeners = new Set<() => void>(); +let nextJobId = 1; +let nextViewerSeq = 1; + +function emitChange(): void { + for (const listener of listeners) listener(); +} + +function updateJob(jobId: string, patch: Partial): void { + jobs = jobs.map((job) => (job.jobId === jobId ? { ...job, ...patch } : job)); + emitChange(); +} + +/** + * Run one mint as a background job. `mintFn` is injectable for tests; the + * public `startCardMint` binds the real Tauri command. + */ +export async function runCardMintJob( + input: CardMintInput, + mintFn: ( + id: string, + styleNotes?: string, + lock?: boolean, + memoryLevel?: SnapshotMemoryLevel, + ) => Promise, +): Promise { + const jobId = `card-mint-${nextJobId++}`; + jobs = [ + ...jobs, + { + jobId, + input, + phase: "minting", + card: null, + error: null, + startedAt: Date.now(), + }, + ]; + emitChange(); + + try { + const card = await mintFn( + input.agentId, + input.styleNotes, + input.lock, + input.memoryLevel, + ); + updateJob(jobId, { phase: "done", card }); + toast.success(`${input.agentName}'s card is ready`, { + action: { + label: "View card", + onClick: () => viewMintedCardJob(jobId), + }, + duration: 10_000, + }); + } catch (error) { + let message = error instanceof Error ? error.message : "Card mint failed."; + if (message.startsWith(NO_OPENAI_KEY_PREFIX)) { + // The dialog pre-checks the key, so this only happens when the key was + // removed between dialog-open and mint. The dialog's key-setup panel is + // long gone — surface a plain instruction instead of the wire prefix. + message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim(); + } + updateJob(jobId, { phase: "error", error: message }); + toast.error(`Minting ${input.agentName}'s card failed`, { + description: message, + }); + } +} + +/** Start a mint in the background. Fire-and-forget; state flows via the store. */ +export function startCardMint(input: CardMintInput): void { + void runCardMintJob(input, mintAgentCard); +} + +/** Open the finished card of a job in the viewer and clear its rail chip. */ +export function viewMintedCardJob(jobId: string): void { + const job = jobs.find((candidate) => candidate.jobId === jobId); + if (job?.phase !== "done" || !job.card) return; + viewer = { + card: job.card, + agentName: job.input.agentName, + remint: job.input, + viewerSeq: nextViewerSeq++, + }; + jobs = jobs.filter((candidate) => candidate.jobId !== jobId); + emitChange(); +} + +/** Remove a job chip (used for error dismissal). */ +export function dismissCardMintJob(jobId: string): void { + const next = jobs.filter((candidate) => candidate.jobId !== jobId); + if (next.length === jobs.length) return; + jobs = next; + emitChange(); +} + +/** Open the viewer on an arbitrary card (e.g. one loaded from the archive). */ +export function openCardViewer( + state: Omit, +): void { + viewer = { ...state, viewerSeq: nextViewerSeq++ }; + emitChange(); +} + +export function closeCardViewer(): void { + if (!viewer) return; + viewer = null; + emitChange(); +} + +export function setCardGalleryOpen(open: boolean): void { + if (galleryOpen === open) return; + galleryOpen = open; + emitChange(); +} + +export function resetCardMintStore(): void { + jobs = []; + viewer = null; + galleryOpen = false; + emitChange(); +} + +export function getCardMintJobs(): CardMintJob[] { + return jobs; +} + +export function getCardViewerState(): CardViewerState | null { + return viewer; +} + +export function getCardGalleryOpen(): boolean { + return galleryOpen; +} + +export function subscribeCardMintStore(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useCardMintJobs(): CardMintJob[] { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardMintJobs, + getCardMintJobs, + ); +} + +export function useCardViewerState(): CardViewerState | null { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardViewerState, + getCardViewerState, + ); +} + +export function useCardGalleryOpen(): boolean { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardGalleryOpen, + getCardGalleryOpen, + ); +} diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs new file mode 100644 index 0000000000..ebc7caa8f4 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Pins the gallery's query-state boundary: a rejected `list_agent_cards` +// MUST surface as an error state — never as "No cards yet". `data` falls +// back to `[]` at the call site, so an emptiness-first branch would render +// a permissions/IPC failure as a false empty archive of paid cards. + +import { agentCardGalleryViewState } from "./agentCardGalleryState.ts"; + +describe("agentCardGalleryViewState", () => { + it("maps a rejected query to an error state with the message, not empty", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: new Error("cards directory unreadable"), + data: undefined, + }); + assert.deepEqual(state, { + kind: "error", + message: "cards directory unreadable", + }); + }); + + it("error wins even when data has fallen back to an empty array", () => { + // react-query keeps `data: undefined` on first failure, but the call + // site coalesces to []. Guard the exact shape that caused the bug. + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: new Error("ipc failure"), + data: [], + }); + assert.equal(state.kind, "error"); + }); + + it("stringifies non-Error rejections", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: "permission denied", + data: undefined, + }); + assert.deepEqual(state, { kind: "error", message: "permission denied" }); + }); + + it("null/undefined rejections still produce a message", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "error", message: "unknown error" }); + }); + + it("loading while fetching", () => { + const state = agentCardGalleryViewState({ + isLoading: true, + isError: false, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "loading" }); + }); + + it("no data yet (not loading, not error) stays loading, not empty", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "loading" }); + }); + + it("resolved and empty is the only path to the empty state", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: [], + }); + assert.deepEqual(state, { kind: "empty" }); + }); + + it("resolved with cards renders cards", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: [{ storedFileName: "a.png" }], + }); + assert.deepEqual(state, { kind: "cards" }); + }); +}); diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.ts b/desktop/src/features/agents/lib/agentCardGalleryState.ts new file mode 100644 index 0000000000..8f6cf4269e --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardGalleryState.ts @@ -0,0 +1,35 @@ +/** + * View-state selection for the minted-card gallery. + * + * Kept as a pure function so the error boundary is testable without a DOM: + * a rejected `list_agent_cards` query MUST surface as an error state, never + * as an empty archive — "No cards yet" on a permissions/IPC failure tells + * someone their paid, persisted cards do not exist. + */ + +export type AgentCardGalleryViewState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "empty" } + | { kind: "cards" }; + +export function agentCardGalleryViewState(query: { + isLoading: boolean; + isError: boolean; + error: unknown; + data: readonly unknown[] | undefined; +}): AgentCardGalleryViewState { + // Error wins over everything: `data` falls back to `[]` at the call site, + // so checking emptiness first would render a failure as a false empty. + if (query.isError) { + const message = + query.error instanceof Error + ? query.error.message + : String(query.error ?? "unknown error"); + return { kind: "error", message }; + } + if (query.isLoading || query.data === undefined) { + return { kind: "loading" }; + } + return query.data.length === 0 ? { kind: "empty" } : { kind: "cards" }; +} diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx new file mode 100644 index 0000000000..219de4aefa --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -0,0 +1,364 @@ +import * as React from "react"; +import { + AlertCircle, + Brain, + ExternalLink, + GalleryVerticalEnd, + KeyRound, + Lock, + Sparkles, +} from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { + setCardGalleryOpen, + startCardMint, +} from "@/features/agents/cardMintStore"; +import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; +import { + cardMintKeyStatus, + cardMintSaveOpenaiKey, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Switch } from "@/shared/ui/switch"; +import { Textarea } from "@/shared/ui/textarea"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; + +const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys"; + +/** Same three levels as snapshot export; "Agent only" is the safe default. */ +const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [ + { value: "none", label: "Agent only" }, + { value: "core", label: "Agent + core memory" }, + { value: "everything", label: "Agent + all memories" }, +]; + +/** + * The free alternative, as an action: ordinary snapshot export shares the + * same importable agent without card art or API spend. Rendered in both the + * key-setup panel and the normal pre-mint form (the cost disclosure and its + * escape hatch must be visible BEFORE any spend, not only during onboarding). + */ +function FreeSharePathRow({ + disabled, + onExportInstead, +}: { + disabled: boolean; + onExportInstead?: () => void; +}) { + return ( +
+

+ Don’t want to spend money? Ordinary export shares the same importable + agent — free, just without the card art. +

+ {onExportInstead ? ( + + ) : null} +
+ ); +} + +/** + * Mint-a-trading-card dialog — the pre-mint half only: key setup (when + * needed) → optional style notes → "Mint card". Minting itself runs as a + * background job in `cardMintStore`: this dialog dispatches and closes, the + * composer activity rail shows live status, and the finished card opens in + * the global `AgentCardViewerDialog` (preview, reroll, save, share). + * + * The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing + * the card shares an importable agent (fresh identity, never secrets; memory + * only when the owner opts in below — plaintext unless the card is locked). + * All snapshot construction and verification happens in Rust. + */ +export function AgentCardMintDialog({ + agentId, + agentName, + canLock, + onExportInstead, + onOpenChange, +}: { + /** Instance pubkey or definition slug — same resolution as snapshot export. */ + agentId: string; + agentName: string; + /** + * True when the agent has a linked instance (a keypair to lock to). + * Locking is disabled — with an explanation — for bare definitions. + */ + canLock: boolean; + /** + * Free alternative: close this dialog and open the ordinary snapshot + * export flow (no API spend). Omitted = the action is not rendered. + */ + onExportInstead?: () => void; + onOpenChange: (open: boolean) => void; +}) { + const [styleNotes, setStyleNotes] = React.useState(""); + const [lockCard, setLockCard] = React.useState(false); + const [memoryLevel, setMemoryLevel] = + React.useState("none"); + const [keyDraft, setKeyDraft] = React.useState(""); + + const queryClient = useQueryClient(); + + const effectiveLock = canLock && lockCard; + // Embedded memory is plaintext in an unlocked card — and unlocked cards + // are meant to be shared. A locked card encrypts the whole manifest to the + // (owner, agent) pair, so the plaintext warning would be false there. + const showMemoryWarning = memoryLevel !== "none" && !effectiveLock; + + // Whether a key already resolves through the agent's env layering. While + // unknown (loading/error) we show the normal mint form — the mint itself + // still fails cleanly if no key exists. + const keyStatusQuery = useQuery({ + queryKey: ["cardMintKeyStatus", agentId], + queryFn: () => cardMintKeyStatus(agentId), + }); + const needsKey = keyStatusQuery.data === false; + + // Save the pasted key into the global Agent Defaults env — the same single + // source of truth every agent inherits. Narrow Rust seam: validated + // single-key merge, never restarts running agents (the mint re-reads + // config per call, so no restart is needed for minting). + const saveKeyMutation = useMutation({ + mutationFn: (key: string) => cardMintSaveOpenaiKey(key), + onSuccess: () => { + queryClient.setQueryData(["cardMintKeyStatus", agentId], true); + // The Agent Defaults editor caches the whole config — refetch it so a + // later-opened settings view shows the key we just wrote. + void queryClient.invalidateQueries({ + queryKey: globalAgentConfigQueryKey, + }); + setKeyDraft(""); + toast.success( + "API key saved to your agent defaults. Running agents pick it up on their next restart.", + ); + }, + onError: (error) => + toast.error(typeof error === "string" ? error : "Couldn't save the key."), + }); + + function beginMint() { + // Dispatch to the background store and close: the composer rail shows + // "Minting card…" and the completion toast opens the viewer. + startCardMint({ + agentId, + agentName, + styleNotes: styleNotes.trim() || undefined, + lock: effectiveLock, + memoryLevel: canLock ? memoryLevel : "none", + }); + onOpenChange(false); + } + + return ( + + + + + + {`Create ${agentName}'s card`} + + + Mint a collectible trading card that doubles as a shareable, + importable copy of this agent. + + + + {needsKey ? ( +
+
+ + + One-time setup: OpenAI API key + +

+ Minting a card costs money — it generates the art and card text + through the OpenAI API with your key (typically well under a + dollar per mint, billed by OpenAI). The key is saved to your + agent defaults, so you only do this once. +

+ + setKeyDraft(e.target.value)} + placeholder="sk-…" + type="password" + value={keyDraft} + /> +
+ +
+ +
+
+ ) : ( +
+
+