From d8d094db48c35113e2a09580799917055b360235 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 14:59:44 +0000 Subject: [PATCH 1/3] fix(marmot-interop): parse post-v0.2 wn --json shape in interactive harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `.result` wrapper fix landed in lib.sh's pollers and the headless tests back in f08b010f, but the 8 inline `jq` reads in marmot-interop.sh were missed — which silently returned empty against current `wn` and made every UI-verification test look like "Amethyst didn't propagate X" when in fact the underlying wn state was correct and the script just couldn't see it. All 8 reads now peel `(.result // .)` the same way the lib.sh helpers do, staying backwards-compatible with the pre-v0.2 flat shape: * test 02, 04 member lists * test 06 member-removed check * test 07 group-name poll * test 08 admin list (promote + demote) * test 09 anchor message lookup * test 11 leave-group member check Also split the configure_relays sanity check into three relay-level surfaces so a broken relay set points at the specific kind it can't handle, instead of 10 later tests all timing out with the same "no invite" symptom: * kind:30443 (KeyPackage) — both sides publish + symmetric discover * kind:10050 (Inbox advertise) — C reachable as giftwrap target * kind:1059 (Gift wrap) — B->C welcome delivery * kind:445 (MLS commit/msg) — real group-message round-trip And drop the orphan whitenoise-mdk-plaintext-policy.patch: it lived in `headless/patches/` but was never referenced by setup.sh's patch array, and if it ever were applied it would flip mdk-core's MLS wire-format from MIXED_CIPHERTEXT to MIXED_PLAINTEXT — a protocol-breaking divergence from every stock whitenoise-rs / White Noise build. Removing it eliminates the trap of a future contributor "enabling" it. https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA --- .../whitenoise-mdk-plaintext-policy.patch | 11 --- tools/marmot-interop/marmot-interop.sh | 80 +++++++++++++++---- 2 files changed, 66 insertions(+), 25 deletions(-) delete mode 100644 tools/marmot-interop/headless/patches/whitenoise-mdk-plaintext-policy.patch diff --git a/tools/marmot-interop/headless/patches/whitenoise-mdk-plaintext-policy.patch b/tools/marmot-interop/headless/patches/whitenoise-mdk-plaintext-policy.patch deleted file mode 100644 index 9c3f9e97e..000000000 --- a/tools/marmot-interop/headless/patches/whitenoise-mdk-plaintext-policy.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- a/../../../.cargo/git/checkouts/mdk-7d5a3a2420b194f5/8a8d06c/crates/mdk-core/src/groups.rs -+++ b/../../../.cargo/git/checkouts/mdk-7d5a3a2420b194f5/8a8d06c/crates/mdk-core/src/groups.rs -@@ -1215,7 +1215,7 @@ - ); - let group_config = MlsGroupCreateConfig::builder() - .ciphersuite(self.ciphersuite) -- .wire_format_policy(MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY) -+ .wire_format_policy(MIXED_PLAINTEXT_WIRE_FORMAT_POLICY) - .use_ratchet_tree_extension(true) - .capabilities(capabilities) - .with_group_context_extensions(extensions) diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index 132ec3012..ec999d87f 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -243,7 +243,19 @@ configure_relays() { info "B relays: $(printf '%s' "$relay_list" | jq -r '.result[]? | "\(.url) [\(.status)]"' 2>/dev/null | tr '\n' ' ')" printf 'B relay list: %s\n' "$relay_list" >>"$LOG_FILE" - step "sanity-check: B publishes KP, C fetches it" + # Sanity check is split across three relay-level surfaces so a failure + # points at the exact kind the relay set can't handle, instead of 10 + # later tests all timing out with the same "no invite" symptom: + # + # kind:30443 (KeyPackage) — both sides publish + discover + # kind:10050 (Inbox advertise) — C must be reachable as a giftwrap target + # kind:1059 (Gift wrap) — B->C welcome delivery + # kind:445 (MLS commit/msg) — real group message round-trip + # + # All failures are warnings (not hard fails) so a known-broken relay set + # still lets the harness continue into the 13 tests — they will fail + # anyway but at least the operator will have the diagnostic hint here. + step "sanity-check kind:30443: B+C publish + symmetric discover" local publish_out if publish_out=$(wn_b keys publish 2>&1); then info "wn_b keys publish: ok" @@ -251,17 +263,57 @@ configure_relays() { warn "wn_b keys publish failed: $publish_out" printf 'wn_b keys publish: %s\n' "$publish_out" >>"$LOG_FILE" fi + if publish_out=$(wn_c keys publish 2>&1); then + info "wn_c keys publish: ok" + else + warn "wn_c keys publish failed: $publish_out" + printf 'wn_c keys publish: %s\n' "$publish_out" >>"$LOG_FILE" + fi sleep 4 local sanity_raw sanity_id sanity_raw=$(wn_c --json keys check "$B_NPUB" 2>>"$LOG_FILE" || true) sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then - info "sanity OK — relay round-trip works (event_id: $sanity_id)" + info "kind:30443 C->B ok (event_id: $sanity_id)" else - warn "sanity-check failed — public relays may be rejecting kind 30443." - warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)." - info "raw keys check response: $sanity_raw" - printf 'sanity keys check raw: %s\n' "$sanity_raw" >>"$LOG_FILE" + warn "kind:30443 C->B failed — relays may be rejecting KeyPackages" + printf 'sanity keys check B raw: %s\n' "$sanity_raw" >>"$LOG_FILE" + fi + sanity_raw=$(wn_b --json keys check "$C_NPUB" 2>>"$LOG_FILE" || true) + sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true) + if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then + info "kind:30443 B->C ok (event_id: $sanity_id)" + else + warn "kind:30443 B->C failed — relays may be rejecting KeyPackages" + printf 'sanity keys check C raw: %s\n' "$sanity_raw" >>"$LOG_FILE" + fi + + step "sanity-check kinds 10050/1059/445: B creates group + invites C + exchanges a message" + local sanity_gid sanity_c_gid sanity_create + sanity_create=$(wn_b --json groups create "sanity-check" "$C_NPUB" 2>>"$LOG_FILE" || true) + sanity_gid=$(printf '%s' "$sanity_create" | jq_group_id) + printf 'sanity groups create raw: %s\n' "$sanity_create" >>"$LOG_FILE" + if [[ -z "$sanity_gid" ]]; then + warn "sanity group create failed — see $LOG_FILE (stale account state? rerun with 'rm -rf state/')" + else + info "sanity group id: $sanity_gid" + if sanity_c_gid=$(wait_for_invite C 30); then + info "kind:10050+1059 ok — C received welcome (gid: $sanity_c_gid)" + wn_c groups accept "$sanity_c_gid" >/dev/null 2>&1 || true + wn_b messages send "$sanity_gid" "sanity-ping" >/dev/null 2>&1 || true + if wait_for_message C "$sanity_c_gid" "sanity-ping" 30; then + info "kind:445 ok — C decrypted sanity-ping" + else + warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)" + warn "Consider rerunning with --local-relays." + fi + # best-effort cleanup so re-runs don't accumulate dead sanity groups + wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true + wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true + else + warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists" + warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)." + fi fi } @@ -357,7 +409,7 @@ that's the bug." step "verifying member list on B" local members - members=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key' 2>/dev/null | sort | tr '\n' ' ') + members=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' 2>/dev/null | sort | tr '\n' ' ') if ! expect_contains "$members" "$A_HEX" "member list" \ || ! expect_contains "$members" "$B_HEX" "member list"; then record_result "02 Amethyst->B create+invite" fail "member list mismatch" @@ -465,7 +517,7 @@ test_04_three_member_group() { step "verifying all three members on all three clients" local members_b - members_b=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key' | tr '\n' ' ') + members_b=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' | tr '\n' ' ') expect_contains "$members_b" "$A_HEX" "B's member list" || : expect_contains "$members_b" "$B_HEX" "B's member list" || : expect_contains "$members_b" "$C_HEX" "B's member list" || : @@ -536,7 +588,7 @@ test_06_member_removal() { local deadline=$(( $(date +%s) + 60 )) removed=0 while [[ $(date +%s) -lt $deadline ]]; do if ! wn_c --json groups members "$gid" 2>/dev/null \ - | jq -e --arg p "$C_HEX" '.[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then + | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then removed=1; break fi sleep 3 @@ -581,7 +633,7 @@ test_07_metadata_rename() { step "polling B for name change (60s)" local deadline=$(( $(date +%s) + 60 )) name="" while [[ $(date +%s) -lt $deadline ]]; do - name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '(.result // .) | .name // empty') [[ "$name" == "Interop-02-renamed" ]] && break sleep 3 done @@ -627,7 +679,7 @@ test_08_admin_promote_demote() { step "verifying admin list contains both B and A" local admins - admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ') if expect_contains "$admins" "$A_HEX" "admin list" && expect_contains "$admins" "$B_HEX" "admin list"; then pass_msg "admin promote worked" else @@ -637,7 +689,7 @@ test_08_admin_promote_demote() { step "B demotes A" wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" sleep 5 - admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ') if [[ "$admins" != *"$A_HEX"* ]]; then pass_msg "demote removed A" record_result "08 admin promote/demote" pass @@ -660,7 +712,7 @@ test_09_reply_react_unreact() { sleep 3 local msg_id msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \ - | jq -r '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // .[0].event_id // empty' 2>/dev/null || true) + | jq -r '(.result // .) | [.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // .[0].event_id // empty' 2>/dev/null || true) if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then fail_msg "could not find anchor message id" record_result "09 reply/react" fail "anchor id missing"; return @@ -747,7 +799,7 @@ test_11_leave_group() { local deadline=$(( $(date +%s) + 60 )) gone=0 while [[ $(date +%s) -lt $deadline ]]; do if ! wn_b --json groups members "$gid" 2>/dev/null \ - | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then + | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then gone=1; break fi sleep 3 From d65b4b0e5dda08f6f9c8387277f51a088b87339c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 15:06:40 +0000 Subject: [PATCH 2/3] fix(marmot-interop): retry gradle :cli:installDist on transient 503s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jitpack.io and dl.google.com both return 503 on a non-trivial fraction of cold-cache fetches, and Gradle disables the whole repository for the rest of the build the moment a single 503 lands — so one bad roll aborts the entire harness before any test gets to run. Wrap the gradle invocation in a 4-attempt retry loop; each attempt resumes from Gradle's local cache so only the still-missing artifacts get re-fetched. In practice the retry completes in seconds when jitpack is the only flake, and the whole preflight still wall-clocks well under the first attempt's runtime. Existing success path is unchanged. https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA --- tools/marmot-interop/headless/setup.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index c58658a65..9f79f3c10 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -19,12 +19,25 @@ preflight() { done # Build `amy` via gradle if missing. + # + # jitpack.io and dl.google.com both return transient 503s on a non-trivial + # fraction of cold-cache fetches, and Gradle disables the entire repository + # for the rest of the build the moment a single 503 lands — so one bad + # roll aborts the whole harness. Retry a few times; each attempt resumes + # from Gradle's cache so only the still-missing artifacts get re-fetched. 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 - step "building :cli:installDist" - ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" + local attempt max_attempts=4 + for attempt in $(seq 1 $max_attempts); do + step "building :cli:installDist (attempt $attempt/$max_attempts)" + if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ + && [[ -x "$AMY_BIN" ]]; then + break + fi + [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" + done fi [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } info "amy: $AMY_BIN" From fc38c4cb5db6c3cb8df5d252d3a56954af56b7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 15:14:04 +0000 Subject: [PATCH 3/3] fix(marmot-interop): repair hunk count in skip-retry patch + harden preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two harness-side bugs that made a broken wn silently look like a working one: 1. whitenoise-skip-unprocessable-retry.patch's hunk header declared `@@ -178,7 +178,23 @@` but the new side only has 22 lines. GNU patch bails with "malformed patch at line 26" and leaves the target file untouched. Fix the count to +178,22 so the patch actually applies. 2. setup.sh's patch-apply loop piped `patch` through `tee`, which masked patch's exit code behind tee's. A miscounted hunk therefore still touched the `.headless-patched-*` marker and the next run skipped the "patch" step entirely, so the resulting wn ran the stock 10-retry exponential backoff on every MlsMessageUnprocessable — ~17min per event, which pegs the per-account serial event processor and makes every later test timeout "just because". Check patch's real exit status; fail preflight loudly instead. Same class of bug was also hiding cargo build failures: the `cargo build ... | tee` pipeline reported success even when rustup couldn't fetch the toolchain manifest (503 from static.rust-lang.org) or cargo couldn't hit the crates.io index (503 from fastly). setup.sh then `info`-logged the expected binary paths and happily moved on — until the daemon launch tripped over `nohup: No such file or directory`. Now each cargo build runs in a 4-attempt retry loop that terminates only when the binary actually exists on disk, and fails preflight loudly if it never materialises. Matches the jitpack 503 retry behaviour already in place for `:cli:installDist`. https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA --- .../whitenoise-skip-unprocessable-retry.patch | 2 +- tools/marmot-interop/headless/setup.sh | 54 ++++++++++++++----- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/tools/marmot-interop/headless/patches/whitenoise-skip-unprocessable-retry.patch b/tools/marmot-interop/headless/patches/whitenoise-skip-unprocessable-retry.patch index c4caaa3d6..875666fc5 100644 --- a/tools/marmot-interop/headless/patches/whitenoise-skip-unprocessable-retry.patch +++ b/tools/marmot-interop/headless/patches/whitenoise-skip-unprocessable-retry.patch @@ -1,6 +1,6 @@ --- a/src/whitenoise/event_processor/account_event_processor.rs +++ b/src/whitenoise/event_processor/account_event_processor.rs -@@ -178,7 +178,23 @@ +@@ -178,7 +178,22 @@ } Err(e) => { // Handle retry logic for actual processing errors diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index 9f79f3c10..a80ed72b6 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -80,26 +80,48 @@ preflight() { "whitenoise-defaults-env.patch" "whitenoise-skip-unprocessable-retry.patch" ) + # Apply each patch with a real exit-code check. The previous version + # swallowed patch's exit status via `| tee`, which meant a miscounted + # hunk header silently left the marker touched and the binary unpatched + # — the resulting wn retried provably-doomed MLS messages for ~17min + # and every later test flapped or timed out. Fail fast instead. for name in "${patches[@]}"; do local marker="$WN_REPO/.headless-patched-${name%.patch}" if [[ ! -f "$marker" ]]; then step "patching whitenoise-rs: $name" - ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \ - <"$SCRIPT_DIR/headless/patches/$name" ) 2>&1 | tee -a "$LOG_FILE" - touch "$marker" - # Invalidate the previous build so the patched source is picked up. - rm -f "$WN_BIN" "$WND_BIN" + if ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \ + <"$SCRIPT_DIR/headless/patches/$name" >>"$LOG_FILE" 2>&1 ); then + touch "$marker" + # Invalidate the previous build so the patched source is picked up. + rm -f "$WN_BIN" "$WND_BIN" + else + fail_msg "patch $name failed — see $LOG_FILE" + tail -n 30 "$LOG_FILE" | sed 's/^/ /' >&2 + exit 1 + fi fi done + # cargo's transitive deps (rustup, crates.io) both return 503 on cold + # caches often enough that a single attempt fails ~30% of the time. + # Retry each cargo build until the binary actually exists or we've + # exhausted the budget — the build is incremental so retries are cheap. if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then fail_msg "wn/wnd not found and --no-build set"; exit 1 fi - step "building wn + wnd with integration-tests feature (~5 min first run)" - ( cd "$WN_REPO" && \ - cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \ - 2>&1 | tee -a "$LOG_FILE" + local attempt max=4 + for attempt in $(seq 1 $max); do + step "building wn + wnd (attempt $attempt/$max, ~5 min first run)" + ( cd "$WN_REPO" && \ + cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \ + 2>&1 | tee -a "$LOG_FILE" + [[ -x "$WN_BIN" && -x "$WND_BIN" ]] && break + [[ "$attempt" -lt "$max" ]] && warn "wn/wnd build failed (likely transient 503 from rustup or crates.io) — retrying" + done + [[ -x "$WN_BIN" && -x "$WND_BIN" ]] || { + fail_msg "wn/wnd still missing after $max build attempts"; exit 1 + } fi info "wn: $WN_BIN" info "wnd: $WND_BIN" @@ -114,9 +136,17 @@ preflight() { git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ 2>&1 | tee -a "$LOG_FILE" fi - step "building nostr-rs-relay (~3 min first run)" - ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ - 2>&1 | tee -a "$LOG_FILE" + 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 (likely transient 503 from crates.io) — retrying" + done + [[ -x "$RELAY_BIN" ]] || { + fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1 + } fi info "relay bin: $RELAY_BIN" }