Addresses review findings from a merge-time audit. - **Complete the headline multi-value `clone` fix for PRs** (was applied only to kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619) carry `clone` with the same spec shape but still emitted repeated single-value tags and read only the first value — so the exact interop bug this branch set out to kill was still live for PRs, both directions (ngit keeps only the last repeated tag; we lost every URL after the first from ngit's multi-value tag). Now both emit one multi-value `["clone", …]` tag and read both forms. Verified on the wire + GitNip34InteropTest + CLI harness (40 checks). - **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no author check meant anyone could publish a kind-1632 and make someone else's issue render closed. Now filter statuses to the repository owner (from the status's own `a` tag), declared maintainers (from the cached announcement), or the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing on main; this branch made the CLI/Android divergence visible. - **CLI robustness**: `git comment`/`git patch` no longer block forever reading stdin on an interactive TTY (amy is non-interactive — error instead). The local `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the CLI. Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the cross-module coupling here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
amy CLI test harnesses
Shell-based end-to-end harnesses that drive the amy CLI binary — against a
loopback nostr-rs-relay, an embedded amy serve relay, live public servers,
or no relay at all, depending on the suite. Eleven directories:
cli/tests/
├── lib.sh # shared logging, results, assertions
├── headless/ # shared bits used by every harness
│ └── helpers.sh
├── 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 nostr-rs-relay
├── clink/ # CLINK pointer decode — local-only, no relay
│ └── clink-headless.sh
├── dm/ # NIP-17 DM interop (amy ↔ amy)
│ ├── 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 + relay + identities
│ ├── tests-create.sh # tests 01–05
│ ├── tests-manage.sh # tests 06–08, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13
│ └── patches/ # whitenoise-rs harness patches
├── 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 a loopback nostr-rs-relay.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.
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 loopback nostr-rs-relay. No whitenoise-rs required — onlyamyand the relay binary (which is shared with the Marmot harness's checkout atmarmot/state-headless/nostr-rs-relay/).
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 whitenoise-rs (https://github.com/marmot-protocol/whitenoise-rs), the reference Rust implementation that powers the White Noise Flutter app. Every test records a pass/fail/skip result into a tab-separated log, and the summary is printed at the end of the run.
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
- git, curl, jq — package manager
- ~5 GB disk for the first-run build of
wn+wnd - Public internet access (for the default relay set and fetching crates)
On the Android side:
- Amethyst installed on an emulator or a physical device
- The device must reach the same relays the harness uses (see below)
Quick start
cd tools/marmot-interop
./marmot-interop.sh
The script will, in order:
- Verify
jq,git,cargoetc. are present. - Clone
whitenoise-rsintostate/whitenoise-rs/and buildwn/wnd(release,--features cli). First build takes ~5 minutes; subsequent runs reuse the binaries. - 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.
- Add the default public relays to both daemons and run a sanity check (publish a KP from B, fetch it from C).
- Print an Amethyst setup checklist — add the same relays 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
--local-relays Use ws://localhost:8080 instead of the default public relays.
Required if the public relays reject kinds 444/445/30443.
Run 'just docker-up' inside whitenoise-rs first.
--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/whitenoise-rs # use an existing checkout
Default relays
wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable
events). If the sanity check fails — meaning C cannot read the KeyPackage
that B just published — the harness warns you and continues. In that case
re-run with --local-relays after starting the Docker stack:
cd state/whitenoise-rs
just docker-up
cd ../..
./marmot-interop.sh --local-relays
For Amethyst with --local-relays:
- Android emulator: add
ws://10.0.2.2:8080to Settings → Relays and Settings → Key Package Relays. - Physical device on same Wi-Fi: add
ws://<laptop-LAN-ip>:8080.
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. Containswhitenoise-rs/source checkout, per-daemon data/log dirs, the sessionrun.env, logs, and results TSVs.