Files
amethyst/tools/marmot-interop/headless/tests-manage.sh
T
Claude f08b010f50 fix(marmot,cli,interop): interop-compatible Marmot flows and harness correctness
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.

quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
  engine used by whitenoise-rs) strict-rejects v3 payloads with
  `ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
  — our v3 welcomes and GCE commits never apply, so every cross-client
  group flow dies at welcome processing. We still parse v3 happily on
  the way in; we just don't emit it until mdk publishes the
  forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
  REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
  extension list; callers that pass only [marmot_group_data] dropped
  [required_capabilities], which peers then reject. Preserve every slot
  except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
  that bakes MarmotGroupData into epoch-0 GroupContext.extensions
  directly. Without it, creators had to publish a pre-membership
  "bootstrap" commit that no later joiner could decrypt — each such
  peer then burned their retry budget on an undecryptable kind:445
  before seeing the real state. Threaded through MlsGroup.create /
  MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
  juggling the MIP-01 nostr_group_id (what amy indexes on) and the
  MLS GroupContext groupId (what mdk indexes on) can cross-reference
  them without reaching into MlsGroupManager directly.

amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
  and promote a surviving member to admin first if the caller is the
  sole admin (otherwise we'd throw "admin depletion"). Matches the
  cli/GroupMembershipCommands.leave flow.

cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
  (so the first-ever sync doesn't bump the cursor past every
  past-timestamped wrap we've ever been sent), subtract 2 days lookback
  when filtering (NIP-59 randomWithTwoDays gift wraps can have any
  createdAt in the last 48h), and only advance `groupSince` for groups
  we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
  KeyPackage, rotate and publish a fresh one immediately. MIP-00
  requires this — a KP can only be welcomed once and leaving the
  consumed one on relays just means later senders invite us with a
  bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
  key) or the MLS GroupContext groupId (what wn emits) on every verb
  that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
  /Read, Message send/list, and all the await* verbs so harness
  scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
  quartz change above). Dropped the now-redundant bootstrap commit
  publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
  promote an heir if we're the only admin, otherwise the GCE would
  deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
  wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
  only unwrapped once and then checked `inner.kind == 444`, which is
  always false because inner is actually the seal. Unseal once more
  before the Welcome check. This single fix is what unsticks every
  amy-side Welcome ingestion.

wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
  `Relay::defaults()` (release builds otherwise bake damus.io / primal
  / nos.lol into every new account's NIP-65 / Inbox / KeyPackage
  lists, which breaks publishing and prevents the inbox subscription
  plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
  nostr-rs-relay has a chance to fsync before wn's first targeted
  discovery query. Without it wn's `keys check` races the relay's
  WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
  `wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
  wn `--json` output nests everything under `.result` (and
  `groups invites[]` nests further under `.group.mls_group_id`) —
  these helpers were still pattern-matching on the flat shape, so
  they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
  (amy's nostr + wn's MLS), pass each CLI the id it understands, and
  bump the post-commit wait timeouts to 90–120s so wn's exponential
  retry backoff has time to work through the pre-membership commits
  it can't decrypt and get to the ones it can.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 00:28:45 +00:00

174 lines
6.0 KiB
Bash

# shellcheck shell=bash
#
# headless/tests-manage.sh — tests 06, 07, 08, 11.
# Focus: removal, metadata rename, admin promote/demote, leave.
test_06_member_removal() {
banner "Test 06 — Member removal + forward secrecy"
local id="06 member removal"
# MIP-03 only admins may commit Remove proposals. In GROUP_05 (wn-created
# by B, A joined later) A is not an admin, so the test used to fail with
# `IllegalStateException: non-admin members may only commit...`. Test on
# GROUP_02 instead, where amy is the creator and therefore sole admin,
# and where test 04 has already added C. amy calls use the nostr id,
# wn calls use the MLS id.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || {
record_result "$id" fail "amy remove C failed"; return
}
# C should no longer see the group on its own member view.
local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break
fi
sleep 3
done
if [[ "$removed" -ne 1 ]]; then
warn "C still appears as a member — continuing"
fi
amy_json marmot message send "$gid" "after removing C" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
wait_for_message B "$mls_gid" "after removing C" 90 || {
record_result "$id" fail "B lost access after C's removal"; return
}
# Forward secrecy: C must NOT see the post-removal message.
sleep 5
if wait_for_message C "$mls_gid" "after removing C" 10; then
record_result "$id" fail "C still decrypted a post-removal message"
else
record_result "$id" pass
fi
}
test_07_metadata_rename() {
banner "Test 07 — Metadata rename round-trip (MIP-01)"
local id="07 metadata rename"
# amy was the creator of GROUP_02 so its own nostr_group_id is saved as
# GROUP_02; wn keys its copy by the MLS group id saved as GROUP_02_MLS.
# Pass each CLI the id it understands.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || {
record_result "$id" fail "amy rename failed"; return
}
local deadline=$(( $(date +%s) + 120 )) seen=""
while [[ $(date +%s) -lt $deadline ]]; do
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .name // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3
done
[[ "$seen" == "Interop-02-renamed" ]] || {
record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return
}
# Now B renames back and A should pick it up.
wn_b groups rename "$mls_gid" "Interop-02-reverse" >/dev/null 2>&1 || true
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 120 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "A did not pick up B's rename"
fi
}
test_08_admin_promote_demote() {
banner "Test 08 — Admin promote / demote"
local id="08 admin promote/demote"
# GROUP_03 was created by wn so both sides need different ids:
# GROUP_03 → amy's nostr_group_id (captured in test 03 after
# `amy await group` returned `.group_id`)
# GROUP_03_MLS → wn's mls_group_id (wn's `groups create` output)
local a_gid mls_gid
a_gid=$(load_state GROUP_03 || true)
mls_gid=$(load_state GROUP_03_MLS || true)
if [[ -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_03"; return
fi
# Ensure 3 members (add C if missing).
wn_c keys publish >/dev/null 2>&1 || true
sleep 2
wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
# B promotes A.
wn_b groups promote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn promote failed"; return
}
# A should reflect the new admin set — poll via amy.
if ! amy_json marmot await admin "$a_gid" "$A_NPUB" --timeout 90 >/dev/null; then
record_result "$id" fail "A never saw itself promoted"; return
fi
# A now commits a rename — only possible if we're admin.
amy_json marmot group rename "$a_gid" "Interop-03-by-A" >/dev/null || {
record_result "$id" fail "A (now admin) could not rename"; return
}
# B demotes A.
wn_b groups demote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
sleep 5
local admins
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | .[]?.pubkey // .[]?.public_key // .[]?' | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote"
else
record_result "$id" pass
fi
}
test_11_leave_group() {
banner "Test 11 — Leave group"
local id="11 leave group"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group leave "$gid" >/dev/null || {
record_result "$id" fail "amy leave failed"; return
}
local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
gone=1; break
fi
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "A still in B's member list after leave"
fi
}