diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 466cd8410..61c227102 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -94,7 +94,12 @@ move anything, re-audit — you're probably duplicating logic. 7. **`amy zap send|verify`** (NIP-57). 8. **Distribution** — Homebrew + Scoop + `.deb` in the same release pipeline as desktop. Plan: `cli/plans/2026-04-21-cli-distribution.md`. -9. **Test suite** — end-to-end against a local relay. +9. **Test suite** — end-to-end against a local relay. Marmot interop is + covered by `tools/marmot-interop/marmot-interop-headless.sh`; NIP-17 + DM interop between two `amy` clients is covered by + `tools/marmot-interop/dm-interop-headless.sh` (text + file + strict + 10050 + fallback + cursor-advance). Neither runs in CI yet (both + need Rust + ~3 min cold relay build). 10. **Everything else in the matrix.** --- diff --git a/tools/marmot-interop/README.md b/tools/marmot-interop/README.md index f5fa97e0f..2fca06f1b 100644 --- a/tools/marmot-interop/README.md +++ b/tools/marmot-interop/README.md @@ -1,6 +1,6 @@ -# Marmot Interop Test Harness +# Interop Test Harnesses -Two flavours, same scenarios: +Two flavours of the Marmot harness, same scenarios: - **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the human** to perform each Amethyst-side step in the mobile UI (Identity A). @@ -10,7 +10,15 @@ Two flavours, same scenarios: 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. -Both harnesses validate Amethyst against **whitenoise-rs** +A third, slimmer harness covers the NIP-17 DM surface: + +- **`dm-interop-headless.sh`** — two `amy` processes (Identity A and + Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay. + No whitenoise-rs required — only `amy` and the relay binary (which + is shared with the Marmot harness's checkout at + `state-headless/nostr-rs-relay/`). + +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 @@ -33,6 +41,23 @@ the end of the run. | 11 | Leave group | – | | 12 | Offline catch-up / replay | – | | 13 | KeyPackage rotation | – | + +### DM (amy ↔ amy, NIP-17) — `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 | No-flag `dm list` advances the gift-wrap cursor (second call empty) | + +**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/`. | 14 | Push notifications (MIP-05) | opt-in via `--transponder` | ## Prerequisites diff --git a/tools/marmot-interop/dm-interop-headless.sh b/tools/marmot-interop/dm-interop-headless.sh new file mode 100755 index 000000000..36414c256 --- /dev/null +++ b/tools/marmot-interop/dm-interop-headless.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness. +# +# Two `amy` processes (Identity A and Identity D) talk to each other +# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No +# whitenoise-rs, no Marmot, no public internet traffic. +# +# Usage: ./dm-interop-headless.sh [--port N] [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-dm-headless" +LOG_DIR="$STATE_DIR/logs" +A_DIR="$STATE_DIR/A" +D_DIR="$STATE_DIR/D" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +# Share the nostr-rs-relay checkout with the Marmot harness to avoid +# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full +# isolation between runs. +RELAY_REPO="${RELAY_REPO:-$SCRIPT_DIR/state-headless/nostr-rs-relay}" +RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" +RELAY_DATA="$STATE_DIR/relay" +RELAY_PORT="${RELAY_PORT:-8090}" +RELAY_URL="ws://127.0.0.1:$RELAY_PORT" +NO_BUILD=0 + +A_NPUB="" +A_HEX="" +D_NPUB="" +D_HEX="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$D_DIR" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# Reuse the logging + result helpers from the Marmot harness. lib.sh +# also declares wn-specific helpers; they're harmless when unused. +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +# Reuse start_local_relay / stop_local_relay from setup.sh. preflight() +# there also builds whitenoise-rs, which we don't need — setup-dm.sh +# defines a slimmer preflight_dm(). +# shellcheck source=headless/setup.sh +source "$SCRIPT_DIR/headless/setup.sh" +# shellcheck source=headless/setup-dm.sh +source "$SCRIPT_DIR/headless/setup-dm.sh" +# shellcheck source=headless/helpers.sh +source "$SCRIPT_DIR/headless/helpers.sh" +# shellcheck source=headless/tests-dm.sh +source "$SCRIPT_DIR/headless/tests-dm.sh" + +cleanup() { + local rc=$? + trap - EXIT INT TERM HUP + stop_local_relay + print_summary + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +banner "Amethyst NIP-17 DM headless interop ($RUN_TS)" +preflight_dm +start_local_relay +ensure_identity_for A "$A_DIR" +ensure_identity_for D "$D_DIR" +configure_relays_dm + +test_01_dm_text_round_trip +test_02_dm_list_surfaces_history +test_03_dm_send_rejects_no_inbox +test_04_dm_send_allow_fallback +test_05_dm_file_reference_round_trip +test_06_dm_list_cursor_advance diff --git a/tools/marmot-interop/headless/setup-dm.sh b/tools/marmot-interop/headless/setup-dm.sh new file mode 100644 index 000000000..6bb7e764a --- /dev/null +++ b/tools/marmot-interop/headless/setup-dm.sh @@ -0,0 +1,112 @@ +# shellcheck shell=bash +# +# headless/setup-dm.sh — amy-only preflight + identity bootstrap for the +# NIP-17 DM interop harness. Much slimmer than the Marmot setup: +# +# - Builds `amy` (same retry-on-503 logic as setup.sh). +# - Builds nostr-rs-relay if missing. +# - Bootstraps two fresh amy identities (A and D), each with its own +# `--data-dir`, both pointed at the loopback relay. +# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict +# recipient-inbox routing has something to resolve to. +# +# The heavy `start_local_relay` / `stop_local_relay` helpers live in +# headless/setup.sh and are sourced by the top-level harness. + +# --- preflight (amy + relay only, no wn / Marmot patches) ------------------- +preflight_dm() { + banner "Preflight (DM harness)" + for cmd in jq git cargo; do + if ! command -v "$cmd" >/dev/null 2>&1; then + fail_msg "missing required tool: $cmd" + exit 1 + fi + info "$cmd: $(command -v "$cmd")" + done + + if [[ ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 + fi + # Gradle + jitpack/dl.google.com occasionally return transient 503s; + # retry so a single bad roll doesn't tank the whole harness. + local attempt max=4 + for attempt in $(seq 1 $max); do + step "building :cli:installDist (attempt $attempt/$max)" + if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ + && [[ -x "$AMY_BIN" ]]; then + break + fi + [[ "$attempt" -lt "$max" ]] && warn "gradle build failed — retrying" + done + fi + [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } + info "amy: $AMY_BIN" + + # nostr-rs-relay (same build path as the Marmot harness). + if [[ ! -x "$RELAY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 + fi + if [[ ! -d "$RELAY_REPO/.git" ]]; then + step "cloning nostr-rs-relay into $RELAY_REPO" + git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ + 2>&1 | tee -a "$LOG_FILE" + fi + local attempt max=4 + for attempt in $(seq 1 $max); do + step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)" + ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ + 2>&1 | tee -a "$LOG_FILE" + [[ -x "$RELAY_BIN" ]] && break + [[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying" + done + [[ -x "$RELAY_BIN" ]] || { + fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1 + } + fi + info "relay bin: $RELAY_BIN" +} + +# --- amy identity wrappers --------------------------------------------------- +# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity +# with the existing harness files; D_DIR is new. +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; } +amy_d() { "$AMY_BIN" --data-dir "$D_DIR" "$@"; } + +# --- identity bootstrap ------------------------------------------------------ +ensure_identity_for() { + local who="$1" dir="$2" + step "initialising Identity $who (amy at $dir)" + local out + out=$("$AMY_BIN" --data-dir "$dir" init) || { + fail_msg "amy init failed for $who: $out"; exit 1 + } + local npub hex + npub=$(printf '%s' "$out" | jq -r '.npub') + hex=$(printf '%s' "$out" | jq -r '.hex') + case "$who" in + A) A_NPUB="$npub"; A_HEX="$hex" ;; + D) D_NPUB="$npub"; D_HEX="$hex" ;; + esac + info "$who npub: $npub" + info "$who hex: $hex" +} + +# --- relay wiring ------------------------------------------------------------ +# Point both identities at the loopback relay (all three buckets: +# nip65 / inbox / key_package), then publish each identity's kind:10050 +# so the DM strict-relay routing has something to resolve to. +configure_relays_dm() { + banner "Configuring relays → $RELAY_URL" + "$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null + "$AMY_BIN" --data-dir "$D_DIR" relay add "$RELAY_URL" --type all >/dev/null + + step "publishing A's NIP-65 + kind:10050 lists" + amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \ + || warn "amy_a relay publish-lists failed" + + step "publishing D's NIP-65 + kind:10050 lists" + amy_d relay publish-lists >>"$LOG_FILE" 2>&1 \ + || warn "amy_d relay publish-lists failed" +} diff --git a/tools/marmot-interop/headless/tests-dm.sh b/tools/marmot-interop/headless/tests-dm.sh new file mode 100644 index 000000000..a693ca675 --- /dev/null +++ b/tools/marmot-interop/headless/tests-dm.sh @@ -0,0 +1,220 @@ +# shellcheck shell=bash +# +# headless/tests-dm.sh — NIP-17 DM interop tests for two `amy` clients. +# +# Identity A (sender) and Identity D (recipient) each live in their own +# --data-dir and share one loopback nostr-rs-relay. Tests cover: +# +# dm-01 text round-trip (both directions) +# dm-02 dm list surfaces prior exchange with type:text discriminator +# dm-03 strict kind:10050 routing refuses sends to inboxless recipient +# dm-04 --allow-fallback opts into the NIP-65 read / bootstrap chain +# dm-05 file message reference mode round-trip (kind:15) +# dm-06 cursor advance (subsequent no-flag `dm list` is empty) + +# Wrap amy_json around either data-dir so the per-test code stays tight. +amy_json_for() { + local dir="$1"; shift + local out + if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then + fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)" + printf '%s\n' "$out" >>"$LOG_FILE" + return 1 + fi + printf '%s' "$out" +} + +amy_json_a() { amy_json_for "$A_DIR" "$@"; } +amy_json_d() { amy_json_for "$D_DIR" "$@"; } + +test_01_dm_text_round_trip() { + banner "DM-01 — text round-trip A↔D (kind:14)" + local id="dm-01 text round-trip" + + # A → D + local out_send + out_send=$(amy_json_a dm send "$D_NPUB" "hello from A") || { + record_result "$id" fail "A send failed"; return + } + local event_id recipients_ok + event_id=$(printf '%s' "$out_send" | jq -r '.event_id') + recipients_ok=$(printf '%s' "$out_send" \ + | jq -r '[.recipients[] | select(.relay_source == "kind_10050")] | length') + info "A sent kind:14 event_id=$event_id (to ${recipients_ok} kind:10050 inboxes)" + if [[ -z "$event_id" || "$event_id" == "null" ]]; then + record_result "$id" fail "A send response missing event_id"; return + fi + + # D awaits the text + if ! amy_json_d dm await --peer "$A_NPUB" --match "hello from A" --timeout 30 >/dev/null; then + record_result "$id" fail "D never received A's text"; return + fi + info "D received A's text" + + # D → A + amy_json_d dm send "$A_NPUB" "reply from D" >/dev/null || { + record_result "$id" fail "D send failed"; return + } + if ! amy_json_a dm await --peer "$D_NPUB" --match "reply from D" --timeout 30 >/dev/null; then + record_result "$id" fail "A never received D's reply"; return + fi + info "A received D's reply" + + record_result "$id" pass +} + +test_02_dm_list_surfaces_history() { + banner "DM-02 — dm list returns text messages with type discriminator" + local id="dm-02 dm list text history" + + local out + out=$(amy_json_a dm list --peer "$D_NPUB" --timeout 5) || { + record_result "$id" fail "amy dm list failed"; return + } + local text_count reply_present + text_count=$(printf '%s' "$out" \ + | jq -r '[.messages[] | select(.type == "text")] | length') + reply_present=$(printf '%s' "$out" \ + | jq -r '[.messages[] | select(.content == "reply from D")] | length') + info "A sees $text_count text message(s) with D; reply_present=$reply_present" + if [[ "$reply_present" == "1" ]]; then + record_result "$id" pass + else + record_result "$id" fail "D's reply missing from A's dm list" + fi +} + +test_03_dm_send_rejects_no_inbox() { + banner "DM-03 — strict kind:10050 refuses sends to inboxless recipient" + local id="dm-03 strict no_dm_relays" + + # Generate a throwaway identity but do NOT publish its kind:10050. + local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") + local ghost_out ghost_npub + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || { + record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return + } + ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') + info "ghost: $ghost_npub (no relays advertised)" + + # A sends without --allow-fallback; amy should refuse. + local raw rc + raw=$("$AMY_BIN" --data-dir "$A_DIR" dm send "$ghost_npub" "should be rejected" 2>&1) + rc=$? + rm -rf "$tmpdir" + if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then + info "amy refused with no_dm_relays as expected (exit $rc)" + record_result "$id" pass + else + fail_msg "expected no_dm_relays error; got rc=$rc raw=$raw" + record_result "$id" fail "send to inboxless recipient did not refuse" + fi +} + +test_04_dm_send_allow_fallback() { + banner "DM-04 — --allow-fallback opts into NIP-65 read / bootstrap chain" + local id="dm-04 allow-fallback" + + # Same ghost pattern as dm-03. With --allow-fallback, resolution should + # fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set + # always includes the loopback relay via the shared RelayConfig, so the + # publish should succeed even though the ghost has no 10050. + local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") + local ghost_out ghost_npub + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || { + record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return + } + ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') + rm -rf "$tmpdir" + + local out source + out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || { + record_result "$id" fail "send with --allow-fallback failed"; return + } + # The ghost's own wrap will land in the recipients list; its source + # should be bootstrap (no 10050, no 10002) — which confirms the + # fallback actually fired. + source=$(printf '%s' "$out" \ + | jq -r --arg pk "${ghost_npub}" \ + '.recipients[] | select(.pubkey != $pk) | .relay_source' \ + | head -1) + info "relay_source for ghost wrap: $source" + if printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("bootstrap")' >/dev/null \ + || printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("nip65_read")' >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "no recipient reported a fallback relay_source" + fi +} + +test_05_dm_file_reference_round_trip() { + banner "DM-05 — file message reference mode (kind:15) round-trip" + local id="dm-05 file reference round-trip" + + # 64 hex chars = 32-byte AES-GCM key; 32 hex chars = 16-byte nonce. + local key="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + local nonce="fedcba9876543210fedcba9876543210" + local url="https://example.test/blob/test-dm-05.bin" + local mime="application/octet-stream" + + amy_json_a dm send-file "$D_NPUB" "$url" \ + --key "$key" --nonce "$nonce" --mime-type "$mime" --size 1024 >/dev/null || { + record_result "$id" fail "A send-file failed"; return + } + info "A published kind:15 for $url" + + # D awaits — `dm await --match` searches URL for kind:15. + if ! amy_json_d dm await --peer "$A_NPUB" --match "$url" --timeout 30 >/dev/null; then + record_result "$id" fail "D never received the file message"; return + fi + + # Verify the recovered JSON carries all crypto material. + local raw + raw=$(amy_json_d dm list --peer "$A_NPUB" --timeout 5) || { + record_result "$id" fail "D dm list failed"; return + } + local fields + fields=$(printf '%s' "$raw" | jq -r \ + '[.messages[] | select(.type == "file" and .url == "'"$url"'")][0]') + if [[ -z "$fields" || "$fields" == "null" ]]; then + record_result "$id" fail "D did not decode a kind:15 matching the URL"; return + fi + + local got_key got_nonce got_mime + got_key=$(printf '%s' "$fields" | jq -r '.decryption_key') + got_nonce=$(printf '%s' "$fields" | jq -r '.decryption_nonce') + got_mime=$(printf '%s' "$fields" | jq -r '.mime_type') + info "D decoded key=${got_key:0:16}… nonce=${got_nonce:0:16}… mime=$got_mime" + assert_eq "$got_key" "$key" "$id" "decryption_key mismatch" || return + assert_eq "$got_nonce" "$nonce" "$id" "decryption_nonce mismatch" || return + assert_eq "$got_mime" "$mime" "$id" "mime_type mismatch" || return + + record_result "$id" pass +} + +test_06_dm_list_cursor_advance() { + banner "DM-06 — no-flag dm list advances the giftWrapSince cursor" + local id="dm-06 cursor advance" + + # First no-flag list: drains everything and advances the cursor to now. + local first_out first_count + first_out=$(amy_json_d dm list --timeout 5) || { + record_result "$id" fail "first dm list failed"; return + } + first_count=$(printf '%s' "$first_out" | jq -r '.messages | length') + info "first dm list returned $first_count message(s)" + + # Second no-flag list: nothing new should land. + local second_out second_count + second_out=$(amy_json_d dm list --timeout 5) || { + record_result "$id" fail "second dm list failed"; return + } + second_count=$(printf '%s' "$second_out" | jq -r '.messages | length') + info "second dm list returned $second_count message(s)" + + if [[ "$first_count" -ge 1 && "$second_count" -eq 0 ]]; then + record_result "$id" pass + else + record_result "$id" fail "expected first>=1, second==0; got first=$first_count second=$second_count" + fi +}