`amy cordn` grows the coordinator half: `coordinator`, `keypackage`, `group`, `invite`, `request`, `requests`, `welcomes`, `join`, `decline`, `send`, `fetch`. Enough to run the whole lifecycle from a shell script, which is what a second implementation is for. `cli/tests/cordn/tier-b.sh` is that script. Read the header of that file before running it. The reference coordinator is unlicensed (interop plan §7), so nothing here pulls it, no build or CI task references it, and it stays a diagnostic you choose to run rather than part of the test process. Running it at all was a maintainer's explicit decision; §7.1 records what it found, which is the argument for having made it. **The verbs.** Three shapes worth naming, because they are not stylistic: - Every live verb takes `--coordinator`, and it is part of a group's address rather than a convenience. A `gid` is unique only within one coordinator (`spec/00.md` §4), so the wrong one is not a near miss — it is an answer about a different group. Omitting it works only while exactly one is remembered, and `--gid` follows the same rule. - `fetch` is explicit because a CLI invocation is a process and cannot hold a subscription. The cursor on disk is the entire continuity mechanism between runs — which is what `spec/02.md` §7 means by calling a cursor a delivery primitive, doing double duty here. - `send --reply-to ID` demands `--to-author PK` beside it. Not an awkward flag: an annotation's tags name the target's author and kind, and amy keeps no message store (cursors and MLS state persist, decrypted messages deliberately do not), so the caller supplies what it read from `fetch`. `NostrClientCvmRelayPool` and the link factory moved to commons in the previous commit for this; `KeyedCordnBlobCipher` is what encrypts the CLI's blobs, and `DataDir.cordnBlobKeyFile` says plainly what a key stored beside its ciphertext does and does not buy. **Finding 1, fixed here: our CEP-4 gift wraps were invisible to any real ContextVM server.** `CvmGiftWrap.wrap` set `created_at` to NIP-59's randomized timestamp — the real time minus up to two days. The reference server subscribes with `since = now` when it connects, the obvious filter for a live request stream, and relays honour `since`. So every request we have ever sent to a real coordinator was dropped **by the relay**, before the server saw it. The symptom is a 20-second timeout with nothing in any log, because from the other side nothing happened. NIP-59 is right in its own context: a gift-wrapped DM is stored and fetched later, so its timestamp would reveal when a conversation happened. A ContextVM wrap carries an RPC request to a peer listening right now, and the shift protects nothing — CEP-4 puts the recipient in a visible `p` tag, delivery is real time, and kind 21059 is ephemeral so no relay keeps it to be read later. It hid from an observer a fact that same observer reads off the socket, at the cost of the request never arriving. Why nothing caught it: our fixture server reads whatever is addressed to it with no `since` filter, and so does every test double. A backdated wrap is indistinguishable from a fresh one unless the other end filters on time, and only a real server does. There was a test asserting the *broken* behaviour — `CVM-4-14`, "the wrap timestamp is shifted and must not be used for ordering" — and it passed for the whole life of the transport. A test that pins what the code does is not evidence about what the protocol needs. It now asserts the send time. **Finding 2, recorded here, fixed in the next commit: self-echo bookkeeping does not survive a process boundary.** With delivery working, a sender's own traffic comes back as `undecryptable` — its Commit sealed under an epoch key it has since left, its message from a ratchet generation already consumed. `GroupInbox` holds that bookkeeping in memory while the cursor beside it is persisted. amy hits it every run; the Android app hits it whenever the OS kills it between sending and syncing. The harness asserts the fixed behaviour and currently fails on it, which is the honest state of the tree for one commit. What Tier B did not find is as informative: both bugs are transport and bookkeeping. Nothing in the crypto surface moved — the MLS engine, the seal, the envelopes and the group refs were already verified against ts-mls and against cordn's own wire contracts, and a live counterparty found nothing wrong with any of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
amy CLI test harnesses
Shell-based end-to-end harnesses that drive the amy CLI binary — against an
embedded relay (amy serve, i.e. geode, the relay this repo ships), live
public servers, or no relay at all, depending on the suite. No suite depends on
an external relay binary or a Rust toolchain for its relay: every relay-backed
harness boots geode from the amy binary it already built, so the relay under
test is the same server code that runs in production. Eleven directories:
cli/tests/
├── lib.sh # shared logging, results, assertions
├── headless/ # shared bits used by every harness
│ └── helpers.sh # amy wrappers, assertions, embedded relay boot
├── blossom/ # Blossom blob lifecycle vs LIVE public servers
│ └── blossom-live.sh
├── cache/ # local-store-as-cache semantics (profile show
│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve`
├── clink/ # CLINK pointer decode — local-only, no relay
│ └── clink-headless.sh
├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
├── git/ # NIP-34 git collaboration vs embedded `amy serve`
│ └── git-nip34-headless.sh
├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt
│ ├── setup.sh # preflight + wn + identities
│ ├── tests-create.sh # tests 01–05
│ ├── tests-manage.sh # tests 06–08, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13
│ ├── tests-media.sh # tests 20-29 (avatar, edits, deletions,
│ │ # media v2, retention, disband)
├── nests/ # Audio-rooms interop (Amethyst ↔ nostrnests.com)
│ ├── nests-interop.sh # 47-test manual harness
│ └── README.md # operator brief + per-test matrix
├── pow/ # NIP-13 primitives (bench/mine/check) — no relay
│ └── pow-headless.sh
├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode)
│ └── relaygroup-headless.sh
└── sync/ # NIP-77 deletion propagation vs `amy serve`
└── sync-deletions-headless.sh
These shell suites are the interop layer. The contract net — Args
parsing, the exit-code contract (bad_args → 2, timeout → 124), and --json
shapes — is the JVM unit suite at cli/src/test/kotlin/ (ArgsTest,
ExitCodeContractTest, JsonContractTest), which drives runCli
in-process with an isolated ~/.amy via the amy.home seam. Run it with
./gradlew :cli:test — no relay, no Rust, milliseconds.
Suite notes:
clink/clink-headless.shis local-only (no relay): asserts thatamy offer info/amy debit infodecode the canonical interop vectors (the same fixtures quartz'sClinkInteropTestuses) to the right fields, plus the argument-error paths. The round-trip verbs (offer request,debit pay/budget) need a live CLINK service and aren't covered here.pow/pow-headless.shis also relay-free:pow benchsanity,pow minehitting its target (and exiting 124 on an impossible one), and mined-nonce round-trips throughpow check.cache/cache-headless.shproves the local store is the source of truth for reads:profile showserved from cache vs--refresh, andstore statreporting the right histogram, vs the embeddedamy serverelay.relaygroup/relaygroup-headless.shruns NIP-29 create/message/join/ list/browse against an embedded relay (amy serve, which boots geode) — no external relay binary. geode doesn't sign 39000-39003, so browse/info emptiness is a relay capability, not a client bug.sync/sync-deletions-headless.shproves NIP-77 deletion propagation both directions (plus the--no-sync-deletionsopt-out) againstamy serve, with one$HOMEper account so stores don't share.git/git-nip34-headless.shdrives the full NIP-34 collaboration surface againstamy serve:git initbootstrapping a repo from the harness's own git checkout (announce + state derived viagit), announce (30617) + state (30618) + GRASP list (10317), issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22 comment (1111), NIP-32 label (1985), and status events (1630-1633). It also publishes a realgit format-patchandgit applys it back into a scratch working tree, and asserts theissues/patches/prs/threadreads derive the right status (a closed issue readsclosed, an applied PR readsapplied) and that--open/--closedfilter correctly. Pass--liveto additionally exercise the git smart-HTTP reads (git browse/cat/log) against a real public repo ($LIVE_REPO, default octocat/Hello-World) — skipped by default since it needs a reachable git host.
The Marmot harnesses come in two flavours, same scenarios:
-
marmot/marmot-interop.sh— interactive. Drives B/C viawnand prompts the human to perform each Amethyst-side step in the mobile UI (Identity A). Use this for final UI verification. -
marmot/marmot-interop-headless.sh— zero prompts. Drives A via theamyCLI (./gradlew :cli:installDist) and B/C viawn. Runs every scenario end-to-end and exits with a pass/fail summary. Use this for CI and for iterating on the Nostr/Marmot plumbing without needing to touch a phone.Most features are covered in BOTH directions — founding (02/03), adding (04/05), removal (06/14), leaving (11/15), keypackage rotation (13/16), agent streams (18/19), avatar URL (20/21), encrypted media v2 (24/25), deletion (23/27), retention (26/28). Three gaps are the reference CLI's, not ours, and cannot be closed from here:
- edits wn→amy.
wn messageshas noeditverb, and MDK reserves kind 1009 somessages send-eventrefuses to forge one. MDK's runtime hasedit_messageand its uniffi surface exposes it; only the CLI does not. - setting retention from wn.
wn groupshas no retention verb andgroups createhas no flag for it, so test 28 has amy own the setting and wn own the sending — which is the half that was untested anyway, since inbound messages are where the epoch-pinning rule lives. - disband wn→amy. Same shape:
disband_groupexists on MDK's runtime and uniffi surface (the apps call it) but has nown groupsverb, so test 29 runs one way only.
The daemon is not a way around this, which is worth stating because it is the obvious next idea.
wnd's socket protocol (crates/cli/src/daemon/protocol.rs) carriesPing,Status,Shutdown, four*Subscribevariants, andExecute { cli: Box<Cli> }— and that last one takes the same clap command treewnparses. The daemon is a persistent host for the CLI's verbs, not a richer RPC, so a verb missing fromCliis unreachable through the socket too. Closing these three needs either a verb upstream in MDK or a driver linked againstmarmot-uniffi/marmot-c; both are out of scope for a harness that deliberately builds MDK unpatched. - edits wn→amy.
A third, slimmer harness covers the NIP-17 DM surface:
dm/dm-interop-headless.sh— twoamyprocesses (Identity A and Identity D) exchange NIP-17 DMs through the embeddedamy serverelay. No MDK, no Rust — onlyamy.
A harness covers Blossom blob storage (BUD-01/02/04/09) against live public servers rather than a loopback relay:
blossom/blossom-live.sh— drives the fullamy blossomlifecycle (upload → HEAD check → download-and-verify-hash → list → cross-server mirror → delete) against a real Blossom server. Server-side write rejections (whitelists, rate limits, payment) record as SKIP, not FAIL — only a broken client contract (bad descriptor, hash mismatch) fails. Defaults tohttps://files.sovbit.host; pass--mirror-server URLto exercise BUD-04. Example:blossom/blossom-live.sh --server https://files.sovbit.host --mirror-server https://blossom.primal.net.
A fourth harness covers audio rooms (NIP-53 + moq-lite):
nests/nests-interop.sh— fully manual interop between Amethyst Android and the nostrnests.com reference web client. 47 tests spanning host/listener flows, audio round-trip, hand-raise + role promotion, reactions, in-room chat (kind 1311), kick (kind 4312), close-room, schedule, network-drop reconnect, 10-min JWT refresh, custom moq servers (kind 10112), and PIP / background audio. Seenests/README.mdfor the full matrix and prereqs.
Both Marmot harnesses validate Amethyst against MDK
(https://github.com/marmot-protocol/mdk), the reference Rust implementation of
the Marmot protocol, via its wn / wnd binaries (the wn-cli package).
Every test records a pass/fail/skip result into a tab-separated log, and the
summary is printed at the end of the run.
These harnesses previously targeted
marmot-protocol/whitenoise-rs, which was archived on 2026-08-05 pinned tomdk-core 0.8.0. Testing against it meant testing against a frozen MIP-era client. The reference moved intomdk, and so did we — seequartz/plans/2026-09-08-marmot-spec-resync.mdfor what that change exposed.
What gets tested
| # | Test | Needs 3rd identity |
|---|---|---|
| 01 | KeyPackage publish & discovery (MIP-00) | – |
| 02 | Amethyst creates group, invites wn | – |
| 03 | wn creates group, invites Amethyst | – |
| 04 | 3-member group, add-after-create | yes |
| 05 | wn adds Amethyst to existing group | yes |
| 06 | Member removal + forward secrecy | yes |
| 07 | Group metadata rename round-trip (MIP-01) | – |
| 08 | Admin promote / demote | yes |
| 09 | Reply / react / unreact (inner event kinds 9, 7) | – |
| 10 | Concurrent commits race | – |
| 11 | Leave group | – |
| 12 | Offline catch-up / replay | – |
| 13 | KeyPackage rotation | – |
| 14 | Push notifications (MIP-05) | opt-in via --transponder |
DM (amy ↔ amy, NIP-17) — dm/dm-interop-headless.sh
| # | Test |
|---|---|
| dm-01 | Text round-trip A↔D (kind:14) |
| dm-02 | dm list returns prior exchange with type:text discriminator |
| dm-03 | Strict kind:10050 refuses sends to an inboxless recipient |
| dm-04 | --allow-fallback opts into the NIP-65 read / bootstrap chain |
| dm-05 | File message reference mode round-trip (kind:15 with manual key/nonce) |
| dm-06 | dm list --since filters out older messages (window-slide past the newest event returns 0) |
Relay binding note: the DM harness binds the loopback relay to
127.0.0.2 (not 127.0.0.1) because Quartz's RelayTag.parse rejects
localhost URLs via isLocalHost() — so ws://127.0.0.1 in a kind:10050
event is silently stripped during recipient-relay resolution, which
would make strict-mode DM sends spuriously fail. 127.0.0.2 is still
pure loopback and isn't matched by that filter. Override with
--host 127.0.0.5 etc. if 127.0.0.2 is taken.
Note: dm-05 validates the kind:15 wire format via reference mode
(caller supplies the URL + AES-GCM key/nonce). The upload-mode variant
(dm send-file --file PATH --server URL) needs a local Blossom server
and isn't scripted here — the upload classes are unit-tested on desktop
at desktopApp/src/jvmTest/kotlin/.../service/upload/.
Prerequisites
On the machine that runs the harness:
- Rust 1.90+ — install via https://rustup.rs (for MDK's
wn/wndonly; the relay isamy serve, no Rust needed for it) - git, curl, jq — package manager
- ~5 GB disk for the first-run build of
wn+wnd - Internet access for fetching crates on the first build. Test traffic
stays on the machine unless you pass
--public-relays.
On the Android side:
- Amethyst installed on an emulator or a physical device
- The device must reach the harness's embedded relay over the network
(see below), or the public relays when running with
--public-relays
Quick start
cd tools/marmot-interop
./marmot-interop.sh
The script will, in order:
- Verify
jq,git,cargoetc. are present. - Clone
mdkintostate/mdk/and buildwn/wnd(cargo build --release -p wn-cli). First build takes ~5 minutes; subsequent runs reuse the binaries. MDK pins its own Rust toolchain inrust-toolchain.toml, so rustup may fetch a toolchain on the first run. - Launch two
wnddaemons (one for Identity B, one for Identity C). - Create Nostr identities for B and C, persist their npubs in
state/run.env. - Ask you to paste your Amethyst account npub (Identity A). This is cached for subsequent runs.
- Boot the embedded relay (
amy serve, i.e. geode, on0.0.0.0:8080), add it to both daemons and run a sanity check (publish a KP from B, fetch it from C). With--public-relaysthe default public set is used instead and the relay is not started. - Print an Amethyst setup checklist — add the same relay to Amethyst, publish a KP, verify you are logged in with A.
- Run all 13 tests sequentially. Each test either:
- runs
wncommands fully automatically and asserts on JSON output, or - prints a "DO THIS IN AMETHYST" prompt and waits for you to press
<Enter>, then verifies the Amethyst action viawn.
- runs
- Stop the daemons and print a results table.
Command-line flags
--public-relays Use the public relay set below instead of the embedded relay.
The only mode whose test traffic leaves the machine; the
public relays may reject kinds 444/445/30443.
--port N Port for the embedded relay (default 8080).
--transponder Run Test 14 (push notifications via the transponder service).
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
-h, --help Show help.
Environment overrides:
WN_REPO=/some/path/mdk # use an existing checkout
Relays
By default the harness owns the only relay: amy serve (geode) bound to
0.0.0.0:8080. The wn daemons reach it on loopback; Amethyst reaches it
over the network:
- Android emulator: add
ws://10.0.2.2:8080to Settings → Relays, Settings → Key Package Relays and Settings → DM Inbox Relays. - Physical device on same Wi-Fi: add
ws://<laptop-LAN-ip>:8080.
With --public-relays the daemons are bootstrapped on
wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
wss://nostr.bitcoiner.social
wss://nostr.mom
instead and Amethyst is left on its own relay set, so the run surfaces
real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the
public relays drop). If the sanity check fails in that mode — meaning C
cannot read the KeyPackage that B just published — the harness warns you and
continues; re-run without --public-relays to rule the relays out.
How human interaction works
When the script needs you to do something in Amethyst, it prints a yellow block like this:
---- DO THIS IN AMETHYST ----
In Amethyst:
1. Tap + -> Create Group
2. Name: Interop-02
3. Add member: npub1abc...
4. Tap Create / Send Invite
-----------------------------
[Press Enter to continue]
After you press Enter the script resumes. For UI-only verifications (e.g. "does Amethyst show reaction 🌮?"), the script asks:
? Does Amethyst show the 🌮 reaction? [p]ass / [f]ail / [s]kip:
Pick p, f, or s.
Output
state/logs/run-<timestamp>.log— every step, assertion, and human prompt, with the exactwnstdout that was parsed.state/results-<timestamp>.tsv— tab-separatedtest_id \t status \t notelines. Easy to grep.state/run.env— persistent key/value state (npubs, group ids) so you can kill and resume the harness mid-run without losing context.- Final summary table on stdout — colored per-test status and totals.
State cleanup
The harness leaves state/ on disk so daemons and identities survive across
runs. To start completely fresh:
# stops daemons if still running; removes identities, groups, logs
./marmot-interop.sh --no-build # Ctrl-C when it waits for input, then:
rm -rf state/
Published KeyPackages on public relays will remain until they expire naturally
or are deleted via wn keys delete-all --confirm. Use a throwaway identity
for B/C if this matters to you.
Known gaps
- Test 10 (concurrent commits) is inherently human-timing sensitive. It's a best-effort race; expect occasional flakes.
- Test 14 (push) only exercises the harness side — full end-to-end
verification requires a running
transponderinstance and platform registrations that are out of scope here. - The harness assumes Amethyst exposes UI affordances for add-member, remove-member, rename, promote/demote, and leave. If any of those is missing from the current build, the corresponding test will fail with a clear note rather than crash.
Files
marmot-interop.sh— main entry point; orchestrates preflight, daemons, identities, relays, and runs the 13 tests in sequence.lib.sh— helpers (logging, prompts, polling, jq wrappers, result table).state/— runtime directory, gitignored. Contains themdk/source checkout, per-daemon data/log dirs, the sessionrun.env, logs, and results TSVs.